Skip to content

Commit

Permalink
Enable pop workflows (#21)
Browse files Browse the repository at this point in the history
* add possibility to use pop workflows on renew, update and revoke

* update documentation for pop workflow

* feat(horizon_template): implem (#17) (#20)

* Add action horizon_template

* Fix decentralized renew

* Add samples with the new action horizon_template

* prepare release 1.5.0
  • Loading branch information
AdrienDucourthial authored Dec 2, 2024
1 parent c2f9e04 commit e4127cc
Show file tree
Hide file tree
Showing 20 changed files with 251 additions and 49 deletions.
32 changes: 17 additions & 15 deletions .github/workflows/run_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ on:

jobs:
test:
runs-on: ${{ vars.RUNS_ON }}
runs-on: ubuntu-latest
steps:
- name: Perform integration testing with ansible-test
uses: ansible-community/ansible-test-gh-action@release/v1
- name: Checkout code
uses: actions/checkout@v4
with:
ansible-core-version: stable-2.14
target-python-version: 3.9
testing-type: integration
test-deps: community.crypto
pre-test-cmd: >-
echo "[endpoint: ${{ secrets.HORIZON_ENDPOINT }}, x_api_id: ${{ secrets.HORIZON_API_ID }}, x_api_key: ${{ secrets.HORIZON_API_KEY }}]" >> tests/integration/integration_config.yml
submodules: true
fetch-depth: 0

- name: Perform unit testing with ansible-test
uses: ansible-community/ansible-test-gh-action@release/v1
with:
ansible-core-version: stable-2.14
target-python-version: 3.9
testing-type: units
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ansible
- name: Set integration_config.yml
run: >-
echo "[endpoint: ${{ secrets.HORIZON_ENDPOINT }}, x_api_id: ${{ secrets.HORIZON_API_ID }}, x_api_key: ${{ secrets.HORIZON_API_KEY }}]" >> tests/integration/integration_config.yml
- name: Run tests
run: make test
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
ANSIBLEGALAXYBUILD ?= ansible-galaxy
ANSIBLEGALAXYOPTS ?= --token="$(ANSIBLE_GALAXY_API_TOKEN)"
ANTSIBULLBUILD ?= antsibull-docs
ANTSIBULLOPTS ?=
ANTSIBULLOPTS ?=
SPHINXBUILD ?= sphinx-build
SPHINXOPTS ?=
ANSIBLETEST ?= ansible-test
COLLECTIONPATH ?= ~/.ansible/collections/ansible_collections/evertrust/horizon

.PHONY: docs

Expand All @@ -16,10 +18,20 @@ docs:
build: clean
@$(ANSIBLEGALAXYBUILD) "collection" "build" "--output-path=build/" $(ANSIBLEGALAXYPOTS)

install:
ARTIFACT=$$(find "build" -name "*.tar.gz" ); \
echo $$ARTIFACT; \
$(ANSIBLEGALAXYBUILD) "collection" "install" $$ARTIFACT

clean: ## Clean build artifacts
@rm -f build/*

publish: build ## Build and publish build artifact to Ansible Galaxy
ARTIFACT=$$(find "build" -name "*.tar.gz" ); \
echo $$ARTIFACT; \
$(ANSIBLEGALAXYBUILD) "collection" "publish" $$ARTIFACT $(ANSIBLEGALAXYOPTS)

test: build install
cd $(COLLECTIONPATH); \
$(ANSIBLETEST) "integration"; \
$(ANSIBLETEST) "units"
1 change: 1 addition & 0 deletions meta/runtime.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ action_groups:
- horizon_feed
- horion_inventory
- horizon_lookup
- horizon_template
- horizon
2 changes: 1 addition & 1 deletion plugins/action/horizon_renew.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ActionModule(HorizonAction):
TRANSFERS_FILES = True

def _args(self):
return ['certificate_id', 'certificate_pem', 'password', 'csr']
return ['certificate_id', 'certificate_pem', 'password', 'csr', 'private_key']

def run(self, tmp=None, task_vars=None):
result = super(ActionModule, self).run(tmp, task_vars)
Expand Down
2 changes: 1 addition & 1 deletion plugins/action/horizon_revoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ActionModule(HorizonAction):
TRANSFERS_FILES = True

def _args(self):
return ["certificate_pem", "certificate_id", "revocation_reason", "skip_already_revoked"]
return ["certificate_pem", "certificate_id", "revocation_reason", "skip_already_revoked", "private_key"]

def run(self, tmp=None, task_vars=None):
result = super(ActionModule, self).run(tmp, task_vars)
Expand Down
2 changes: 1 addition & 1 deletion plugins/action/horizon_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ActionModule(HorizonAction):
TRANSFERS_FILES = True

def _args(self):
return ["labels", "certificate_pem", "metadata", "owner", "team", "contact_email"]
return ["labels", "certificate_pem", "metadata", "owner", "team", "contact_email", "private_key"]

def run(self, tmp=None, task_vars=None):
result = super(ActionModule, self).run(tmp, task_vars)
Expand Down
45 changes: 36 additions & 9 deletions plugins/module_utils/horizon.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import requests
from ansible.errors import AnsibleError
from ansible_collections.evertrust.horizon.plugins.module_utils.horizon_crypto import HorizonCrypto
from ansible_collections.evertrust.horizon.plugins.module_utils.horizon_errors import HorizonError
from ansible.utils.display import Display

Expand All @@ -23,7 +24,7 @@ class Horizon:
DISCOVERY_FEED_URL = "/api/v1/discovery/feed"
RFC5280_TC_URL = "/api/v1/rfc5280/tc/"

def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=None, client_key=None, ca_bundle=None):
def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=None, client_key=None, ca_bundle=None, private_key=None):
"""
Initialize client with endpoint and authentication parameters
:type endpoint: str
Expand Down Expand Up @@ -51,7 +52,7 @@ def __init__(self, endpoint=None, x_api_id=None, x_api_key=None, client_cert=Non
elif x_api_id is not None and x_api_key is not None:
self.headers = {"x-api-id": str(x_api_id), "x-api-key": str(x_api_key)}

else:
elif private_key is None: # Authorizing missin authent parameters for pop request
raise AnsibleError("Please inform authentication parameters : 'x_api_id' and 'x_api_key' or 'client_cert' and 'client_key'.")

def enroll(self, profile, template, mode=None, csr=None, password=None, key_type=None, labels=None, metadata=None,
Expand Down Expand Up @@ -155,20 +156,24 @@ def recover(self, certificate_pem, password):

return self.post(self.REQUEST_SUBMIT_URL, json)

def renew(self, certificate_pem, certificate_id, password=None, csr=None):
def renew(self, certificate_pem, certificate_id, password=None, csr=None, private_key=None):
"""
Renew a certificate
:type certificate_pem: Union[str,dict]
:type certificate_id: str
:rtype: dict
"""
csr = self.__load_file_or_string(csr)
cert = self.__load_file_or_string(certificate_pem)
if private_key is not None:
key = self.__load_file_or_string(private_key)
self.set_jwt_headers(cert, key)

json = {
"module": "webra",
"workflow": "renew",
"certificateId": certificate_id,
"certificatePem": self.__load_file_or_string(certificate_pem),
"certificatePem": cert,
"template": {
"csr": csr
}
Expand All @@ -180,17 +185,22 @@ def renew(self, certificate_pem, certificate_id, password=None, csr=None):

return self.post(self.REQUEST_SUBMIT_URL, json)

def revoke(self, certificate_pem, certificate_id, revocation_reason):
def revoke(self, certificate_pem, certificate_id, revocation_reason, private_key=None):
"""
Revoke a certificate
:type certificate_pem: Union[str,dict]
:type revocation_reason: str
:rtype: dict
"""
cert = self.__load_file_or_string(certificate_pem)
if private_key is not None:
key = self.__load_file_or_string(private_key)
self.set_jwt_headers(cert, key)

# Duplication of value is to support older API versions
json = {
"workflow": "revoke",
"certificatePem": self.__load_file_or_string(certificate_pem),
"certificatePem": cert,
"certificateId": certificate_id,
"revocationReason": revocation_reason,
"template": {
Expand All @@ -200,7 +210,7 @@ def revoke(self, certificate_pem, certificate_id, revocation_reason):

return self.post(self.REQUEST_SUBMIT_URL, json)

def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=None, contact_email=None):
def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=None, contact_email=None, private_key=None):
"""
Update a certificate
:param metadata:
Expand All @@ -215,9 +225,14 @@ def update(self, certificate_pem, labels=None, metadata=None, owner=None, team=N
if labels is None:
labels = {}

cert = self.__load_file_or_string(certificate_pem)
if private_key is not None:
key = self.__load_file_or_string(private_key)
self.set_jwt_headers(cert, key)

json = {
"workflow": "update",
"certificatePem": self.__load_file_or_string(certificate_pem),
"certificatePem": cert,
"template": {
"metadata": self.__set_metadata(metadata),
"labels": self.__set_labels(labels)
Expand Down Expand Up @@ -498,6 +513,12 @@ def send(self, method, path, **kwargs):
method = method.upper()
try:
response = requests.request(method, uri, cert=self.cert, verify=self.bundle, headers=self.headers, **kwargs)
if "Replay-Nonce" in response.headers:
nonce = response.headers["Replay-Nonce"]
valid_jwt_token = HorizonCrypto.generate_jwt_token(self.certificate, self.private_key, nonce)
self.headers["X-JWT-CERT-POP"] = valid_jwt_token
response = requests.request(method, uri, cert=self.cert, verify=self.bundle, headers=self.headers, **kwargs)

except requests.exceptions.SSLError:
raise AnsibleError("Got an SSL error try using the 'ca_bundle' paramater")

Expand Down Expand Up @@ -771,4 +792,10 @@ def get_password(self, password_policy):
Get a password from password_policy
:param password_policy
"""
return self.send('GET', "/api/v1/security/passwordpolicies/"+password_policy+"/generate")
return self.send('GET', "/api/v1/security/passwordpolicies/"+password_policy+"/generate")

def set_jwt_headers(self, cert, key):
jwt_token = HorizonCrypto.generate_jwt_token(cert, key, "")
self.headers = {"X-JWT-CERT-POP": jwt_token}
self.certificate = cert
self.private_key = key
2 changes: 1 addition & 1 deletion plugins/module_utils/horizon_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def _args(self):
return []

def _auth_args(self):
return ["endpoint", "x_api_id", "x_api_key", "ca_bundle", "client_cert", "client_key"]
return ["endpoint", "x_api_id", "x_api_key", "ca_bundle", "client_cert", "client_key", "private_key"]

def _get_auth(self):
auth = {}
Expand Down
40 changes: 39 additions & 1 deletion plugins/module_utils/horizon_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@
import base64
import secrets
import re
import jwt
import time
import hashlib
import time
import json

from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ec
from cryptography.hazmat.primitives.asymmetric import rsa, ec, padding
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.hazmat.backends import default_backend


class HorizonCrypto:

Expand Down Expand Up @@ -134,3 +142,33 @@ def generate_key_pair(key_type):
public_key = private_key.public_key()

return private_key, public_key

@staticmethod
def generate_jwt_token(cert, private_key, nonce=""):
# Define payload
jwt_payload = {
"sub": cert,
"iat": int(time.time()),
"exp": int(time.time()) + 5
}
if nonce != "":
jwt_payload["nonce"] = nonce

# Load the private key from PEM format if it's a string
if isinstance(private_key, str):
private_key = private_key.encode('utf-8')
key = serialization.load_pem_private_key(private_key, password=None, backend=default_backend())

# Determine algorithm based on key type
if isinstance(key, rsa.RSAPrivateKey):
signing_method = "RS256"
elif isinstance(key, ec.EllipticCurvePrivateKey):
signing_method = "ES256"
else:
raise ValueError("Unsupported key type")

# Use PyJWT to encode and sign the token
jwt_token = jwt.encode(jwt_payload, private_key, algorithm=signing_method)

return jwt_token

25 changes: 23 additions & 2 deletions plugins/modules/horizon_renew.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
description: The path to a CSR file
required: false
type: path
password:
description: Security password of the certificate.
required: false
type: str
private_key:
description: The PEM encoded private key associated to the certificate.
required: false
type: str
suboptions:
src:
description: The path to the PEM encoded private key associated to the certificate.
required: false
type: path
'''

# language=yaml
Expand All @@ -47,7 +60,7 @@
x_api_id: "<horizon-id>"
x_api_key: "<horizon-password>"
certificate_pem:
src: pem/file/path
src: path/to/pem
- name: renew a certificate from his ID
evertrust.horizon.horizon_renew:
Expand All @@ -63,7 +76,15 @@
x_api_key: "<horizon-password>"
certificate_id: <id>
csr:
src: csr/file/path
src: path/to/csr
- name: renew a certificate with pop
evertrust.horizon_renew:
endpoint: "https://<horizon-endpoint>"
certificate_pem:
src: path/to/pem
private_key:
src: path/to/key
'''


Expand Down
19 changes: 18 additions & 1 deletion plugins/modules/horizon_revoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
description: The ID of the certificate to revoke.
required: false
type: str
private_key:
description: The PEM encoded private key associated to the certificate.
required: false
type: str
suboptions:
src:
description: The path to the PEM encoded private key associated to the certificate.
required: false
type: path
revocation_reason:
description: The reason for revoking the certificate.
required: false
Expand Down Expand Up @@ -64,7 +73,15 @@
x_api_id: "<horizon-id>"
x_api_key: "<horizon-password>"
certificate_pem:
src: /pem/file/path
src: path/to/pem
- name: Revoke a certificate with pop
evertrust.horizon.horizon_revoke:
endpoint: "https://<horizon-endpoint>"
certificate_pem:
src: path/to/pem
private_key:
src: path/to/key
'''

# language=yaml
Expand Down
Loading

0 comments on commit e4127cc

Please sign in to comment.