diff --git a/charmcraft.yaml b/charmcraft.yaml index 900d34a2..2fc7d3fe 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -9,3 +9,7 @@ bases: run-on: - name: ubuntu channel: "22.04" +parts: + charm: + charm-binary-python-packages: + - cryptography diff --git a/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py new file mode 100644 index 00000000..44ddfdae --- /dev/null +++ b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py @@ -0,0 +1,390 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the certificate_transfer relation. + +This library contains the Requires and Provides classes for handling the +ertificate-transfer interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.certificate_transfer_interface.v0.certificate_transfer +``` + +### Provider charm +The provider charm is the charm providing public certificates to another charm that requires them. + +Example: +```python +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main + +from lib.charms.certificate_transfer_interface.v0.certificate_transfer import CertificateTransferProvides # noqa: E501 W505 + + +class DummyCertificateTransferProviderCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferProvides(self, "certificates") + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent): + certificate = "my certificate" + ca = "my CA certificate" + chain = ["certificate 1", "certificate 2"] + self.certificate_transfer.set_certificate(certificate=certificate, ca=ca, chain=chain, relation_id=event.relation.id) + + +if __name__ == "__main__": + main(DummyCertificateTransferProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. + +Example: +```python + +from ops.charm import CharmBase +from ops.main import main + +from lib.charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateAvailableEvent, + CertificateRemovedEvent, + CertificateTransferRequires, +) + + +class DummyCertificateTransferRequirerCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.certificate_transfer = CertificateTransferRequires(self, "certificates") + self.framework.observe( + self.certificate_transfer.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certificate_transfer.on.certificate_removed, self._on_certificate_removed + ) + + def _on_certificate_available(self, event: CertificateAvailableEvent): + print(event.certificate) + print(event.ca) + print(event.chain) + print(event.relation_id) + + def _on_certificate_removed(self, event: CertificateRemovedEvent): + print(event.relation_id) + + +if __name__ == "__main__": + main(DummyCertificateTransferRequirerCharm) +``` + +You can relate both charms by running: + +```bash +juju relate +``` + +""" + + +import json +import logging +from typing import List + +from jsonschema import exceptions, validate # type: ignore[import-untyped] +from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent +from ops.framework import EventBase, EventSource, Handle, Object + +# The unique Charmhub library identifier, never change it +LIBID = "3785165b24a743f2b0c60de52db25c8b" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 5 + +PYDEPS = ["jsonschema"] + + +logger = logging.getLogger(__name__) + + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://canonical.github.io/charm-relation-interfaces/interfaces/certificate_transfer/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`certificate_transfer` provider schema", + "description": "The `certificate_transfer` root schema comprises the entire provider application databag for this interface.", # noqa: E501 + "default": {}, + "examples": [ + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUW42TU9LSjEZLMCclWrvSwAsgRtcwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDMxOVoXDTI0MDMyMzE4NDMxOVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJGUw\nNjVmMWI3LTE2OWEtNDE5YS1iNmQyLTc3OWJkOGM4NzIwNjCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAK42ixoklDH5K5i1NxXo/AFACDa956pE5RA57wlC\nBfgUYaIDRmv7TUVJh6zoMZSD6wjSZl3QgP7UTTZeHbvs3QE9HUwEkH1Lo3a8vD3z\neqsE2vSnOkpWWnPbfxiQyrTm77/LAWBt7lRLRLdfL6WcucD3wsGqm58sWXM3HG0f\nSN7PHCZUFqU6MpkHw8DiKmht5hBgWG+Vq3Zw8MNaqpwb/NgST3yYdcZwb58G2FTS\nZvDSdUfRmD/mY7TpciYV8EFylXNNFkth8oGNLunR9adgZ+9IunfRKj1a7S5GSwXU\nAZDaojw+8k5i3ikztsWH11wAVCiLj/3euIqq95z8xGycnKcCAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEAWMvcaozgBrZ/MAxzTJmp5gZyLxmMNV6iT9dcqbwzDtDtBvA/\n46ux6ytAQ+A7Bd3AubvozwCr1Id6g66ae0blWYRRZmF8fDdX/SBjIUkv7u9A3NVQ\nXN9gsEvK9pdpfN4ZiflfGSLdhM1STHycLmhG6H5s7HklbukMRhQi+ejbSzm/wiw1\nipcxuKhSUIVNkTLusN5b+HE2gwF1fn0K0z5jWABy08huLgbaEKXJEx5/FKLZGJga\nfpIzAdf25kMTu3gggseaAmzyX3AtT1i8A8nqYfe8fnnVMkvud89kq5jErv/hlMC9\n49g5yWQR2jilYYM3j9BHDuB+Rs+YS5BCep1JnQ==\n-----END CERTIFICATE-----\n", # noqa: E501 + "ca": "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUdiBwE/CtaBXJl3MArjZen6Y8kigwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDg1OVoXDTI0MDMyMzE4NDg1OVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJDEw\nMDdjNDBhLWUwYzMtNDVlOS05YTAxLTVlYjY0NWQ0ZmEyZDCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANOnUl6JDlXpLMRr/PxgtfE/E5Yk6E/TkPkPL/Kk\ntUGjEi42XZDg9zn3U6cjTDYu+rfKY2jiitfsduW6DQIkEpz3AvbuCMbbgnFpcjsB\nYysLSMTmuz/AVPrfnea/tQTALcONCSy1VhAjGSr81ZRSMB4khl9StSauZrbkpJ1P\nshqkFSUyAi31mKrnXz0Es/v0Yi0FzAlgWrZ4u1Ld+Bo2Xz7oK4mHf7/93Jc+tEaM\nIqG6ocD0q8bjPp0tlSxftVADNUzWlZfM6fue5EXzOsKqyDrxYOSchfU9dNzKsaBX\nkxbHEeSUPJeYYj7aVPEfAs/tlUGsoXQvwWfRie8grp2BoLECAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEACZARBpHYH6Gr2a1ka0mCWfBmOZqfDVan9rsI5TCThoylmaXW\nquEiZ2LObI+5faPzxSBhr9TjJlQamsd4ywout7pHKN8ZGqrCMRJ1jJbUfobu1n2k\nUOsY4+jzV1IRBXJzj64fLal4QhUNv341lAer6Vz3cAyRk7CK89b/DEY0x+jVpyZT\n1osx9JtsOmkDTgvdStGzq5kPKWOfjwHkmKQaZXliCgqbhzcCERppp1s/sX6K7nIh\n4lWiEmzUSD3Hngk51KGWlpZszO5KQ4cSZ3HUt/prg+tt0ROC3pY61k+m5dDUa9M8\nRtMI6iTjzSj/UV8DiAx0yeM+bKoy4jGeXmaL3g==\n-----END CERTIFICATE-----\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUW42TU9LSjEZLMCclWrvSwAsgRtcwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDMxOVoXDTI0MDMyMzE4NDMxOVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJGUw\nNjVmMWI3LTE2OWEtNDE5YS1iNmQyLTc3OWJkOGM4NzIwNjCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAK42ixoklDH5K5i1NxXo/AFACDa956pE5RA57wlC\nBfgUYaIDRmv7TUVJh6zoMZSD6wjSZl3QgP7UTTZeHbvs3QE9HUwEkH1Lo3a8vD3z\neqsE2vSnOkpWWnPbfxiQyrTm77/LAWBt7lRLRLdfL6WcucD3wsGqm58sWXM3HG0f\nSN7PHCZUFqU6MpkHw8DiKmht5hBgWG+Vq3Zw8MNaqpwb/NgST3yYdcZwb58G2FTS\nZvDSdUfRmD/mY7TpciYV8EFylXNNFkth8oGNLunR9adgZ+9IunfRKj1a7S5GSwXU\nAZDaojw+8k5i3ikztsWH11wAVCiLj/3euIqq95z8xGycnKcCAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEAWMvcaozgBrZ/MAxzTJmp5gZyLxmMNV6iT9dcqbwzDtDtBvA/\n46ux6ytAQ+A7Bd3AubvozwCr1Id6g66ae0blWYRRZmF8fDdX/SBjIUkv7u9A3NVQ\nXN9gsEvK9pdpfN4ZiflfGSLdhM1STHycLmhG6H5s7HklbukMRhQi+ejbSzm/wiw1\nipcxuKhSUIVNkTLusN5b+HE2gwF1fn0K0z5jWABy08huLgbaEKXJEx5/FKLZGJga\nfpIzAdf25kMTu3gggseaAmzyX3AtT1i8A8nqYfe8fnnVMkvud89kq5jErv/hlMC9\n49g5yWQR2jilYYM3j9BHDuB+Rs+YS5BCep1JnQ==\n-----END CERTIFICATE-----\n", # noqa: E501 + "-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIUdiBwE/CtaBXJl3MArjZen6Y8kigwDQYJKoZIhvcNAQEL\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIzMDMyNDE4\nNDg1OVoXDTI0MDMyMzE4NDg1OVowPDELMAkGA1UEAwwCb2sxLTArBgNVBC0MJDEw\nMDdjNDBhLWUwYzMtNDVlOS05YTAxLTVlYjY0NWQ0ZmEyZDCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANOnUl6JDlXpLMRr/PxgtfE/E5Yk6E/TkPkPL/Kk\ntUGjEi42XZDg9zn3U6cjTDYu+rfKY2jiitfsduW6DQIkEpz3AvbuCMbbgnFpcjsB\nYysLSMTmuz/AVPrfnea/tQTALcONCSy1VhAjGSr81ZRSMB4khl9StSauZrbkpJ1P\nshqkFSUyAi31mKrnXz0Es/v0Yi0FzAlgWrZ4u1Ld+Bo2Xz7oK4mHf7/93Jc+tEaM\nIqG6ocD0q8bjPp0tlSxftVADNUzWlZfM6fue5EXzOsKqyDrxYOSchfU9dNzKsaBX\nkxbHEeSUPJeYYj7aVPEfAs/tlUGsoXQvwWfRie8grp2BoLECAwEAATANBgkqhkiG\n9w0BAQsFAAOCAQEACZARBpHYH6Gr2a1ka0mCWfBmOZqfDVan9rsI5TCThoylmaXW\nquEiZ2LObI+5faPzxSBhr9TjJlQamsd4ywout7pHKN8ZGqrCMRJ1jJbUfobu1n2k\nUOsY4+jzV1IRBXJzj64fLal4QhUNv341lAer6Vz3cAyRk7CK89b/DEY0x+jVpyZT\n1osx9JtsOmkDTgvdStGzq5kPKWOfjwHkmKQaZXliCgqbhzcCERppp1s/sX6K7nIh\n4lWiEmzUSD3Hngk51KGWlpZszO5KQ4cSZ3HUt/prg+tt0ROC3pY61k+m5dDUa9M8\nRtMI6iTjzSj/UV8DiAx0yeM+bKoy4jGeXmaL3g==\n-----END CERTIFICATE-----\n", # noqa: E501 + ], + } + ], + "properties": { + "certificate": { + "$id": "#/properties/certificate", + "type": "string", + "title": "Public TLS certificate", + "description": "Public TLS certificate", + }, + "ca": { + "$id": "#/properties/ca", + "type": "string", + "title": "CA public TLS certificate", + "description": "CA Public TLS certificate", + }, + "chain": { + "$id": "#/properties/chain", + "type": "array", + "items": {"type": "string", "$id": "#/properties/chain/items"}, + "title": "CA public TLS certificate chain", + "description": "CA public TLS certificate chain", + }, + }, + "anyOf": [{"required": ["certificate"]}, {"required": ["ca"]}, {"required": ["chain"]}], + "additionalProperties": True, +} + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + ca: str, + chain: List[str], + relation_id: int, + ): + super().__init__(handle) + self.certificate = certificate + self.ca = ca + self.chain = chain + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return { + "certificate": self.certificate, + "ca": self.ca, + "chain": self.chain, + "relation_id": self.relation_id, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + self.relation_id = snapshot["relation_id"] + + +class CertificateRemovedEvent(EventBase): + """Charm Event triggered when a TLS certificate is removed.""" + + def __init__(self, handle: Handle, relation_id: int): + super().__init__(handle) + self.relation_id = relation_id + + def snapshot(self) -> dict: + """Return snapshot.""" + return {"relation_id": self.relation_id} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.relation_id = snapshot["relation_id"] + + +def _load_relation_data(raw_relation_data: dict) -> dict: + """Load relation data from the relation data bag. + + Args: + raw_relation_data: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + loaded_relation_data = {} + for key in raw_relation_data: + try: + loaded_relation_data[key] = json.loads(raw_relation_data[key]) + except (json.decoder.JSONDecodeError, TypeError): + loaded_relation_data[key] = raw_relation_data[key] + return loaded_relation_data + + +class CertificateTransferRequirerCharmEvents(CharmEvents): + """List of events that the Certificate Transfer requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_removed = EventSource(CertificateRemovedEvent) + + +class CertificateTransferProvides(Object): + """Certificate Transfer provider class.""" + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.charm = charm + self.relationship_name = relationship_name + + def set_certificate( + self, + certificate: str, + ca: str, + chain: List[str], + relation_id: int, + ) -> None: + """Add certificates to relation data. + + Args: + certificate (str): Certificate + ca (str): CA Certificate + chain (list): CA Chain + relation_id (int): Juju relation ID + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"No relation found with relation name {self.relationship_name} and " + f"relation ID {relation_id}" + ) + relation.data[self.model.unit]["certificate"] = certificate + relation.data[self.model.unit]["ca"] = ca + relation.data[self.model.unit]["chain"] = json.dumps(chain) + + def remove_certificate(self, relation_id: int) -> None: + """Remove a given certificate from relation data. + + Args: + relation_id (int): Relation ID + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + logger.warning( + f"Can't remove certificate - Non-existent relation '{self.relationship_name}'" + ) + return + unit_relation_data = relation.data[self.model.unit] + certificate_removed = False + if "certificate" in unit_relation_data: + relation.data[self.model.unit].pop("certificate") + certificate_removed = True + if "ca" in unit_relation_data: + relation.data[self.model.unit].pop("ca") + certificate_removed = True + if "chain" in unit_relation_data: + relation.data[self.model.unit].pop("chain") + certificate_removed = True + + if certificate_removed: + logger.warning("Certificate removed from relation data") + else: + logger.warning("Can't remove certificate - No certificate in relation data") + + +class CertificateTransferRequires(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificateTransferRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + charm.on[relationship_name].relation_broken, self._on_relation_broken + ) + + @staticmethod + def _relation_data_is_valid(relation_data: dict) -> bool: + """Return whether relation data is valid based on json schema. + + Args: + relation_data: Relation data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=relation_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Emit certificate available event. + + Args: + event: Juju event + + Returns: + None + """ + if not event.unit: + logger.info(f"No remote unit in relation: {self.relationship_name}") + return + remote_unit_relation_data = _load_relation_data(event.relation.data[event.unit]) + if not self._relation_data_is_valid(remote_unit_relation_data): + logger.warning( + f"Provider relation data did not pass JSON Schema validation: " + f"{event.relation.data[event.unit]}" + ) + return + self.on.certificate_available.emit( + certificate=remote_unit_relation_data.get("certificate"), + ca=remote_unit_relation_data.get("ca"), + chain=remote_unit_relation_data.get("chain"), + relation_id=event.relation.id, + ) + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handler triggered on relation broken event. + + Args: + event: Juju event + + Returns: + None + """ + self.on.certificate_removed.emit(relation_id=event.relation.id) diff --git a/lib/charms/glauth_k8s/v0/ldap.py b/lib/charms/glauth_k8s/v0/ldap.py index 7e4807e0..476faf3b 100644 --- a/lib/charms/glauth_k8s/v0/ldap.py +++ b/lib/charms/glauth_k8s/v0/ldap.py @@ -151,7 +151,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 2 +LIBPATCH = 1 PYDEPS = ["pydantic~=2.5.3"] diff --git a/lib/charms/observability_libs/v0/cert_handler.py b/lib/charms/observability_libs/v0/cert_handler.py new file mode 100644 index 00000000..db14e00f --- /dev/null +++ b/lib/charms/observability_libs/v0/cert_handler.py @@ -0,0 +1,432 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. +"""## Overview. + +This document explains how to use the `CertHandler` class to +create and manage TLS certificates through the `tls_certificates` interface. + +The goal of the CertHandler is to provide a wrapper to the `tls_certificates` +library functions to make the charm integration smoother. + +## Library Usage + +This library should be used to create a `CertHandler` object, as per the +following example: + +```python +self.cert_handler = CertHandler( + charm=self, + key="my-app-cert-manager", + peer_relation_name="replicas", + cert_subject="unit_name", # Optional +) +``` + +You can then observe the library's custom event and make use of the key and cert: +```python +self.framework.observe(self.cert_handler.on.cert_changed, self._on_server_cert_changed) + +container.push(keypath, self.cert_handler.key) +container.push(certpath, self.cert_handler.cert) +``` + +This library requires a peer relation to be declared in the requirer's metadata. Peer relation data +is used for "persistent storage" of the private key and certs. +""" +import ipaddress +import json +import socket +from itertools import filterfalse +from typing import List, Optional, Union + +try: + from charms.tls_certificates_interface.v2.tls_certificates import ( # type: ignore + AllCertificatesInvalidatedEvent, + CertificateAvailableEvent, + CertificateExpiringEvent, + CertificateInvalidatedEvent, + TLSCertificatesRequiresV2, + generate_csr, + generate_private_key, + ) +except ImportError: + raise ImportError( + "charms.tls_certificates_interface.v2.tls_certificates is missing; please get it through charmcraft fetch-lib" + ) +import logging + +from ops.charm import CharmBase, RelationBrokenEvent +from ops.framework import EventBase, EventSource, Object, ObjectEvents +from ops.model import Relation + +logger = logging.getLogger(__name__) + + +LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a" +LIBAPI = 0 +LIBPATCH = 9 + + +def is_ip_address(value: str) -> bool: + """Return True if the input value is a valid IPv4 address; False otherwise.""" + try: + ipaddress.IPv4Address(value) + return True + except ipaddress.AddressValueError: + return False + + +class CertChanged(EventBase): + """Event raised when a cert is changed (becomes available or revoked).""" + + +class CertHandlerEvents(ObjectEvents): + """Events for CertHandler.""" + + cert_changed = EventSource(CertChanged) + + +class CertHandler(Object): + """A wrapper for the requirer side of the TLS Certificates charm library.""" + + on = CertHandlerEvents() # pyright: ignore + + def __init__( + self, + charm: CharmBase, + *, + key: str, + peer_relation_name: str, + certificates_relation_name: str = "certificates", + cert_subject: Optional[str] = None, + extra_sans_dns: Optional[List[str]] = None, # TODO: in v1, rename arg to `sans` + ): + """CertHandler is used to wrap TLS Certificates management operations for charms. + + CerHandler manages one single cert. + + Args: + charm: The owning charm. + key: A manually-crafted, static, unique identifier used by ops to identify events. + It shouldn't change between one event to another. + peer_relation_name: Must match metadata.yaml. + certificates_relation_name: Must match metadata.yaml. + cert_subject: Custom subject. Name collisions are under the caller's responsibility. + extra_sans_dns: DNS names. If none are given, use FQDN. + """ + super().__init__(charm, key) + + self.charm = charm + # We need to sanitize the unit name, otherwise route53 complains: + # "urn:ietf:params:acme:error:malformed" :: Domain name contains an invalid character + self.cert_subject = charm.unit.name.replace("/", "-") if not cert_subject else cert_subject + + # Use fqdn only if no SANs were given, and drop empty/duplicate SANs + sans = list(set(filter(None, (extra_sans_dns or [socket.getfqdn()])))) + self.sans_ip = list(filter(is_ip_address, sans)) + self.sans_dns = list(filterfalse(is_ip_address, sans)) + + self.peer_relation_name = peer_relation_name + self.certificates_relation_name = certificates_relation_name + + self.certificates = TLSCertificatesRequiresV2(self.charm, self.certificates_relation_name) + + self.framework.observe( + self.charm.on.config_changed, + self._on_config_changed, + ) + self.framework.observe( + self.charm.on.certificates_relation_joined, # pyright: ignore + self._on_certificates_relation_joined, + ) + self.framework.observe( + self.certificates.on.certificate_available, # pyright: ignore + self._on_certificate_available, + ) + self.framework.observe( + self.certificates.on.certificate_expiring, # pyright: ignore + self._on_certificate_expiring, + ) + self.framework.observe( + self.certificates.on.certificate_invalidated, # pyright: ignore + self._on_certificate_invalidated, + ) + self.framework.observe( + self.certificates.on.all_certificates_invalidated, # pyright: ignore + self._on_all_certificates_invalidated, + ) + self.framework.observe( + self.charm.on[self.certificates_relation_name].relation_broken, # pyright: ignore + self._on_certificates_relation_broken, + ) + + # Peer relation events + self.framework.observe( + self.charm.on[self.peer_relation_name].relation_created, self._on_peer_relation_created + ) + + @property + def enabled(self) -> bool: + """Boolean indicating whether the charm has a tls_certificates relation.""" + # We need to check for units as a temporary workaround because of https://bugs.launchpad.net/juju/+bug/2024583 + # This could in theory not work correctly on scale down to 0 but it is necessary for the moment. + return ( + len(self.charm.model.relations[self.certificates_relation_name]) > 0 + and len(self.charm.model.get_relation(self.certificates_relation_name).units) > 0 # type: ignore + ) + + @property + def _peer_relation(self) -> Optional[Relation]: + """Return the peer relation.""" + return self.charm.model.get_relation(self.peer_relation_name, None) + + def _on_peer_relation_created(self, _): + """Generate the CSR if the certificates relation is ready.""" + self._generate_privkey() + + # check cert relation is ready + if not (self.charm.model.get_relation(self.certificates_relation_name)): + # peer relation event happened to fire before tls-certificates events. + # Abort, and let the "certificates joined" observer create the CSR. + logger.info("certhandler waiting on certificates relation") + return + + logger.debug("certhandler has peer and certs relation: proceeding to generate csr") + self._generate_csr() + + def _on_certificates_relation_joined(self, _) -> None: + """Generate the CSR if the peer relation is ready.""" + self._generate_privkey() + + # check peer relation is there + if not self._peer_relation: + # tls-certificates relation event happened to fire before peer events. + # Abort, and let the "peer joined" relation create the CSR. + logger.info("certhandler waiting on peer relation") + return + + logger.debug("certhandler has peer and certs relation: proceeding to generate csr") + self._generate_csr() + + def _generate_privkey(self): + # Generate priv key unless done already + # TODO figure out how to go about key rotation. + if not self._private_key: + private_key = generate_private_key() + self._private_key = private_key.decode() + + def _on_config_changed(self, _): + # FIXME on config changed, the web_external_url may or may not change. But because every + # call to `generate_csr` appends a uuid, CSRs cannot be easily compared to one another. + # so for now, will be overwriting the CSR (and cert) every config change. This is not + # great. We could avoid this problem if: + # - we extract the external_url from the existing cert and compare to current; or + # - we drop the web_external_url from the list of SANs. + # Generate a CSR only if the necessary relations are already in place. + if self._peer_relation and self.charm.model.get_relation(self.certificates_relation_name): + self._generate_csr(renew=True) + + def _generate_csr( + self, overwrite: bool = False, renew: bool = False, clear_cert: bool = False + ): + """Request a CSR "creation" if renew is False, otherwise request a renewal. + + Without overwrite=True, the CSR would be created only once, even if calling the method + multiple times. This is useful needed because the order of peer-created and + certificates-joined is not predictable. + + This method intentionally does not emit any events, leave it for caller's responsibility. + """ + # At this point, assuming "peer joined" and "certificates joined" have already fired + # (caller must guard) so we must have a private_key entry in relation data at our disposal. + # Otherwise, traceback -> debug. + + # In case we already have a csr, do not overwrite it by default. + if overwrite or renew or not self._csr: + private_key = self._private_key + if private_key is None: + # FIXME: raise this in a less nested scope by + # generating privkey and csr in the same method. + raise RuntimeError( + "private key unset. call _generate_privkey() before you call this method." + ) + csr = generate_csr( + private_key=private_key.encode(), + subject=self.cert_subject, + sans_dns=self.sans_dns, + sans_ip=self.sans_ip, + ) + + if renew and self._csr: + self.certificates.request_certificate_renewal( + old_certificate_signing_request=self._csr.encode(), + new_certificate_signing_request=csr, + ) + else: + logger.info( + "Creating CSR for %s with DNS %s and IPs %s", + self.cert_subject, + self.sans_dns, + self.sans_ip, + ) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + # Note: CSR is being replaced with a new one, so until we get the new cert, we'd have + # a mismatch between the CSR and the cert. + # For some reason the csr contains a trailing '\n'. TODO figure out why + self._csr = csr.decode().strip() + + if clear_cert: + self._ca_cert = "" + self._server_cert = "" + self._chain = [] + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + """Get the certificate from the event and store it in a peer relation. + + Note: assuming "limit: 1" in metadata + """ + # We need to store the ca cert and server cert somewhere it would persist across upgrades. + # While we support Juju 2.9, the only option is peer data. When we drop 2.9, then secrets. + + # I think juju guarantees that a peer-created always fires before any regular + # relation-changed. If that is not the case, we would need more guards and more paths. + + # Process the cert only if it belongs to the unit that requested it (this unit) + event_csr = ( + event.certificate_signing_request.strip() + if event.certificate_signing_request + else None + ) + if event_csr == self._csr: + self._ca_cert = event.ca + self._server_cert = event.certificate + self._chain = event.chain + self.on.cert_changed.emit() # pyright: ignore + + @property + def key(self): + """Return the private key.""" + return self._private_key + + @property + def _private_key(self) -> Optional[str]: + if self._peer_relation: + return self._peer_relation.data[self.charm.unit].get("private_key", None) + return None + + @_private_key.setter + def _private_key(self, value: str): + # Caller must guard. We want the setter to fail loudly. Failure must have a side effect. + rel = self._peer_relation + assert rel is not None # For type checker + rel.data[self.charm.unit].update({"private_key": value}) + + @property + def _csr(self) -> Optional[str]: + if self._peer_relation: + return self._peer_relation.data[self.charm.unit].get("csr", None) + return None + + @_csr.setter + def _csr(self, value: str): + # Caller must guard. We want the setter to fail loudly. Failure must have a side effect. + rel = self._peer_relation + assert rel is not None # For type checker + rel.data[self.charm.unit].update({"csr": value}) + + @property + def _ca_cert(self) -> Optional[str]: + if self._peer_relation: + return self._peer_relation.data[self.charm.unit].get("ca", None) + return None + + @_ca_cert.setter + def _ca_cert(self, value: str): + # Caller must guard. We want the setter to fail loudly. Failure must have a side effect. + rel = self._peer_relation + assert rel is not None # For type checker + rel.data[self.charm.unit].update({"ca": value}) + + @property + def cert(self): + """Return the server cert.""" + return self._server_cert + + @property + def ca(self): + """Return the CA cert.""" + return self._ca_cert + + @property + def _server_cert(self) -> Optional[str]: + if self._peer_relation: + return self._peer_relation.data[self.charm.unit].get("certificate", None) + return None + + @_server_cert.setter + def _server_cert(self, value: str): + # Caller must guard. We want the setter to fail loudly. Failure must have a side effect. + rel = self._peer_relation + assert rel is not None # For type checker + rel.data[self.charm.unit].update({"certificate": value}) + + @property + def _chain(self) -> List[str]: + if self._peer_relation: + if chain := self._peer_relation.data[self.charm.unit].get("chain", []): + return json.loads(chain) + return [] + + @_chain.setter + def _chain(self, value: List[str]): + # Caller must guard. We want the setter to fail loudly. Failure must have a side effect. + rel = self._peer_relation + assert rel is not None # For type checker + rel.data[self.charm.unit].update({"chain": json.dumps(value)}) + + @property + def chain(self) -> List[str]: + """Return the ca chain.""" + return self._chain + + def _on_certificate_expiring( + self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent] + ) -> None: + """Generate a new CSR and request certificate renewal.""" + if event.certificate == self._server_cert: + self._generate_csr(renew=True) + + def _certificate_revoked(self, event) -> None: + """Remove the certificate from the peer relation and generate a new CSR.""" + # Note: assuming "limit: 1" in metadata + if event.certificate == self._server_cert: + self._generate_csr(overwrite=True, clear_cert=True) + self.on.cert_changed.emit() # pyright: ignore + + def _on_certificate_invalidated(self, event: CertificateInvalidatedEvent) -> None: + """Deal with certificate revocation and expiration.""" + if event.certificate != self._server_cert: + return + + # if event.reason in ("revoked", "expired"): + # Currently, the reason does not matter to us because the action is the same. + self._generate_csr(overwrite=True, clear_cert=True) + self.on.cert_changed.emit() # pyright: ignore + + def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEvent) -> None: + # Do what you want with this information, probably remove all certificates + # Note: assuming "limit: 1" in metadata + self._generate_csr(overwrite=True, clear_cert=True) + self.on.cert_changed.emit() # pyright: ignore + + def _on_certificates_relation_broken(self, event: RelationBrokenEvent) -> None: + """Clear the certificates data when removing the relation.""" + if self._peer_relation: + private_key = self._private_key + # This is a workaround for https://bugs.launchpad.net/juju/+bug/2024583 + self._peer_relation.data[self.charm.unit].clear() + if private_key: + self._peer_relation.data[self.charm.unit].update({"private_key": private_key}) + + self.on.cert_changed.emit() # pyright: ignore diff --git a/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/lib/charms/tls_certificates_interface/v2/tls_certificates.py new file mode 100644 index 00000000..b8855bea --- /dev/null +++ b/lib/charms/tls_certificates_interface/v2/tls_certificates.py @@ -0,0 +1,1872 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + + +"""Library for the tls-certificates relation. + +This library contains the Requires and Provides classes for handling the tls-certificates +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.tls_certificates_interface.v2.tls_certificates +``` + +Add the following libraries to the charm's `requirements.txt` file: +- jsonschema +- cryptography + +Add the following section to the charm's `charmcraft.yaml` file: +```yaml +parts: + charm: + build-packages: + - libffi-dev + - libssl-dev + - rustc + - cargo +``` + +### Provider charm +The provider charm is the charm providing certificates to another charm that requires them. In +this example, the provider charm is storing its private key using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v2.tls_certificates import ( + CertificateCreationRequestEvent, + CertificateRevocationRequestEvent, + TLSCertificatesProvidesV2, + generate_private_key, +) +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus + + +def generate_ca(private_key: bytes, subject: str) -> str: + return "whatever ca content" + + +def generate_certificate(ca: str, private_key: str, csr: str) -> str: + return "Whatever certificate" + + +class ExampleProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.certificates = TLSCertificatesProvidesV2(self, "certificates") + self.framework.observe( + self.certificates.on.certificate_request, + self._on_certificate_request + ) + self.framework.observe( + self.certificates.on.certificate_revocation_request, + self._on_certificate_revocation_request + ) + self.framework.observe(self.on.install, self._on_install) + + def _on_install(self, event: InstallEvent) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + ca_certificate = generate_ca(private_key=private_key, subject="whatever") + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + { + "private_key_password": "banana", + "private_key": private_key, + "ca_certificate": ca_certificate, + } + ) + self.unit.status = ActiveStatus() + + def _on_certificate_request(self, event: CertificateCreationRequestEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + ca_certificate = replicas_relation.data[self.app].get("ca_certificate") + private_key = replicas_relation.data[self.app].get("private_key") + certificate = generate_certificate( + ca=ca_certificate, + private_key=private_key, + csr=event.certificate_signing_request, + ) + + self.certificates.set_relation_certificate( + certificate=certificate, + certificate_signing_request=event.certificate_signing_request, + ca=ca_certificate, + chain=[ca_certificate, certificate], + relation_id=event.relation_id, + ) + + def _on_certificate_revocation_request(self, event: CertificateRevocationRequestEvent) -> None: + # Do what you want to do with this information + pass + + +if __name__ == "__main__": + main(ExampleProviderCharm) +``` + +### Requirer charm +The requirer charm is the charm requiring certificates from another charm that provides them. In +this example, the requirer charm is storing its certificates using a peer relation interface called +`replicas`. + +Example: +```python +from charms.tls_certificates_interface.v2.tls_certificates import ( + CertificateAvailableEvent, + CertificateExpiringEvent, + CertificateRevokedEvent, + TLSCertificatesRequiresV2, + generate_csr, + generate_private_key, +) +from ops.charm import CharmBase, RelationJoinedEvent +from ops.main import main +from ops.model import ActiveStatus, WaitingStatus +from typing import Union + + +class ExampleRequirerCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.cert_subject = "whatever" + self.certificates = TLSCertificatesRequiresV2(self, "certificates") + self.framework.observe(self.on.install, self._on_install) + self.framework.observe( + self.on.certificates_relation_joined, self._on_certificates_relation_joined + ) + self.framework.observe( + self.certificates.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self.certificates.on.certificate_expiring, self._on_certificate_expiring + ) + self.framework.observe( + self.certificates.on.certificate_invalidated, self._on_certificate_invalidated + ) + self.framework.observe( + self.certificates.on.all_certificates_invalidated, + self._on_all_certificates_invalidated + ) + + def _on_install(self, event) -> None: + private_key_password = b"banana" + private_key = generate_private_key(password=private_key_password) + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update( + {"private_key_password": "banana", "private_key": private_key.decode()} + ) + + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + replicas_relation.data[self.app].update({"csr": csr.decode()}) + self.certificates.request_certificate_creation(certificate_signing_request=csr) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + replicas_relation.data[self.app].update({"certificate": event.certificate}) + replicas_relation.data[self.app].update({"ca": event.ca}) + replicas_relation.data[self.app].update({"chain": event.chain}) + self.unit.status = ActiveStatus() + + def _on_certificate_expiring( + self, event: Union[CertificateExpiringEvent, CertificateInvalidatedEvent] + ) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + + def _certificate_revoked(self) -> None: + old_csr = replicas_relation.data[self.app].get("csr") + private_key_password = replicas_relation.data[self.app].get("private_key_password") + private_key = replicas_relation.data[self.app].get("private_key") + new_csr = generate_csr( + private_key=private_key.encode(), + private_key_password=private_key_password.encode(), + subject=self.cert_subject, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + replicas_relation.data[self.app].update({"csr": new_csr.decode()}) + replicas_relation.data[self.app].pop("certificate") + replicas_relation.data[self.app].pop("ca") + replicas_relation.data[self.app].pop("chain") + self.unit.status = WaitingStatus("Waiting for new certificate") + + def _on_certificate_invalidated(self, event: CertificateInvalidatedEvent) -> None: + replicas_relation = self.model.get_relation("replicas") + if not replicas_relation: + self.unit.status = WaitingStatus("Waiting for peer relation to be created") + event.defer() + return + if event.reason == "revoked": + self._certificate_revoked() + if event.reason == "expired": + self._on_certificate_expiring(event) + + def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEvent) -> None: + # Do what you want with this information, probably remove all certificates. + pass + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` + +You can relate both charms by running: + +```bash +juju relate +``` + +""" # noqa: D405, D410, D411, D214, D416 + +import copy +import json +import logging +import uuid +from contextlib import suppress +from datetime import datetime, timedelta +from ipaddress import IPv4Address +from typing import Any, Dict, List, Literal, Optional, Union + +from cryptography import x509 +from cryptography.hazmat._oid import ExtensionOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.extensions import Extension, ExtensionNotFound +from jsonschema import exceptions, validate # type: ignore[import-untyped] +from ops.charm import ( + CharmBase, + CharmEvents, + RelationBrokenEvent, + RelationChangedEvent, + SecretExpiredEvent, + UpdateStatusEvent, +) +from ops.framework import EventBase, EventSource, Handle, Object +from ops.jujuversion import JujuVersion +from ops.model import ModelError, Relation, RelationDataContent, SecretNotFoundError + +# The unique Charmhub library identifier, never change it +LIBID = "afd8c2bccf834997afce12c2706d2ede" + +# Increment this major API version when introducing breaking changes +LIBAPI = 2 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 21 + +PYDEPS = ["cryptography", "jsonschema"] + +REQUIRER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v2/schemas/requirer.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` requirer root schema", + "description": "The `tls_certificates` root schema comprises the entire requirer databag for this interface.", # noqa: E501 + "examples": [ + { + "certificate_signing_requests": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\\nAQEBBQADggEPADCCAQoCggEBAMk3raaX803cHvzlBF9LC7KORT46z4VjyU5PIaMb\\nQLIDgYKFYI0n5hf2Ra4FAHvOvEmW7bjNlHORFEmvnpcU5kPMNUyKFMTaC8LGmN8z\\nUBH3aK+0+FRvY4afn9tgj5435WqOG9QdoDJ0TJkjJbJI9M70UOgL711oU7ql6HxU\\n4d2ydFK9xAHrBwziNHgNZ72L95s4gLTXf0fAHYf15mDA9U5yc+YDubCKgTXzVySQ\\nUx73VCJLfC/XkZIh559IrnRv5G9fu6BMLEuBwAz6QAO4+/XidbKWN4r2XSq5qX4n\\n6EPQQWP8/nd4myq1kbg6Q8w68L/0YdfjCmbyf2TuoWeImdUCAwEAAaAAMA0GCSqG\\nSIb3DQEBCwUAA4IBAQBIdwraBvpYo/rl5MH1+1Um6HRg4gOdQPY5WcJy9B9tgzJz\\nittRSlRGTnhyIo6fHgq9KHrmUthNe8mMTDailKFeaqkVNVvk7l0d1/B90Kz6OfmD\\nxN0qjW53oP7y3QB5FFBM8DjqjmUnz5UePKoX4AKkDyrKWxMwGX5RoET8c/y0y9jp\\nvSq3Wh5UpaZdWbe1oVY8CqMVUEVQL2DPjtopxXFz2qACwsXkQZxWmjvZnRiP8nP8\\nbdFaEuh9Q6rZ2QdZDEtrU4AodPU3NaukFr5KlTUQt3w/cl+5//zils6G5zUWJ2pN\\ng7+t9PTvXHRkH+LnwaVnmsBFU2e05qADQbfIn7JA\\n-----END CERTIFICATE REQUEST-----\\n" # noqa: E501 + }, + ] + } + ], + "properties": { + "certificate_signing_requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "certificate_signing_request": {"type": "string"}, + "ca": {"type": "boolean"}, + }, + "required": ["certificate_signing_request"], + }, + } + }, + "required": ["certificate_signing_requests"], + "additionalProperties": True, +} + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "$id": "https://canonical.github.io/charm-relation-interfaces/tls_certificates/v2/schemas/provider.json", # noqa: E501 + "type": "object", + "title": "`tls_certificates` provider root schema", + "description": "The `tls_certificates` root schema comprises the entire provider databag for this interface.", # noqa: E501 + "examples": [ + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + } + ] + }, + { + "certificates": [ + { + "ca": "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n", # noqa: E501 + "chain": [ + "-----BEGIN CERTIFICATE-----\\nMIIDJTCCAg2gAwIBAgIUMsSK+4FGCjW6sL/EXMSxColmKw8wDQYJKoZIhvcNAQEL\\nBQAwIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdoYXRldmVyMB4XDTIyMDcyOTIx\\nMTgyN1oXDTIzMDcyOTIxMTgyN1owIDELMAkGA1UEBhMCVVMxETAPBgNVBAMMCHdo\\nYXRldmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA55N9DkgFWbJ/\\naqcdQhso7n1kFvt6j/fL1tJBvRubkiFMQJnZFtekfalN6FfRtA3jq+nx8o49e+7t\\nLCKT0xQ+wufXfOnxv6/if6HMhHTiCNPOCeztUgQ2+dfNwRhYYgB1P93wkUVjwudK\\n13qHTTZ6NtEF6EzOqhOCe6zxq6wrr422+ZqCvcggeQ5tW9xSd/8O1vNID/0MTKpy\\nET3drDtBfHmiUEIBR3T3tcy6QsIe4Rz/2sDinAcM3j7sG8uY6drh8jY3PWar9til\\nv2l4qDYSU8Qm5856AB1FVZRLRJkLxZYZNgreShAIYgEd0mcyI2EO/UvKxsIcxsXc\\nd45GhGpKkwIDAQABo1cwVTAfBgNVHQ4EGAQWBBRXBrXKh3p/aFdQjUcT/UcvICBL\\nODAhBgNVHSMEGjAYgBYEFFcGtcqHen9oV1CNRxP9Ry8gIEs4MA8GA1UdEwEB/wQF\\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGmCEvcoFUrT9e133SHkgF/ZAgzeIziO\\nBjfAdU4fvAVTVfzaPm0yBnGqzcHyacCzbZjKQpaKVgc5e6IaqAQtf6cZJSCiJGhS\\nJYeosWrj3dahLOUAMrXRr8G/Ybcacoqc+osKaRa2p71cC3V6u2VvcHRV7HDFGJU7\\noijbdB+WhqET6Txe67rxZCJG9Ez3EOejBJBl2PJPpy7m1Ml4RR+E8YHNzB0lcBzc\\nEoiJKlDfKSO14E2CPDonnUoWBJWjEvJys3tbvKzsRj2fnLilytPFU0gH3cEjCopi\\nzFoWRdaRuNHYCqlBmso1JFDl8h4fMmglxGNKnKRar0WeGyxb4xXBGpI=\\n-----END CERTIFICATE-----\\n" # noqa: E501, W505 + ], + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----\nMIICWjCCAUICAQAwFTETMBEGA1UEAwwKYmFuYW5hLmNvbTCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBANWlx9wE6cW7Jkb4DZZDOZoEjk1eDBMJ+8R4pyKp\nFBeHMl1SQSDt6rAWsrfL3KOGiIHqrRY0B5H6c51L8LDuVrJG0bPmyQ6rsBo3gVke\nDSivfSLtGvHtp8lwYnIunF8r858uYmblAR0tdXQNmnQvm+6GERvURQ6sxpgZ7iLC\npPKDoPt+4GKWL10FWf0i82FgxWC2KqRZUtNbgKETQuARLig7etBmCnh20zmynorA\ncY7vrpTPAaeQpGLNqqYvKV9W6yWVY08V+nqARrFrjk3vSioZSu8ZJUdZ4d9++SGl\nbH7A6e77YDkX9i/dQ3Pa/iDtWO3tXS2MvgoxX1iSWlGNOHcCAwEAAaAAMA0GCSqG\nSIb3DQEBCwUAA4IBAQCW1fKcHessy/ZhnIwAtSLznZeZNH8LTVOzkhVd4HA7EJW+\nKVLBx8DnN7L3V2/uPJfHiOg4Rx7fi7LkJPegl3SCqJZ0N5bQS/KvDTCyLG+9E8Y+\n7wqCmWiXaH1devimXZvazilu4IC2dSks2D8DPWHgsOdVks9bme8J3KjdNMQudegc\newWZZ1Dtbd+Rn7cpKU3jURMwm4fRwGxbJ7iT5fkLlPBlyM/yFEik4SmQxFYrZCQg\n0f3v4kBefTh5yclPy5tEH+8G0LMsbbo3dJ5mPKpAShi0QEKDLd7eR1R/712lYTK4\ndi4XaEfqERgy68O4rvb4PGlJeRGS7AmL7Ss8wfAq\n-----END CERTIFICATE REQUEST-----\n", # noqa: E501 + "certificate": "-----BEGIN CERTIFICATE-----\nMIICvDCCAaQCFFPAOD7utDTsgFrm0vS4We18OcnKMA0GCSqGSIb3DQEBCwUAMCAx\nCzAJBgNVBAYTAlVTMREwDwYDVQQDDAh3aGF0ZXZlcjAeFw0yMjA3MjkyMTE5Mzha\nFw0yMzA3MjkyMTE5MzhaMBUxEzARBgNVBAMMCmJhbmFuYS5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDVpcfcBOnFuyZG+A2WQzmaBI5NXgwTCfvE\neKciqRQXhzJdUkEg7eqwFrK3y9yjhoiB6q0WNAeR+nOdS/Cw7layRtGz5skOq7Aa\nN4FZHg0or30i7Rrx7afJcGJyLpxfK/OfLmJm5QEdLXV0DZp0L5vuhhEb1EUOrMaY\nGe4iwqTyg6D7fuBili9dBVn9IvNhYMVgtiqkWVLTW4ChE0LgES4oO3rQZgp4dtM5\nsp6KwHGO766UzwGnkKRizaqmLylfVusllWNPFfp6gEaxa45N70oqGUrvGSVHWeHf\nfvkhpWx+wOnu+2A5F/Yv3UNz2v4g7Vjt7V0tjL4KMV9YklpRjTh3AgMBAAEwDQYJ\nKoZIhvcNAQELBQADggEBAChjRzuba8zjQ7NYBVas89Oy7u++MlS8xWxh++yiUsV6\nWMk3ZemsPtXc1YmXorIQohtxLxzUPm2JhyzFzU/sOLmJQ1E/l+gtZHyRCwsb20fX\nmphuJsMVd7qv/GwEk9PBsk2uDqg4/Wix0Rx5lf95juJP7CPXQJl5FQauf3+LSz0y\nwF/j+4GqvrwsWr9hKOLmPdkyKkR6bHKtzzsxL9PM8GnElk2OpaPMMnzbL/vt2IAt\nxK01ZzPxCQCzVwHo5IJO5NR/fIyFbEPhxzG17QsRDOBR9fl9cOIvDeSO04vyZ+nz\n+kA2c3fNrZFAtpIlOOmFh8Q12rVL4sAjI5mVWnNEgvI=\n-----END CERTIFICATE-----\n", # noqa: E501 + "revoked": True, + } + ] + }, + ], + "properties": { + "certificates": { + "$id": "#/properties/certificates", + "type": "array", + "items": { + "$id": "#/properties/certificates/items", + "type": "object", + "required": ["certificate_signing_request", "certificate", "ca", "chain"], + "properties": { + "certificate_signing_request": { + "$id": "#/properties/certificates/items/certificate_signing_request", + "type": "string", + }, + "certificate": { + "$id": "#/properties/certificates/items/certificate", + "type": "string", + }, + "ca": {"$id": "#/properties/certificates/items/ca", "type": "string"}, + "chain": { + "$id": "#/properties/certificates/items/chain", + "type": "array", + "items": { + "type": "string", + "$id": "#/properties/certificates/items/chain/items", + }, + }, + "revoked": { + "$id": "#/properties/certificates/items/revoked", + "type": "boolean", + }, + }, + "additionalProperties": True, + }, + } + }, + "required": ["certificates"], + "additionalProperties": True, +} + + +logger = logging.getLogger(__name__) + + +class CertificateAvailableEvent(EventBase): + """Charm Event triggered when a TLS certificate is available.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class CertificateExpiringEvent(EventBase): + """Charm Event triggered when a TLS certificate is almost expired.""" + + def __init__(self, handle, certificate: str, expiry: str): + """CertificateExpiringEvent. + + Args: + handle (Handle): Juju framework handle + certificate (str): TLS Certificate + expiry (str): Datetime string representing the time at which the certificate + won't be valid anymore. + """ + super().__init__(handle) + self.certificate = certificate + self.expiry = expiry + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {"certificate": self.certificate, "expiry": self.expiry} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.expiry = snapshot["expiry"] + + +class CertificateInvalidatedEvent(EventBase): + """Charm Event triggered when a TLS certificate is invalidated.""" + + def __init__( + self, + handle: Handle, + reason: Literal["expired", "revoked"], + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ): + super().__init__(handle) + self.reason = reason + self.certificate_signing_request = certificate_signing_request + self.certificate = certificate + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "reason": self.reason, + "certificate_signing_request": self.certificate_signing_request, + "certificate": self.certificate, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.reason = snapshot["reason"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.certificate = snapshot["certificate"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +class AllCertificatesInvalidatedEvent(EventBase): + """Charm Event triggered when all TLS certificates are invalidated.""" + + def __init__(self, handle: Handle): + super().__init__(handle) + + def snapshot(self) -> dict: + """Returns snapshot.""" + return {} + + def restore(self, snapshot: dict): + """Restores snapshot.""" + pass + + +class CertificateCreationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate is required.""" + + def __init__( + self, + handle: Handle, + certificate_signing_request: str, + relation_id: int, + is_ca: bool = False, + ): + super().__init__(handle) + self.certificate_signing_request = certificate_signing_request + self.relation_id = relation_id + self.is_ca = is_ca + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate_signing_request": self.certificate_signing_request, + "relation_id": self.relation_id, + "is_ca": self.is_ca, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.relation_id = snapshot["relation_id"] + self.is_ca = snapshot["is_ca"] + + +class CertificateRevocationRequestEvent(EventBase): + """Charm Event triggered when a TLS certificate needs to be revoked.""" + + def __init__( + self, + handle: Handle, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: str, + ): + super().__init__(handle) + self.certificate = certificate + self.certificate_signing_request = certificate_signing_request + self.ca = ca + self.chain = chain + + def snapshot(self) -> dict: + """Returns snapshot.""" + return { + "certificate": self.certificate, + "certificate_signing_request": self.certificate_signing_request, + "ca": self.ca, + "chain": self.chain, + } + + def restore(self, snapshot: dict): + """Restores snapshot.""" + self.certificate = snapshot["certificate"] + self.certificate_signing_request = snapshot["certificate_signing_request"] + self.ca = snapshot["ca"] + self.chain = snapshot["chain"] + + +def _load_relation_data(relation_data_content: RelationDataContent) -> dict: + """Loads relation data from the relation data bag. + + Json loads all data. + + Args: + relation_data_content: Relation data from the databag + + Returns: + dict: Relation data in dict format. + """ + certificate_data = dict() + try: + for key in relation_data_content: + try: + certificate_data[key] = json.loads(relation_data_content[key]) + except (json.decoder.JSONDecodeError, TypeError): + certificate_data[key] = relation_data_content[key] + except ModelError: + pass + return certificate_data + + +def generate_ca( + private_key: bytes, + subject: str, + private_key_password: Optional[bytes] = None, + validity: int = 365, + country: str = "US", +) -> bytes: + """Generates a CA Certificate. + + Args: + private_key (bytes): Private key + subject (str): Certificate subject + private_key_password (bytes): Private key password + validity (int): Certificate validity time (in days) + country (str): Certificate Issuing country + + Returns: + bytes: CA Certificate. + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country), + x509.NameAttribute(x509.NameOID.COMMON_NAME, subject), + ] + ) + subject_identifier_object = x509.SubjectKeyIdentifier.from_public_key( + private_key_object.public_key() # type: ignore[arg-type] + ) + subject_identifier = key_identifier = subject_identifier_object.public_bytes() + key_usage = x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=True, + key_agreement=False, + content_commitment=False, + data_encipherment=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(private_key_object.public_key()) # type: ignore[arg-type] + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension(x509.SubjectKeyIdentifier(digest=subject_identifier), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension(key_usage, critical=True) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .sign(private_key_object, hashes.SHA256()) # type: ignore[arg-type] + ) + return cert.public_bytes(serialization.Encoding.PEM) + + +def get_certificate_extensions( + authority_key_identifier: bytes, + csr: x509.CertificateSigningRequest, + alt_names: Optional[List[str]], + is_ca: bool, +) -> List[x509.Extension]: + """Generates a list of certificate extensions from a CSR and other known information. + + Args: + authority_key_identifier (bytes): Authority key identifier + csr (x509.CertificateSigningRequest): CSR + alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR + is_ca (bool): Whether the certificate is a CA certificate + + Returns: + List[x509.Extension]: List of extensions + """ + cert_extensions_list: List[x509.Extension] = [ + x509.Extension( + oid=ExtensionOID.AUTHORITY_KEY_IDENTIFIER, + value=x509.AuthorityKeyIdentifier( + key_identifier=authority_key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ), + x509.Extension( + oid=ExtensionOID.SUBJECT_KEY_IDENTIFIER, + value=x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), + critical=False, + ), + x509.Extension( + oid=ExtensionOID.BASIC_CONSTRAINTS, + critical=True, + value=x509.BasicConstraints(ca=is_ca, path_length=None), + ), + ] + + sans: List[x509.GeneralName] = [] + san_alt_names = [x509.DNSName(name) for name in alt_names] if alt_names else [] + sans.extend(san_alt_names) + try: + loaded_san_ext = csr.extensions.get_extension_for_class(x509.SubjectAlternativeName) + sans.extend( + [x509.DNSName(name) for name in loaded_san_ext.value.get_values_for_type(x509.DNSName)] + ) + sans.extend( + [x509.IPAddress(ip) for ip in loaded_san_ext.value.get_values_for_type(x509.IPAddress)] + ) + sans.extend( + [ + x509.RegisteredID(oid) + for oid in loaded_san_ext.value.get_values_for_type(x509.RegisteredID) + ] + ) + except x509.ExtensionNotFound: + pass + + if sans: + cert_extensions_list.append( + x509.Extension( + oid=ExtensionOID.SUBJECT_ALTERNATIVE_NAME, + critical=False, + value=x509.SubjectAlternativeName(sans), + ) + ) + + if is_ca: + cert_extensions_list.append( + x509.Extension( + ExtensionOID.KEY_USAGE, + critical=True, + value=x509.KeyUsage( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + ) + ) + + existing_oids = {ext.oid for ext in cert_extensions_list} + for extension in csr.extensions: + if extension.oid == ExtensionOID.SUBJECT_ALTERNATIVE_NAME: + continue + if extension.oid in existing_oids: + logger.warning("Extension %s is managed by the TLS provider, ignoring.", extension.oid) + continue + cert_extensions_list.append(extension) + + return cert_extensions_list + + +def generate_certificate( + csr: bytes, + ca: bytes, + ca_key: bytes, + ca_key_password: Optional[bytes] = None, + validity: int = 365, + alt_names: Optional[List[str]] = None, + is_ca: bool = False, +) -> bytes: + """Generates a TLS certificate based on a CSR. + + Args: + csr (bytes): CSR + ca (bytes): CA Certificate + ca_key (bytes): CA private key + ca_key_password: CA private key password + validity (int): Certificate validity (in days) + alt_names (list): List of alt names to put on cert - prefer putting SANs in CSR + is_ca (bool): Whether the certificate is a CA certificate + + Returns: + bytes: Certificate + """ + csr_object = x509.load_pem_x509_csr(csr) + subject = csr_object.subject + ca_pem = x509.load_pem_x509_certificate(ca) + issuer = ca_pem.issuer + private_key = serialization.load_pem_private_key(ca_key, password=ca_key_password) + + certificate_builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(csr_object.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + ) + extensions = get_certificate_extensions( + authority_key_identifier=ca_pem.extensions.get_extension_for_class( + x509.SubjectKeyIdentifier + ).value.key_identifier, + csr=csr_object, + alt_names=alt_names, + is_ca=is_ca, + ) + for extension in extensions: + try: + certificate_builder = certificate_builder.add_extension( + extval=extension.value, + critical=extension.critical, + ) + except ValueError as e: + logger.warning("Failed to add extension %s: %s", extension.oid, e) + + cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] + return cert.public_bytes(serialization.Encoding.PEM) + + +def generate_pfx_package( + certificate: bytes, + private_key: bytes, + package_password: str, + private_key_password: Optional[bytes] = None, +) -> bytes: + """Generates a PFX package to contain the TLS certificate and private key. + + Args: + certificate (bytes): TLS certificate + private_key (bytes): Private key + package_password (str): Password to open the PFX package + private_key_password (bytes): Private key password + + Returns: + bytes: + """ + private_key_object = serialization.load_pem_private_key( + private_key, password=private_key_password + ) + certificate_object = x509.load_pem_x509_certificate(certificate) + name = certificate_object.subject.rfc4514_string() + pfx_bytes = pkcs12.serialize_key_and_certificates( + name=name.encode(), + cert=certificate_object, + key=private_key_object, # type: ignore[arg-type] + cas=None, + encryption_algorithm=serialization.BestAvailableEncryption(package_password.encode()), + ) + return pfx_bytes + + +def generate_private_key( + password: Optional[bytes] = None, + key_size: int = 2048, + public_exponent: int = 65537, +) -> bytes: + """Generates a private key. + + Args: + password (bytes): Password for decrypting the private key + key_size (int): Key size in bytes + public_exponent: Public exponent. + + Returns: + bytes: Private Key + """ + private_key = rsa.generate_private_key( + public_exponent=public_exponent, + key_size=key_size, + ) + key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(password) + if password + else serialization.NoEncryption(), + ) + return key_bytes + + +def generate_csr( + private_key: bytes, + subject: str, + add_unique_id_to_subject_name: bool = True, + organization: Optional[str] = None, + email_address: Optional[str] = None, + country_name: Optional[str] = None, + private_key_password: Optional[bytes] = None, + sans: Optional[List[str]] = None, + sans_oid: Optional[List[str]] = None, + sans_ip: Optional[List[str]] = None, + sans_dns: Optional[List[str]] = None, + additional_critical_extensions: Optional[List] = None, +) -> bytes: + """Generates a CSR using private key and subject. + + Args: + private_key (bytes): Private key + subject (str): CSR Subject. + add_unique_id_to_subject_name (bool): Whether a unique ID must be added to the CSR's + subject name. Always leave to "True" when the CSR is used to request certificates + using the tls-certificates relation. + organization (str): Name of organization. + email_address (str): Email address. + country_name (str): Country Name. + private_key_password (bytes): Private key password + sans (list): Use sans_dns - this will be deprecated in a future release + List of DNS subject alternative names (keeping it for now for backward compatibility) + sans_oid (list): List of registered ID SANs + sans_dns (list): List of DNS subject alternative names (similar to the arg: sans) + sans_ip (list): List of IP subject alternative names + additional_critical_extensions (list): List of critical additional extension objects. + Object must be a x509 ExtensionType. + + Returns: + bytes: CSR + """ + signing_key = serialization.load_pem_private_key(private_key, password=private_key_password) + subject_name = [x509.NameAttribute(x509.NameOID.COMMON_NAME, subject)] + if add_unique_id_to_subject_name: + unique_identifier = uuid.uuid4() + subject_name.append( + x509.NameAttribute(x509.NameOID.X500_UNIQUE_IDENTIFIER, str(unique_identifier)) + ) + if organization: + subject_name.append(x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, organization)) + if email_address: + subject_name.append(x509.NameAttribute(x509.NameOID.EMAIL_ADDRESS, email_address)) + if country_name: + subject_name.append(x509.NameAttribute(x509.NameOID.COUNTRY_NAME, country_name)) + csr = x509.CertificateSigningRequestBuilder(subject_name=x509.Name(subject_name)) + + _sans: List[x509.GeneralName] = [] + if sans_oid: + _sans.extend([x509.RegisteredID(x509.ObjectIdentifier(san)) for san in sans_oid]) + if sans_ip: + _sans.extend([x509.IPAddress(IPv4Address(san)) for san in sans_ip]) + if sans: + _sans.extend([x509.DNSName(san) for san in sans]) + if sans_dns: + _sans.extend([x509.DNSName(san) for san in sans_dns]) + if _sans: + csr = csr.add_extension(x509.SubjectAlternativeName(set(_sans)), critical=False) + + if additional_critical_extensions: + for extension in additional_critical_extensions: + csr = csr.add_extension(extension, critical=True) + + signed_certificate = csr.sign(signing_key, hashes.SHA256()) # type: ignore[arg-type] + return signed_certificate.public_bytes(serialization.Encoding.PEM) + + +class CertificatesProviderCharmEvents(CharmEvents): + """List of events that the TLS Certificates provider charm can leverage.""" + + certificate_creation_request = EventSource(CertificateCreationRequestEvent) + certificate_revocation_request = EventSource(CertificateRevocationRequestEvent) + + +class CertificatesRequirerCharmEvents(CharmEvents): + """List of events that the TLS Certificates requirer charm can leverage.""" + + certificate_available = EventSource(CertificateAvailableEvent) + certificate_expiring = EventSource(CertificateExpiringEvent) + certificate_invalidated = EventSource(CertificateInvalidatedEvent) + all_certificates_invalidated = EventSource(AllCertificatesInvalidatedEvent) + + +class TLSCertificatesProvidesV2(Object): + """TLS certificates provider class to be instantiated by TLS certificates providers.""" + + on = CertificatesProviderCharmEvents() + + def __init__(self, charm: CharmBase, relationship_name: str): + super().__init__(charm, relationship_name) + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.charm = charm + self.relationship_name = relationship_name + + def _load_app_relation_data(self, relation: Relation) -> dict: + """Loads relation data from the application relation data bag. + + Json loads all data. + + Args: + relation_object: Relation data from the application databag + + Returns: + dict: Relation data in dict format. + """ + # If unit is not leader, it does not try to reach relation data. + if not self.model.unit.is_leader(): + return {} + return _load_relation_data(relation.data[self.charm.app]) + + def _add_certificate( + self, + relation_id: int, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + ) -> None: + """Adds certificate to relation data. + + Args: + relation_id (int): Relation id + certificate (str): Certificate + certificate_signing_request (str): Certificate Signing Request + ca (str): CA Certificate + chain (list): CA Chain + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_certificate = { + "certificate": certificate, + "certificate_signing_request": certificate_signing_request, + "ca": ca, + "chain": chain, + } + provider_relation_data = self._load_app_relation_data(relation) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + if new_certificate in certificates: + logger.info("Certificate already in relation data - Doing nothing") + return + certificates.append(new_certificate) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + def _remove_certificate( + self, + relation_id: int, + certificate: Optional[str] = None, + certificate_signing_request: Optional[str] = None, + ) -> None: + """Removes certificate from a given relation based on user provided certificate or csr. + + Args: + relation_id (int): Relation id + certificate (str): Certificate (optional) + certificate_signing_request: Certificate signing request (optional) + + Returns: + None + """ + relation = self.model.get_relation( + relation_name=self.relationship_name, + relation_id=relation_id, + ) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} with relation id {relation_id} does not exist" + ) + provider_relation_data = self._load_app_relation_data(relation) + provider_certificates = provider_relation_data.get("certificates", []) + certificates = copy.deepcopy(provider_certificates) + for certificate_dict in certificates: + if certificate and certificate_dict["certificate"] == certificate: + certificates.remove(certificate_dict) + if ( + certificate_signing_request + and certificate_dict["certificate_signing_request"] == certificate_signing_request + ): + certificates.remove(certificate_dict) + relation.data[self.model.app]["certificates"] = json.dumps(certificates) + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Uses JSON schema validator to validate relation data content. + + Args: + certificates_data (dict): Certificate data dictionary as retrieved from relation data. + + Returns: + bool: True/False depending on whether the relation data follows the json schema. + """ + try: + validate(instance=certificates_data, schema=REQUIRER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def revoke_all_certificates(self) -> None: + """Revokes all certificates of this provider. + + This method is meant to be used when the Root CA has changed. + """ + for relation in self.model.relations[self.relationship_name]: + provider_relation_data = self._load_app_relation_data(relation) + provider_certificates = copy.deepcopy(provider_relation_data.get("certificates", [])) + for certificate in provider_certificates: + certificate["revoked"] = True + relation.data[self.model.app]["certificates"] = json.dumps(provider_certificates) + + def set_relation_certificate( + self, + certificate: str, + certificate_signing_request: str, + ca: str, + chain: List[str], + relation_id: int, + ) -> None: + """Adds certificates to relation data. + + Args: + certificate (str): Certificate + certificate_signing_request (str): Certificate signing request + ca (str): CA Certificate + chain (list): CA Chain + relation_id (int): Juju relation ID + + Returns: + None + """ + if not self.model.unit.is_leader(): + return + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + self._remove_certificate( + certificate_signing_request=certificate_signing_request.strip(), + relation_id=relation_id, + ) + self._add_certificate( + relation_id=relation_id, + certificate=certificate.strip(), + certificate_signing_request=certificate_signing_request.strip(), + ca=ca.strip(), + chain=[cert.strip() for cert in chain], + ) + + def remove_certificate(self, certificate: str) -> None: + """Removes a given certificate from relation data. + + Args: + certificate (str): TLS Certificate + + Returns: + None + """ + certificates_relation = self.model.relations[self.relationship_name] + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + for certificate_relation in certificates_relation: + self._remove_certificate(certificate=certificate, relation_id=certificate_relation.id) + + def get_issued_certificates( + self, relation_id: Optional[int] = None + ) -> Dict[str, List[Dict[str, str]]]: + """Returns a dictionary of issued certificates. + + It returns certificates from all relations if relation_id is not specified. + Certificates are returned per application name and CSR. + + Returns: + dict: Certificates per application name. + """ + certificates: Dict[str, List[Dict[str, str]]] = {} + relations = ( + [ + relation + for relation in self.model.relations[self.relationship_name] + if relation.id == relation_id + ] + if relation_id is not None + else self.model.relations.get(self.relationship_name, []) + ) + for relation in relations: + provider_relation_data = self._load_app_relation_data(relation) + provider_certificates = provider_relation_data.get("certificates", []) + + certificates[relation.app.name] = [] # type: ignore[union-attr] + for certificate in provider_certificates: + if not certificate.get("revoked", False): + certificates[relation.app.name].append( # type: ignore[union-attr] + { + "csr": certificate["certificate_signing_request"], + "certificate": certificate["certificate"], + } + ) + + return certificates + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggered on relation changed event. + + Looks at the relation data and either emits: + - certificate request event: If the unit relation data contains a CSR for which + a certificate does not exist in the provider relation data. + - certificate revocation event: If the provider relation data contains a CSR for which + a csr does not exist in the requirer relation data. + + Args: + event: Juju event + + Returns: + None + """ + if event.unit is None: + logger.error("Relation_changed event does not have a unit.") + return + if not self.model.unit.is_leader(): + return + requirer_relation_data = _load_relation_data(event.relation.data[event.unit]) + provider_relation_data = self._load_app_relation_data(event.relation) + if not self._relation_data_is_valid(requirer_relation_data): + logger.debug("Relation data did not pass JSON Schema validation") + return + provider_certificates = provider_relation_data.get("certificates", []) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + provider_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in provider_certificates + ] + requirer_unit_certificate_requests = [ + { + "csr": certificate_creation_request["certificate_signing_request"], + "is_ca": certificate_creation_request.get("ca", False), + } + for certificate_creation_request in requirer_csrs + ] + for certificate_request in requirer_unit_certificate_requests: + if certificate_request["csr"] not in provider_csrs: + self.on.certificate_creation_request.emit( + certificate_signing_request=certificate_request["csr"], + relation_id=event.relation.id, + is_ca=certificate_request["is_ca"], + ) + self._revoke_certificates_for_which_no_csr_exists(relation_id=event.relation.id) + + def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None: + """Revokes certificates for which no unit has a CSR. + + Goes through all generated certificates and compare against the list of CSRs for all units + of a given relationship. + + Args: + relation_id (int): Relation id + + Returns: + None + """ + certificates_relation = self.model.get_relation( + relation_name=self.relationship_name, relation_id=relation_id + ) + if not certificates_relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + provider_relation_data = self._load_app_relation_data(certificates_relation) + list_of_csrs: List[str] = [] + for unit in certificates_relation.units: + requirer_relation_data = _load_relation_data(certificates_relation.data[unit]) + requirer_csrs = requirer_relation_data.get("certificate_signing_requests", []) + list_of_csrs.extend(csr["certificate_signing_request"] for csr in requirer_csrs) + provider_certificates = provider_relation_data.get("certificates", []) + for certificate in provider_certificates: + if certificate["certificate_signing_request"] not in list_of_csrs: + self.on.certificate_revocation_request.emit( + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + self.remove_certificate(certificate=certificate["certificate"]) + + def get_outstanding_certificate_requests( + self, relation_id: Optional[int] = None + ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: + """Returns CSR's for which no certificate has been issued. + + Example return: [ + { + "relation_id": 0, + "application_name": "tls-certificates-requirer", + "unit_name": "tls-certificates-requirer/0", + "unit_csrs": [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", + "is_ca": false + } + ] + } + ] + + Args: + relation_id (int): Relation id + + Returns: + list: List of dictionaries that contain the unit's csrs + that don't have a certificate issued. + """ + all_unit_csr_mappings = copy.deepcopy(self.get_requirer_csrs(relation_id=relation_id)) + filtered_all_unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] + for unit_csr_mapping in all_unit_csr_mappings: + csrs_without_certs = [] + for csr in unit_csr_mapping["unit_csrs"]: # type: ignore[union-attr] + if not self.certificate_issued_for_csr( + app_name=unit_csr_mapping["application_name"], # type: ignore[arg-type] + csr=csr["certificate_signing_request"], # type: ignore[index] + relation_id=relation_id, + ): + csrs_without_certs.append(csr) + if csrs_without_certs: + unit_csr_mapping["unit_csrs"] = csrs_without_certs # type: ignore[assignment] + filtered_all_unit_csr_mappings.append(unit_csr_mapping) + return filtered_all_unit_csr_mappings + + def get_requirer_csrs( + self, relation_id: Optional[int] = None + ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: + """Returns a list of requirers' CSRs grouped by unit. + + It returns CSRs from all relations if relation_id is not specified. + CSRs are returned per relation id, application name and unit name. + + Returns: + list: List of dictionaries that contain the unit's csrs + with the following information + relation_id, application_name and unit_name. + """ + unit_csr_mappings: List[Dict[str, Union[int, str, List[Dict[str, str]]]]] = [] + + relations = ( + [ + relation + for relation in self.model.relations[self.relationship_name] + if relation.id == relation_id + ] + if relation_id is not None + else self.model.relations.get(self.relationship_name, []) + ) + + for relation in relations: + for unit in relation.units: + requirer_relation_data = _load_relation_data(relation.data[unit]) + unit_csrs_list = requirer_relation_data.get("certificate_signing_requests", []) + unit_csr_mappings.append( + { + "relation_id": relation.id, + "application_name": relation.app.name, # type: ignore[union-attr] + "unit_name": unit.name, + "unit_csrs": unit_csrs_list, + } + ) + return unit_csr_mappings + + def certificate_issued_for_csr( + self, app_name: str, csr: str, relation_id: Optional[int] + ) -> bool: + """Checks whether a certificate has been issued for a given CSR. + + Args: + app_name (str): Application name that the CSR belongs to. + csr (str): Certificate Signing Request. + relation_id (Optional[int]): Relation ID + Returns: + bool: True/False depending on whether a certificate has been issued for the given CSR. + """ + issued_certificates_per_csr = self.get_issued_certificates(relation_id=relation_id)[ + app_name + ] + for issued_pair in issued_certificates_per_csr: + if "csr" in issued_pair and issued_pair["csr"] == csr: + return csr_matches_certificate(csr, issued_pair["certificate"]) + return False + + +class TLSCertificatesRequiresV2(Object): + """TLS certificates requirer class to be instantiated by TLS certificates requirers.""" + + on = CertificatesRequirerCharmEvents() + + def __init__( + self, + charm: CharmBase, + relationship_name: str, + expiry_notification_time: int = 168, + ): + """Generates/use private key and observes relation changed event. + + Args: + charm: Charm object + relationship_name: Juju relation name + expiry_notification_time (int): Time difference between now and expiry (in hours). + Used to trigger the CertificateExpiring event. Default: 7 days. + """ + super().__init__(charm, relationship_name) + self.relationship_name = relationship_name + self.charm = charm + self.expiry_notification_time = expiry_notification_time + self.framework.observe( + charm.on[relationship_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + charm.on[relationship_name].relation_broken, self._on_relation_broken + ) + if JujuVersion.from_environ().has_secrets: + self.framework.observe(charm.on.secret_expired, self._on_secret_expired) + else: + self.framework.observe(charm.on.update_status, self._on_update_status) + + @property + def _requirer_csrs(self) -> List[Dict[str, Union[bool, str]]]: + """Returns list of requirer's CSRs from relation data. + + Example: + [ + { + "certificate_signing_request": "-----BEGIN CERTIFICATE REQUEST-----...", + "ca": false + } + ] + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError(f"Relation {self.relationship_name} does not exist") + requirer_relation_data = _load_relation_data(relation.data[self.model.unit]) + return requirer_relation_data.get("certificate_signing_requests", []) + + @property + def _provider_certificates(self) -> List[Dict[str, str]]: + """Returns list of certificates from the provider's relation data.""" + relation = self.model.get_relation(self.relationship_name) + if not relation: + logger.debug("No relation: %s", self.relationship_name) + return [] + if not relation.app: + logger.debug("No remote app in relation: %s", self.relationship_name) + return [] + provider_relation_data = _load_relation_data(relation.data[relation.app]) + if not self._relation_data_is_valid(provider_relation_data): + logger.warning("Provider relation data did not pass JSON Schema validation") + return [] + return provider_relation_data.get("certificates", []) + + def _add_requirer_csr(self, csr: str, is_ca: bool) -> None: + """Adds CSR to relation data. + + Args: + csr (str): Certificate Signing Request + is_ca (bool): Whether the certificate is a CA certificate + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + new_csr_dict: Dict[str, Union[bool, str]] = { + "certificate_signing_request": csr, + "ca": is_ca, + } + if new_csr_dict in self._requirer_csrs: + logger.info("CSR already in relation data - Doing nothing") + return + requirer_csrs = copy.deepcopy(self._requirer_csrs) + requirer_csrs.append(new_csr_dict) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def _remove_requirer_csr(self, csr: str) -> None: + """Removes CSR from relation data. + + Args: + csr (str): Certificate signing request + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + requirer_csrs = copy.deepcopy(self._requirer_csrs) + if not requirer_csrs: + logger.info("No CSRs in relation data - Doing nothing") + return + for requirer_csr in requirer_csrs: + if requirer_csr["certificate_signing_request"] == csr: + requirer_csrs.remove(requirer_csr) + relation.data[self.model.unit]["certificate_signing_requests"] = json.dumps(requirer_csrs) + + def request_certificate_creation( + self, certificate_signing_request: bytes, is_ca: bool = False + ) -> None: + """Request TLS certificate to provider charm. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + is_ca (bool): Whether the certificate is a CA certificate + + Returns: + None + """ + relation = self.model.get_relation(self.relationship_name) + if not relation: + raise RuntimeError( + f"Relation {self.relationship_name} does not exist - " + f"The certificate request can't be completed" + ) + self._add_requirer_csr(certificate_signing_request.decode().strip(), is_ca=is_ca) + logger.info("Certificate request sent to provider") + + def request_certificate_revocation(self, certificate_signing_request: bytes) -> None: + """Removes CSR from relation data. + + The provider of this relation is then expected to remove certificates associated to this + CSR from the relation data as well and emit a request_certificate_revocation event for the + provider charm to interpret. + + Args: + certificate_signing_request (bytes): Certificate Signing Request + + Returns: + None + """ + self._remove_requirer_csr(certificate_signing_request.decode().strip()) + logger.info("Certificate revocation sent to provider") + + def request_certificate_renewal( + self, old_certificate_signing_request: bytes, new_certificate_signing_request: bytes + ) -> None: + """Renews certificate. + + Removes old CSR from relation data and adds new one. + + Args: + old_certificate_signing_request: Old CSR + new_certificate_signing_request: New CSR + + Returns: + None + """ + try: + self.request_certificate_revocation( + certificate_signing_request=old_certificate_signing_request + ) + except RuntimeError: + logger.warning("Certificate revocation failed.") + self.request_certificate_creation( + certificate_signing_request=new_certificate_signing_request + ) + logger.info("Certificate renewal request completed.") + + @staticmethod + def _relation_data_is_valid(certificates_data: dict) -> bool: + """Checks whether relation data is valid based on json schema. + + Args: + certificates_data: Certificate data in dict format. + + Returns: + bool: Whether relation data is valid. + """ + try: + validate(instance=certificates_data, schema=PROVIDER_JSON_SCHEMA) + return True + except exceptions.ValidationError: + return False + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handler triggered on relation changed events. + + Goes through all providers certificates that match a requested CSR. + + If the provider certificate is revoked, emit a CertificateInvalidateEvent, + otherwise emit a CertificateAvailableEvent. + + When Juju secrets are available, remove the secret for revoked certificate, + or add a secret with the correct expiry time for new certificates. + + + Args: + event: Juju event + + Returns: + None + """ + requirer_csrs = [ + certificate_creation_request["certificate_signing_request"] + for certificate_creation_request in self._requirer_csrs + ] + for certificate in self._provider_certificates: + if certificate["certificate_signing_request"] in requirer_csrs: + if certificate.get("revoked", False): + if JujuVersion.from_environ().has_secrets: + with suppress(SecretNotFoundError): + secret = self.model.get_secret( + label=f"{LIBID}-{certificate['certificate_signing_request']}" + ) + secret.remove_all_revisions() + self.on.certificate_invalidated.emit( + reason="revoked", + certificate=certificate["certificate"], + certificate_signing_request=certificate["certificate_signing_request"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + else: + if JujuVersion.from_environ().has_secrets: + try: + secret = self.model.get_secret( + label=f"{LIBID}-{certificate['certificate_signing_request']}" + ) + secret.set_content({"certificate": certificate["certificate"]}) + secret.set_info( + expire=self._get_next_secret_expiry_time( + certificate["certificate"] + ), + ) + except SecretNotFoundError: + secret = self.charm.unit.add_secret( + {"certificate": certificate["certificate"]}, + label=f"{LIBID}-{certificate['certificate_signing_request']}", + expire=self._get_next_secret_expiry_time( + certificate["certificate"] + ), + ) + self.on.certificate_available.emit( + certificate_signing_request=certificate["certificate_signing_request"], + certificate=certificate["certificate"], + ca=certificate["ca"], + chain=certificate["chain"], + ) + + def _get_next_secret_expiry_time(self, certificate: str) -> Optional[datetime]: + """Return the expiry time or expiry notification time. + + Extracts the expiry time from the provided certificate, calculates the + expiry notification time and return the closest of the two, that is in + the future. + + Args: + certificate: x509 certificate + + Returns: + Optional[datetime]: None if the certificate expiry time cannot be read, + next expiry time otherwise. + """ + expiry_time = _get_certificate_expiry_time(certificate) + if not expiry_time: + return None + expiry_notification_time = expiry_time - timedelta(hours=self.expiry_notification_time) + return _get_closest_future_time(expiry_notification_time, expiry_time) + + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: + """Handler triggered on relation broken event. + + Emitting `all_certificates_invalidated` from `relation-broken` rather + than `relation-departed` since certs are stored in app data. + + Args: + event: Juju event + + Returns: + None + """ + self.on.all_certificates_invalidated.emit() + + def _on_secret_expired(self, event: SecretExpiredEvent) -> None: + """Triggered when a certificate is set to expire. + + Loads the certificate from the secret, and will emit 1 of 2 + events. + + If the certificate is not yet expired, emits CertificateExpiringEvent + and updates the expiry time of the secret to the exact expiry time on + the certificate. + + If the certificate is expired, emits CertificateInvalidedEvent and + deletes the secret. + + Args: + event (SecretExpiredEvent): Juju event + """ + if not event.secret.label or not event.secret.label.startswith(f"{LIBID}-"): + return + csr = event.secret.label[len(f"{LIBID}-") :] + certificate_dict = self._find_certificate_in_relation_data(csr) + if not certificate_dict: + # A secret expired but we did not find matching certificate. Cleaning up + event.secret.remove_all_revisions() + return + + expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) + if not expiry_time: + # A secret expired but matching certificate is invalid. Cleaning up + event.secret.remove_all_revisions() + return + + if datetime.utcnow() < expiry_time: + logger.warning("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate_dict["certificate"], + expiry=expiry_time.isoformat(), + ) + event.secret.set_info( + expire=_get_certificate_expiry_time(certificate_dict["certificate"]), + ) + else: + logger.warning("Certificate is expired") + self.on.certificate_invalidated.emit( + reason="expired", + certificate=certificate_dict["certificate"], + certificate_signing_request=certificate_dict["certificate_signing_request"], + ca=certificate_dict["ca"], + chain=certificate_dict["chain"], + ) + self.request_certificate_revocation(certificate_dict["certificate"].encode()) + event.secret.remove_all_revisions() + + def _find_certificate_in_relation_data(self, csr: str) -> Optional[Dict[str, Any]]: + """Returns the certificate that match the given CSR.""" + for certificate_dict in self._provider_certificates: + if certificate_dict["certificate_signing_request"] != csr: + continue + return certificate_dict + return None + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Triggered on update status event. + + Goes through each certificate in the "certificates" relation and checks their expiry date. + If they are close to expire (<7 days), emits a CertificateExpiringEvent event and if + they are expired, emits a CertificateExpiredEvent. + + Args: + event (UpdateStatusEvent): Juju event + + Returns: + None + """ + for certificate_dict in self._provider_certificates: + expiry_time = _get_certificate_expiry_time(certificate_dict["certificate"]) + if not expiry_time: + continue + time_difference = expiry_time - datetime.utcnow() + if time_difference.total_seconds() < 0: + logger.warning("Certificate is expired") + self.on.certificate_invalidated.emit( + reason="expired", + certificate=certificate_dict["certificate"], + certificate_signing_request=certificate_dict["certificate_signing_request"], + ca=certificate_dict["ca"], + chain=certificate_dict["chain"], + ) + self.request_certificate_revocation(certificate_dict["certificate"].encode()) + continue + if time_difference.total_seconds() < (self.expiry_notification_time * 60 * 60): + logger.warning("Certificate almost expired") + self.on.certificate_expiring.emit( + certificate=certificate_dict["certificate"], + expiry=expiry_time.isoformat(), + ) + + +def csr_matches_certificate(csr: str, cert: str) -> bool: + """Check if a CSR matches a certificate. + + expects to get the original string representations. + + Args: + csr (str): Certificate Signing Request + cert (str): Certificate + Returns: + bool: True/False depending on whether the CSR matches the certificate. + """ + try: + csr_object = x509.load_pem_x509_csr(csr.encode("utf-8")) + cert_object = x509.load_pem_x509_certificate(cert.encode("utf-8")) + + if csr_object.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) != cert_object.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ): + return False + if ( + csr_object.public_key().public_numbers().n # type: ignore[union-attr] + != cert_object.public_key().public_numbers().n # type: ignore[union-attr] + ): + return False + except ValueError: + logger.warning("Could not load certificate or CSR.") + return False + return True + + +def _get_closest_future_time( + expiry_notification_time: datetime, expiry_time: datetime +) -> datetime: + """Return expiry_notification_time if not in the past, otherwise return expiry_time. + + Args: + expiry_notification_time (datetime): Notification time of impending expiration + expiry_time (datetime): Expiration time + + Returns: + datetime: expiry_notification_time if not in the past, expiry_time otherwise + """ + return ( + expiry_notification_time if datetime.utcnow() < expiry_notification_time else expiry_time + ) + + +def _get_certificate_expiry_time(certificate: str) -> Optional[datetime]: + """Extract expiry time from a certificate string. + + Args: + certificate (str): x509 certificate as a string + + Returns: + Optional[datetime]: Expiry datetime or None + """ + try: + certificate_object = x509.load_pem_x509_certificate(data=certificate.encode()) + return certificate_object.not_valid_after + except ValueError: + logger.warning("Could not load certificate.") + return None diff --git a/metadata.yaml b/metadata.yaml index e44f941d..9f84a1ed 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -26,12 +26,17 @@ requires: limit: 1 logging: interface: loki_push_api + optional: true + certificates: + interface: tls-certificates + limit: 1 + optional: true provides: metrics-endpoint: - interface: prometheus_scrape description: | - Provides application metrics to COS Prometheus instance + Provides application metrics to COS Prometheus instance. + interface: prometheus_scrape grafana-dashboard: description: | Forwards the built-in grafana dashboard(s) for monitoring GLAuth. @@ -42,5 +47,13 @@ provides: interface: ldap glauth-auxiliary: description: | - Provides auxiliary data for glauth-utils charmed operator + Provides auxiliary data for glauth-utils charmed operator. interface: glauth_auxiliary + send-ca-cert: + description: | + Transfer certificates to client charmed operators. + interface: certificate_transfer + +peers: + glauth-peers: + interface: glauth_peers diff --git a/pyproject.toml b/pyproject.toml index 571f0a78..1d1aaaaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,9 @@ extend-ignore = [ "D409", "D413", ] -ignore = ["D100", "D101", "D102", "D103", "D105", "D107", "E501", "N818"] +include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py", "lib/charms/glauth_k8s/**/.py"] extend-exclude = ["__pycache__", "*.egg_info"] +ignore = ["D100", "D101", "D102", "D103", "D105", "D107", "E501", "N818"] per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} [tool.ruff.mccabe] diff --git a/src/charm.py b/src/charm.py index d7ff175c..ea5a4798 100755 --- a/src/charm.py +++ b/src/charm.py @@ -18,10 +18,12 @@ from charms.glauth_utils.v0.glauth_auxiliary import AuxiliaryProvider, AuxiliaryRequestedEvent from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer, PromtailDigestError +from charms.observability_libs.v0.cert_handler import CertChanged from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider from configs import ConfigFile, DatabaseConfig, pebble_layer from constants import ( + CERTIFICATES_TRANSFER_INTEGRATION_NAME, DATABASE_INTEGRATION_NAME, GLAUTH_CONFIG_DIR, GLAUTH_LDAP_PORT, @@ -32,7 +34,13 @@ PROMETHEUS_SCRAPE_INTEGRATION_NAME, WORKLOAD_CONTAINER, ) -from integrations import AuxiliaryIntegration, LdapIntegration +from exceptions import CertificatesError +from integrations import ( + AuxiliaryIntegration, + CertificatesIntegration, + CertificatesTransferIntegration, + LdapIntegration, +) from kubernetes_resource import ConfigMapResource, StatefulSetResource from lightkube import Client from ops.charm import ( @@ -41,6 +49,7 @@ HookEvent, InstallEvent, PebbleReadyEvent, + RelationJoinedEvent, RemoveEvent, ) from ops.main import main @@ -89,6 +98,18 @@ def __init__(self, *args: Any): self._on_auxiliary_requested, ) + self._certs_integration = CertificatesIntegration(self) + self.framework.observe( + self._certs_integration.cert_handler.on.cert_changed, + self._on_cert_changed, + ) + + self._certs_transfer_integration = CertificatesTransferIntegration(self) + self.framework.observe( + self.on[CERTIFICATES_TRANSFER_INTEGRATION_NAME].relation_joined, + self._on_certificates_transfer_relation_joined, + ) + self.service_patcher = KubernetesServicePatch(self, [("ldap", GLAUTH_LDAP_PORT)]) self.loki_consumer = LogProxyConsumer( @@ -221,9 +242,7 @@ def _on_pebble_ready(self, event: PebbleReadyEvent) -> None: @validate_database_resource def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: if not (requirer_data := event.data): - logger.error( - f"The LDAP requirer {event.app.name} does not provide " f"necessary data." - ) + logger.error(f"The LDAP requirer {event.app.name} does not provide necessary data.") return self._ldap_integration.load_bind_account(requirer_data.user, requirer_data.group) @@ -239,6 +258,29 @@ def _on_auxiliary_requested(self, event: AuxiliaryRequestedEvent) -> None: data=self._auxiliary_integration.auxiliary_data, ) + @validate_container_connectivity + def _on_cert_changed(self, event: CertChanged) -> None: + try: + self._certs_integration.update_certificates() + except CertificatesError: + self.unit.status = BlockedStatus( + "Failed to update the TLS certificates, please check the logs" + ) + return + + self._certs_transfer_integration.transfer_certificates( + self._certs_integration.cert_data, + ) + + def _on_certificates_transfer_relation_joined(self, event: RelationJoinedEvent) -> None: + if not self._certs_integration.certs_ready(): + event.defer() + return + + self._certs_transfer_integration.transfer_certificates( + self._certs_integration.cert_data, event.relation.id + ) + def _on_promtail_error(self, event: PromtailDigestError) -> None: logger.error(event.message) diff --git a/src/configs.py b/src/configs.py index c71789dd..22bd32b5 100644 --- a/src/configs.py +++ b/src/configs.py @@ -1,7 +1,12 @@ from dataclasses import asdict, dataclass from typing import Any, Optional -from constants import GLAUTH_COMMANDS, LOG_FILE, POSTGRESQL_DSN_TEMPLATE, WORKLOAD_SERVICE +from constants import ( + GLAUTH_COMMANDS, + LOG_FILE, + POSTGRESQL_DSN_TEMPLATE, + WORKLOAD_SERVICE, +) from jinja2 import Template from ops.pebble import Layer diff --git a/src/constants.py b/src/constants.py index c6350b0c..1b949cf9 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,13 +1,14 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from pathlib import PurePath +from pathlib import Path, PurePath from string import Template DATABASE_INTEGRATION_NAME = "pg-database" LOKI_API_PUSH_INTEGRATION_NAME = "logging" PROMETHEUS_SCRAPE_INTEGRATION_NAME = "metrics-endpoint" GRAFANA_DASHBOARD_INTEGRATION_NAME = "grafana-dashboard" +CERTIFICATES_TRANSFER_INTEGRATION_NAME = "send-ca-cert" GLAUTH_CONFIG_DIR = PurePath("/etc/config") GLAUTH_CONFIG_FILE = GLAUTH_CONFIG_DIR / "glauth.cfg" @@ -23,3 +24,11 @@ DEFAULT_UID = 5001 DEFAULT_GID = 5501 POSTGRESQL_DSN_TEMPLATE = Template("postgresql+psycopg://$username:$password@$endpoint/$database") + +CERTIFICATE_FILE = Path("/etc/ssl/certs/ca-certificates.crt") +PRIVATE_KEY_DIR = Path("/etc/ssl/private") +LOCAL_CA_CERTS_DIR = Path("/usr/local/share/ca-certificates") + +SERVER_CA_CERT = LOCAL_CA_CERTS_DIR / "glauth-ca.crt" +SERVER_KEY = PRIVATE_KEY_DIR / "glauth-server.key" +SERVER_CERT = LOCAL_CA_CERTS_DIR / "glauth-server.crt" diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 00000000..2c1bc532 --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,6 @@ +class CharmError(Exception): + """Base class for custom charm errors.""" + + +class CertificatesError(CharmError): + """Error for tls certificates related operations.""" diff --git a/src/integrations.py b/src/integrations.py index 7665ac8a..271c06bc 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -1,15 +1,38 @@ import hashlib import logging +import subprocess +from contextlib import suppress from dataclasses import dataclass from secrets import token_bytes from typing import Optional +from charms.certificate_transfer_interface.v0.certificate_transfer import ( + CertificateTransferProvides, +) from charms.glauth_k8s.v0.ldap import LdapProviderBaseData, LdapProviderData from charms.glauth_utils.v0.glauth_auxiliary import AuxiliaryData +from charms.observability_libs.v0.cert_handler import CertHandler from configs import DatabaseConfig -from constants import DEFAULT_GID, DEFAULT_UID, GLAUTH_LDAP_PORT +from constants import ( + CERTIFICATE_FILE, + CERTIFICATES_TRANSFER_INTEGRATION_NAME, + DEFAULT_GID, + DEFAULT_UID, + GLAUTH_LDAP_PORT, + SERVER_CA_CERT, + SERVER_CERT, + SERVER_KEY, +) from database import Capability, Group, Operation, User +from exceptions import CertificatesError from ops.charm import CharmBase +from ops.pebble import PathError +from tenacity import ( + Retrying, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) logger = logging.getLogger(__name__) @@ -98,3 +121,133 @@ def auxiliary_data(self) -> AuxiliaryData: username=database_config.username, password=database_config.password, ) + + +@dataclass +class CertificateData: + ca_cert: Optional[str] = None + ca_chain: Optional[list[str]] = None + cert: Optional[str] = None + + +class CertificatesIntegration: + def __init__(self, charm: CharmBase) -> None: + self._charm = charm + self._container = charm._container + + hostname = charm.config.get("hostname") + self.cert_handler = CertHandler( + charm, + key="glauth-server-cert", + peer_relation_name="glauth-peers", + cert_subject=hostname, + extra_sans_dns=[hostname], + ) + + @property + def _ca_cert(self) -> Optional[str]: + return self.cert_handler.ca + + @property + def _server_key(self) -> Optional[str]: + return self.cert_handler.key + + @property + def _server_cert(self) -> Optional[str]: + return self.cert_handler.cert + + @property + def _ca_chain(self) -> list[str]: + return self.cert_handler.chain + + @property + def cert_data(self) -> CertificateData: + return CertificateData( + ca_cert=self._ca_cert, + ca_chain=self._ca_chain, + cert=self._server_cert, + ) + + def update_certificates(self) -> None: + if not self.cert_handler.enabled: + logger.debug("The certificates integration is not ready.") + self._remove_certificates() + return + + if not self.certs_ready(): + logger.debug("The certificates data is not ready.") + self._remove_certificates() + return + + self._prepare_certificates() + self._push_certificates() + + def certs_ready(self) -> bool: + return all((self._ca_cert, self._ca_chain, self._server_key, self._server_cert)) + + def _prepare_certificates(self) -> None: + SERVER_CA_CERT.write_text(self._ca_cert) # type: ignore[arg-type] + SERVER_KEY.write_text(self._server_key) # type: ignore[arg-type] + SERVER_CERT.write_text(self._server_cert) # type: ignore[arg-type] + + try: + for attempt in Retrying( + wait=wait_fixed(3), + stop=stop_after_attempt(3), + retry=retry_if_exception_type(subprocess.CalledProcessError), + reraise=True, + ): + with attempt: + subprocess.run( + ["update-ca-certificates", "--fresh"], + check=True, + text=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + logger.error(f"{e.stderr}") + raise CertificatesError("Update the TLS certificates failed.") + + def _push_certificates(self) -> None: + self._container.push(CERTIFICATE_FILE, CERTIFICATE_FILE.read_text(), make_dirs=True) + self._container.push(SERVER_CA_CERT, self._ca_cert, make_dirs=True) + self._container.push(SERVER_KEY, self._server_key, make_dirs=True) + self._container.push(SERVER_CERT, self._server_cert, make_dirs=True) + + def _remove_certificates(self) -> None: + for file in (CERTIFICATE_FILE, SERVER_CA_CERT, SERVER_KEY, SERVER_CERT): + with suppress(PathError): + self._container.remove_path(file) + + +class CertificatesTransferIntegration: + def __init__(self, charm: CharmBase): + self._charm = charm + self._certs_transfer_provider = CertificateTransferProvides( + charm, relationship_name=CERTIFICATES_TRANSFER_INTEGRATION_NAME + ) + + def transfer_certificates( + self, /, data: CertificateData, relation_id: Optional[int] = None + ) -> None: + if not ( + relations := self._charm.model.relations.get(CERTIFICATES_TRANSFER_INTEGRATION_NAME) + ): + return + + if relation_id is not None: + relations = [relation for relation in relations if relation.id == relation_id] + + ca_cert, ca_chain, certificate = data.ca_cert, data.ca_chain, data.cert + if not all((ca_cert, ca_chain, certificate)): + for relation in relations: + self._certs_transfer_provider.remove_certificate(relation_id=relation.id) + return + + for relation in relations: + self._certs_transfer_provider.set_certificate( + ca=data.ca_cert, # type: ignore[arg-type] + chain=data.ca_chain, # type: ignore[arg-type] + certificate=data.cert, # type: ignore[arg-type] + relation_id=relation.id, + ) diff --git a/src/utils.py b/src/utils.py index 74e52d5e..3e51ecca 100644 --- a/src/utils.py +++ b/src/utils.py @@ -79,10 +79,10 @@ def validate_database_resource(func: Callable) -> Callable: @wraps(func) def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: event, *_ = args - logger.info(f"Handling event: {event}") + logger.debug(f"Handling event: {event}") if not charm.database_requirer.is_resource_created(): - logger.info(f"Database has not been created yet, defer event {event}") + logger.debug(f"Database has not been created yet, defer event {event}") event.defer() charm.unit.status = WaitingStatus("Waiting for database creation") diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 3442ad73..749fe97b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -6,16 +6,42 @@ import pytest from charm import GLAuthCharm -from constants import DATABASE_INTEGRATION_NAME, WORKLOAD_CONTAINER +from charms.glauth_k8s.v0.ldap import LdapProviderData +from constants import ( + CERTIFICATES_TRANSFER_INTEGRATION_NAME, + DATABASE_INTEGRATION_NAME, + WORKLOAD_CONTAINER, +) from ops.charm import CharmBase from ops.testing import Harness from pytest_mock import MockerFixture DB_APP = "postgresql-k8s" +DB_DATABASE = "glauth" DB_USERNAME = "relation_id" DB_PASSWORD = "password" DB_ENDPOINTS = "postgresql-k8s-primary.namespace.svc.cluster.local:5432" +LDAP_CLIENT_APP = "ldap-client" +LDAP_PROVIDER_DATA = LdapProviderData( + url="ldap://ldap.glauth.com", + base_dn="dc=glauth,dc=com", + bind_dn="cn=user,ou=group,dc=glauth,dc=com", + bind_password_secret="password", + auth_method="simple", + starttls=True, +) + +LDAP_AUXILIARY_APP = "glauth-utils" +LDAP_AUXILIARY_RELATION_DATA = { + "database": DB_DATABASE, + "endpoint": DB_ENDPOINTS, + "username": DB_USERNAME, + "password": DB_PASSWORD, +} + +CERTIFICATES_TRANSFER_CLIENT_APP = "sssd" + @pytest.fixture(autouse=True) def k8s_client(mocker: MockerFixture) -> MagicMock: @@ -61,13 +87,6 @@ def mocked_statefulset(mocker: MockerFixture, harness: Harness) -> MagicMock: return mocked -@pytest.fixture -def database_relation(harness: Harness) -> int: - relation_id = harness.add_relation(DATABASE_INTEGRATION_NAME, DB_APP) - harness.add_relation_unit(relation_id, "postgresql-k8s/0") - return relation_id - - @pytest.fixture def mocked_restart_glauth_service(mocker: MockerFixture, harness: Harness) -> Callable: def mock_restart_glauth_service(charm: CharmBase) -> None: @@ -76,6 +95,36 @@ def mock_restart_glauth_service(charm: CharmBase) -> None: return mocker.patch("charm.GLAuthCharm._restart_glauth_service", mock_restart_glauth_service) +@pytest.fixture +def mocked_ldap_integration(mocker: MockerFixture, harness: Harness) -> MagicMock: + mocked = mocker.patch("charm.LdapIntegration", autospec=True) + mocked.provider_data = LDAP_PROVIDER_DATA + harness.charm._ldap_integration = mocked + return mocked + + +@pytest.fixture +def mocked_certificates_integration(mocker: MockerFixture, harness: Harness) -> MagicMock: + mocked = mocker.patch("charm.CertificatesIntegration", autospec=True) + mocked.cert_handler = harness.charm._certs_integration.cert_handler + harness.charm._certs_integration = mocked + return mocked + + +@pytest.fixture +def mocked_certificates_transfer_integration(mocker: MockerFixture, harness: Harness) -> MagicMock: + mocked = mocker.patch("charm.CertificatesTransferIntegration", autospec=True) + harness.charm._certs_transfer_integration = mocked + return mocked + + +@pytest.fixture +def database_relation(harness: Harness) -> int: + relation_id = harness.add_relation(DATABASE_INTEGRATION_NAME, DB_APP) + harness.add_relation_unit(relation_id, f"{DB_APP}/0") + return relation_id + + @pytest.fixture def database_resource( harness: Harness, @@ -94,3 +143,48 @@ def database_resource( "username": DB_USERNAME, }, ) + + +@pytest.fixture +def ldap_relation(harness: Harness) -> int: + relation_id = harness.add_relation("ldap", LDAP_CLIENT_APP) + harness.add_relation_unit(relation_id, f"{LDAP_CLIENT_APP}/0") + return relation_id + + +@pytest.fixture +def ldap_relation_data(harness: Harness, ldap_relation: int) -> None: + harness.update_relation_data( + ldap_relation, + LDAP_CLIENT_APP, + { + "user": "user", + "group": "group", + }, + ) + + +@pytest.fixture +def ldap_auxiliary_relation(harness: Harness) -> int: + relation_id = harness.add_relation("glauth-auxiliary", LDAP_AUXILIARY_APP) + harness.add_relation_unit(relation_id, f"{LDAP_AUXILIARY_APP}/0") + return relation_id + + +@pytest.fixture +def ldap_auxiliary_relation_data(harness: Harness, ldap_auxiliary_relation: int) -> dict[str, str]: + harness.update_relation_data( + ldap_auxiliary_relation, + LDAP_AUXILIARY_APP, + LDAP_AUXILIARY_RELATION_DATA, + ) + return LDAP_AUXILIARY_RELATION_DATA + + +@pytest.fixture +def certificates_transfer_relation(harness: Harness) -> int: + relation_id = harness.add_relation( + CERTIFICATES_TRANSFER_INTEGRATION_NAME, CERTIFICATES_TRANSFER_CLIENT_APP + ) + harness.add_relation_unit(relation_id, f"{CERTIFICATES_TRANSFER_CLIENT_APP}/0") + return relation_id diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 25605807..78e8857a 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,10 +1,16 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest +from conftest import ( + LDAP_AUXILIARY_APP, + LDAP_CLIENT_APP, + LDAP_PROVIDER_DATA, +) from constants import WORKLOAD_CONTAINER, WORKLOAD_SERVICE +from exceptions import CertificatesError from kubernetes_resource import KubernetesResourceError from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus from ops.testing import Harness @@ -125,3 +131,102 @@ def test_on_config_changed_event( service = container.get_service(WORKLOAD_SERVICE) assert service.is_running() assert isinstance(harness.model.unit.status, ActiveStatus) + + +class TestLdapRequestedEvent: + def test_when_database_not_created( + self, harness: Harness, database_relation: int, ldap_relation_data: MagicMock + ) -> None: + assert isinstance(harness.model.unit.status, WaitingStatus) + + def test_when_requirer_data_not_ready( + self, + harness: Harness, + database_resource: MagicMock, + ldap_relation: int, + ) -> None: + assert not harness.get_relation_data(ldap_relation, LDAP_CLIENT_APP) + + def test_when_ldap_requested( + self, + harness: Harness, + database_resource: MagicMock, + mocked_ldap_integration: MagicMock, + ldap_relation: int, + ldap_relation_data: MagicMock, + ) -> None: + assert isinstance(harness.model.unit.status, ActiveStatus) + assert LDAP_PROVIDER_DATA.model_dump() == harness.get_relation_data( + ldap_relation, harness.model.app.name + ) + + +class TestLdapAuxiliaryRequestedEvent: + def test_when_database_not_created( + self, harness: Harness, database_relation: int, ldap_auxiliary_relation: int + ) -> None: + assert isinstance(harness.model.unit.status, WaitingStatus) + + def test_on_ldap_auxiliary_requested( + self, + harness: Harness, + database_resource: MagicMock, + ldap_auxiliary_relation: int, + ldap_auxiliary_relation_data: MagicMock, + ) -> None: + assert isinstance(harness.model.unit.status, ActiveStatus) + assert ldap_auxiliary_relation_data == harness.get_relation_data( + ldap_auxiliary_relation, LDAP_AUXILIARY_APP + ) + + +class TestCertChangedEvent: + def test_when_container_not_connected(self, harness: Harness) -> None: + harness.set_can_connect(WORKLOAD_CONTAINER, False) + harness.charm._certs_integration.cert_handler.on.cert_changed.emit() + + assert isinstance(harness.model.unit.status, WaitingStatus) + + @patch( + "charm.CertificatesIntegration.update_certificates", + side_effect=CertificatesError("Failed to update certificates."), + ) + def test_when_update_certificates_failed( + self, + mocked_update_certificates: MagicMock, + harness: Harness, + mocked_certificates_integration: MagicMock, + mocked_certificates_transfer_integration: MagicMock, + ) -> None: + harness.charm._certs_integration.cert_handler.on.cert_changed.emit() + + mocked_certificates_integration.update_certificates.assert_called_once() + mocked_certificates_transfer_integration.transfer_certificates.assert_not_called() + + def test_on_cert_changed( + self, + harness: Harness, + mocked_certificates_integration: MagicMock, + mocked_certificates_transfer_integration: MagicMock, + ) -> None: + harness.charm._certs_integration.cert_handler.on.cert_changed.emit() + + mocked_certificates_integration.update_certificates.assert_called_once() + mocked_certificates_transfer_integration.transfer_certificates.assert_called_once() + + +class TestCertificatesTransferEvent: + def test_when_certificate_data_not_ready( + self, + mocked_certificates_transfer_integration: MagicMock, + certificates_transfer_relation: int, + ) -> None: + mocked_certificates_transfer_integration.transfer_certificates.assert_not_called() + + def test_certificates_transfer_relation_joined( + self, + mocked_certificates_integration: MagicMock, + mocked_certificates_transfer_integration: MagicMock, + certificates_transfer_relation: int, + ) -> None: + mocked_certificates_transfer_integration.transfer_certificates.assert_called_once() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index bc863f56..ab7d5150 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,13 +1,15 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -from unittest.mock import MagicMock, sentinel +from io import StringIO +from unittest.mock import MagicMock, PropertyMock, patch, sentinel from constants import DATABASE_INTEGRATION_NAME, WORKLOAD_CONTAINER from ops.charm import CharmBase, HookEvent from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness from utils import ( + after_config_updated, block_on_missing, leader_unit, validate_container_connectivity, @@ -104,3 +106,20 @@ def wrapped(charm: CharmBase, event: HookEvent) -> sentinel: assert wrapped(harness.charm, mocked_hook_event) is None assert isinstance(harness.model.unit.status, WaitingStatus) + + @patch("ops.model.Container.pull", return_value=StringIO("abc")) + @patch("charm.ConfigFile.content", new_callable=PropertyMock, return_value="abc") + def test_after_config_updated( + self, + mocked_container_pull: MagicMock, + mocked_configfile_content: MagicMock, + harness: Harness, + mocked_hook_event: MagicMock, + ) -> None: + @after_config_updated + def wrapped(charm: CharmBase, event: HookEvent) -> sentinel: + charm.unit.status = ActiveStatus() + return sentinel + + assert wrapped(harness.charm, mocked_hook_event) is sentinel + assert isinstance(harness.model.unit.status, ActiveStatus) diff --git a/unit-requirements.txt b/unit-requirements.txt index 1d2112ec..eb3d2f86 100644 --- a/unit-requirements.txt +++ b/unit-requirements.txt @@ -1,4 +1,6 @@ +-r requirements.txt +coverage[toml] +cryptography +jsonschema pytest pytest-mock -coverage[toml] --r requirements.txt