Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: generate new ca cert on config changed #140

Merged
merged 2 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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)
gruyaume marked this conversation as resolved.
Show resolved Hide resolved
ca = ca_certificate_secret_content["ca-certificate"].encode()
return certificate_has_common_name(certificate=ca, common_name=self._config_ca_common_name)

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions tests/integration/certificates.py
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 47 additions & 10 deletions tests/integration/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
Dismissed Show dismissed Hide dismissed
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")
Expand All @@ -29,6 +53,7 @@
application_name=APP_NAME,
series="jammy",
trust=True,
config={"ca-common-name": CA_COMMON_NAME},
)
await ops_test.model.deploy(
TLS_REQUIRER_CHARM_NAME,
Expand All @@ -50,23 +75,37 @@
)


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(
apps=[TLS_REQUIRER_CHARM_NAME],
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(
Expand All @@ -80,7 +119,7 @@
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:
Expand All @@ -91,8 +130,6 @@
"""
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
82 changes: 58 additions & 24 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading