diff --git a/src/charm.py b/src/charm.py index 445c8ec..d60854f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -54,6 +54,7 @@ def __init__(self, *args): self.framework.observe(self.on.update_status, self._configure) self.framework.observe(self.on.config_changed, self._configure) self.framework.observe(self.on.secret_expired, self._configure) + self.framework.observe(self.on.secret_changed, self._configure) self.framework.observe( self.tls_certificates.on.certificate_creation_request, self._on_certificate_creation_request, @@ -188,6 +189,7 @@ def _configure(self, event: EventBase) -> None: self._generate_root_certificate() self.tls_certificates.revoke_all_certificates() logger.info("Revoked all previously issued certificates.") + return self._send_ca_cert() self._process_outstanding_certificate_requests() @@ -196,7 +198,7 @@ def _root_certificate_matches_config(self) -> bool: if not self._config_ca_common_name: raise ValueError("CA common name should not be empty") ca_certificate_secret = self.model.get_secret(label=CA_CERTIFICATES_SECRET_LABEL) - ca_certificate_secret_content = ca_certificate_secret.get_content() + ca_certificate_secret_content = ca_certificate_secret.get_content(refresh=True) ca = ca_certificate_secret_content["ca-certificate"].encode() return certificate_has_common_name(certificate=ca, common_name=self._config_ca_common_name) @@ -255,7 +257,7 @@ def _generate_self_signed_certificate(self, csr: str, is_ca: bool, relation_id: relation_id (int): Relation id """ ca_certificate_secret = self.model.get_secret(label=CA_CERTIFICATES_SECRET_LABEL) - ca_certificate_secret_content = ca_certificate_secret.get_content() + ca_certificate_secret_content = ca_certificate_secret.get_content(refresh=True) certificate = generate_certificate( ca=ca_certificate_secret_content["ca-certificate"].encode(), ca_key=ca_certificate_secret_content["private-key"].encode(), @@ -283,7 +285,7 @@ def _on_get_ca_certificate(self, event: ActionEvent): event.fail("Root Certificate is not yet generated") return ca_certificate_secret = self.model.get_secret(label=CA_CERTIFICATES_SECRET_LABEL) - ca_certificate_secret_content = ca_certificate_secret.get_content() + ca_certificate_secret_content = ca_certificate_secret.get_content(refresh=True) event.set_results({"ca-certificate": ca_certificate_secret_content["ca-certificate"]}) def _on_send_ca_cert_relation_joined(self, event: RelationJoinedEvent): @@ -298,7 +300,7 @@ def _send_ca_cert(self, *, rel_id=None): send_ca_cert = CertificateTransferProvides(self, SEND_CA_CERT_REL_NAME) if self._root_certificate_is_stored: secret = self.model.get_secret(label=CA_CERTIFICATES_SECRET_LABEL) - secret_content = secret.get_content() + secret_content = secret.get_content(refresh=True) ca = secret_content["ca-certificate"] if rel_id: send_ca_cert.set_certificate("", ca, [], relation_id=rel_id) diff --git a/tests/integration/certificates.py b/tests/integration/certificates.py new file mode 100644 index 0000000..c93ed16 --- /dev/null +++ b/tests/integration/certificates.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +from cryptography import x509 + + +def get_common_name_from_certificate(certificate: bytes) -> str: + loaded_certificate = x509.load_pem_x509_certificate(certificate) + return str(loaded_certificate.subject.get_attributes_for_oid( + x509.oid.NameOID.COMMON_NAME # type: ignore[reportAttributeAccessIssue] + )[0].value) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index d872a04..89732ea 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -4,10 +4,13 @@ import logging +import time from pathlib import Path +from typing import Dict import pytest import yaml +from certificates import get_common_name_from_certificate from pytest_operator.plugin import OpsTest logger = logging.getLogger(__name__) @@ -16,6 +19,27 @@ APP_NAME = METADATA["name"] TLS_REQUIRER_CHARM_NAME = "tls-certificates-requirer" +CA_COMMON_NAME = "example.com" + + +async def wait_for_requirer_ca_certificate(ops_test: OpsTest, ca_common_name: str) -> None: + """Wait for the certificate to be provided to the `tls-requirer-requirer/0` unit.""" + t0 = time.time() + timeout = 300 + while time.time() - t0 < timeout: + logger.info("Waiting for CA certificate with common name %s", ca_common_name) + time.sleep(5) + action_output = await run_get_certificate_action(ops_test) + ca_certificate = action_output.get("ca-certificate", "") + if not ca_certificate: + continue + existing_ca_common_name = get_common_name_from_certificate(ca_certificate.encode()) + if existing_ca_common_name != ca_common_name: + logger.info("Existing CA Common Name: %s", existing_ca_common_name) + continue + logger.info("Certificate with CA common name %s provided", ca_common_name) + return + raise TimeoutError("Timed out waiting for certificate") @pytest.fixture(scope="module") @@ -29,6 +53,7 @@ async def build_and_deploy(ops_test: OpsTest): application_name=APP_NAME, series="jammy", trust=True, + config={"ca-common-name": CA_COMMON_NAME}, ) await ops_test.model.deploy( TLS_REQUIRER_CHARM_NAME, @@ -50,12 +75,12 @@ async def test_given_charm_is_built_when_deployed_then_status_is_active( ) -async def test_given_tls_requirer_is_deployed_and_related_then_certificate_is_created_and_passed_correctly( # noqa: E501 +async def test_given_tls_requirer_is_deployed_when_integrated_then_certificate_is_provided( ops_test: OpsTest, build_and_deploy, ): assert ops_test.model - await ops_test.model.add_relation( + await ops_test.model.integrate( relation1=f"{APP_NAME}:certificates", relation2=f"{TLS_REQUIRER_CHARM_NAME}" ) await ops_test.model.wait_for_idle( @@ -63,10 +88,24 @@ async def test_given_tls_requirer_is_deployed_and_related_then_certificate_is_cr status="active", timeout=1000, ) - action_output = await run_get_certificate_action(ops_test) - assert action_output["certificate"] is not None - assert action_output["ca-certificate"] is not None - assert action_output["csr"] is not None + await wait_for_requirer_ca_certificate(ops_test=ops_test, ca_common_name=CA_COMMON_NAME) + +async def test_given_tls_requirer_is_integrated_when_ca_common_name_config_changed_then_new_certificate_is_provided( # noqa: E501 + ops_test: OpsTest, + build_and_deploy, +): + new_common_name = "newexample.org" + assert ops_test.model + application = ops_test.model.applications[APP_NAME] + assert application + await application.set_config({"ca-common-name": new_common_name}) + await ops_test.model.wait_for_idle( + apps=[APP_NAME, TLS_REQUIRER_CHARM_NAME], + status="active", + timeout=1000, + ) + + await wait_for_requirer_ca_certificate(ops_test=ops_test, ca_common_name=new_common_name) async def test_given_charm_scaled_then_charm_does_not_crash( @@ -80,7 +119,7 @@ async def test_given_charm_scaled_then_charm_does_not_crash( await ops_test.model.wait_for_idle(apps=[APP_NAME], timeout=1000, wait_for_exact_units=1) -async def run_get_certificate_action(ops_test) -> dict: +async def run_get_certificate_action(ops_test) -> Dict[str, str]: """Run `get-certificate` on the `tls-requirer-requirer/0` unit. Args: @@ -91,8 +130,6 @@ async def run_get_certificate_action(ops_test) -> dict: """ assert ops_test.model tls_requirer_unit = ops_test.model.units[f"{TLS_REQUIRER_CHARM_NAME}/0"] - action = await tls_requirer_unit.run_action( - action_name="get-certificate", - ) + action = await tls_requirer_unit.run_action(action_name="get-certificate") action_output = await ops_test.model.get_action_output(action_uuid=action.entity_id, wait=240) return action_output diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 5fae883..4ae85b8 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -110,32 +110,67 @@ def test_given_valid_config_when_config_changed_then_status_is_active( self.assertEqual(self.harness.model.unit.status, ActiveStatus()) - @patch(f"{TLS_LIB_PATH}.TLSCertificatesProvidesV3.set_relation_certificate") - @patch(f"{TLS_LIB_PATH}.TLSCertificatesProvidesV3.get_outstanding_certificate_requests") + @patch("charm.certificate_has_common_name") @patch("charm.generate_private_key") @patch("charm.generate_password") @patch("charm.generate_ca") - @patch("charm.generate_certificate") - def test_given_outstanding_certificate_requests_when_config_changed_then_requests_processed( + def test_given_new_common_name_when_config_changed_then_new_root_ca_is_stored( self, - patch_generate_certificate, patch_generate_ca, patch_generate_password, patch_generate_private_key, - patch_get_outstanding_certificate_requests, - patch_set_relation_certificate, + patch_certificate_has_common_name, ): validity = 100 - relation_id = 123 - ca = "whatever CA certificate" + initial_ca = "whatever initial CA certificate" + new_ca = "whatever CA certificate" private_key_password = "password" private_key = "whatever private key" - requirer_csr = "whatever CSR" - requirer_is_ca = True - generated_certificate = "whatever certificate" - patch_generate_ca.return_value = ca.encode() + patch_certificate_has_common_name.return_value = False + self.harness._backend.secret_add( + label="ca-certificates", + content={ + "ca-certificate": initial_ca, + "private-key": private_key, + "private-key-password": private_key_password, + }, + ) + patch_generate_ca.return_value = new_ca.encode() patch_generate_password.return_value = private_key_password patch_generate_private_key.return_value = private_key.encode() + + key_values = {"ca-common-name": "pizza.com", "certificate-validity": validity} + self.harness.set_leader(is_leader=True) + + self.harness.update_config(key_values=key_values) + + secret =self.harness.model.get_secret(label="ca-certificates") + secret_content = secret.get_content(refresh=True) + assert secret_content["ca-certificate"] == new_ca + + @patch("charm.certificate_has_common_name") + @patch(f"{TLS_LIB_PATH}.TLSCertificatesProvidesV3.set_relation_certificate") + @patch(f"{TLS_LIB_PATH}.TLSCertificatesProvidesV3.get_outstanding_certificate_requests") + @patch("charm.generate_certificate") + def test_given_outstanding_certificate_requests_when_secret_changed_then_certificates_are_generated( # noqa: E501 + self, + patch_generate_certificate, + patch_get_outstanding_certificate_requests, + patch_set_relation_certificate, + patch_certificate_has_common_name, + ): + private_key = "whatever" + private_key_password = "whatever" + ca = "whatever CA certificate" + requirer_csr = "whatever CSR" + requirer_is_ca = False + generated_certificate = "whatever certificate" + patch_certificate_has_common_name.return_value = True + self.harness.set_leader(is_leader=True) + relation_id = self.harness.add_relation( + relation_name="certificates", remote_app="tls-requirer" + ) + self.harness.add_relation_unit(relation_id=relation_id, remote_unit_name="tls-requirer/0") patch_get_outstanding_certificate_requests.return_value = [ RequirerCSR( relation_id=relation_id, @@ -146,19 +181,18 @@ def test_given_outstanding_certificate_requests_when_config_changed_then_request ), ] patch_generate_certificate.return_value = generated_certificate.encode() - key_values = {"ca-common-name": "pizza.com", "certificate-validity": validity} - self.harness.set_leader(is_leader=True) - - self.harness.update_config(key_values=key_values) - patch_generate_certificate.assert_called_with( - ca=ca.encode(), - ca_key=private_key.encode(), - ca_key_password=private_key_password.encode(), - csr=requirer_csr.encode(), - validity=validity, - is_ca=requirer_is_ca, + self.harness._backend.secret_add( + label="ca-certificates", + content={ + "ca-certificate": ca, + "private-key": private_key, + "private-key-password": private_key_password, + }, ) + event = Mock() + self.harness.charm._configure(event) + patch_set_relation_certificate.assert_called_with( certificate_signing_request=requirer_csr, certificate=generated_certificate,