From 591cce6aadc9152840bff17dd447b264036048e6 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Wed, 22 Nov 2023 20:08:34 +0000 Subject: [PATCH] chore: update charm libraries --- .../v0/certificate_transfer.py | 4 +- .../v0/cloud_config_requirer.py | 64 +++-- .../grafana_k8s/v0/grafana_dashboard.py | 3 +- lib/charms/loki_k8s/v0/loki_push_api.py | 14 +- .../observability_libs/v0/cert_handler.py | 63 +++-- .../prometheus_k8s/v0/prometheus_scrape.py | 20 +- .../v1/prometheus_remote_write.py | 12 +- .../v2/tls_certificates.py | 255 ++++++++++++++---- 8 files changed, 313 insertions(+), 122 deletions(-) diff --git a/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py index edad1d9..44ddfda 100644 --- a/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py +++ b/lib/charms/certificate_transfer_interface/v0/certificate_transfer.py @@ -97,7 +97,7 @@ def _on_certificate_removed(self, event: CertificateRemovedEvent): import logging from typing import List -from jsonschema import exceptions, validate # type: ignore[import] +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 @@ -109,7 +109,7 @@ def _on_certificate_removed(self, event: CertificateRemovedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 4 +LIBPATCH = 5 PYDEPS = ["jsonschema"] diff --git a/lib/charms/grafana_cloud_integrator/v0/cloud_config_requirer.py b/lib/charms/grafana_cloud_integrator/v0/cloud_config_requirer.py index d28d2eb..6e01c26 100644 --- a/lib/charms/grafana_cloud_integrator/v0/cloud_config_requirer.py +++ b/lib/charms/grafana_cloud_integrator/v0/cloud_config_requirer.py @@ -6,7 +6,7 @@ LIBID = "e6f580481c1b4388aa4d2cdf412a47fa" LIBAPI = 0 -LIBPATCH = 3 +LIBPATCH = 4 DEFAULT_RELATION_NAME = "grafana-cloud-config" @@ -45,7 +45,7 @@ def __init__(self, charm, relation_name = DEFAULT_RELATION_NAME): super().__init__(charm, relation_name) self._charm = charm self._relation_name = relation_name - + for event in self._change_events: self.framework.observe(event, self._on_relation_changed) @@ -56,14 +56,6 @@ def _on_relation_changed(self, event): if not self._charm.unit.is_leader(): return - if not all( - self._is_not_empty(x) - for x in [ - event.relation.data[event.app].get("username", ""), - event.relation.data[event.app].get("password", ""), - ]): - return - self.on.cloud_config_available.emit() # pyright: ignore def _on_relation_broken(self, event): @@ -96,29 +88,55 @@ def _events(self): @property def credentials(self): - return Credentials( - self._data.get("username", ""), - self._data.get("password", "") - ) + """Return the credentials, if any; otherwise, return None.""" + if not all( + self._is_not_empty(x) + for x in [ + self._data.get("username", ""), + self._data.get("password", ""), + ]): + return Credentials( + self._data.get("username", ""), + self._data.get("password", "") + ) + return None @property def loki_ready(self): - return ( - self._is_not_empty(self.credentials.username) - and self._is_not_empty(self.credentials.password) - and self._is_not_empty(self.loki_url)) + return self._is_not_empty(self.loki_url) + + @property + def loki_endpoint(self) -> dict: + """Return the loki endpoint dict.""" + if not self.loki_ready: + return {} + + endpoint = {} + endpoint["url"] = self.loki_url + if self.credentials: + endpoint["basic_auth"] = {"username": self.credentials.username, "password": self.credentials.password} + return endpoint @property def prometheus_ready(self): - return ( - self._is_not_empty(self.credentials.username) - and self._is_not_empty(self.credentials.password) - and self._is_not_empty(self.prometheus_url)) + return self._is_not_empty(self.prometheus_url) + + @property + def prometheus_endpoint(self) -> dict: + """Return the prometheus endpoint dict.""" + if not self.prometheus_ready: + return {} + + endpoint = {} + endpoint["url"] = self.prometheus_url + if self.credentials: + endpoint["basic_auth"] = {"username": self.credentials.username, "password": self.credentials.password} + return endpoint @property def loki_url(self): return self._data.get("loki_url", "") - + @property def prometheus_url(self): return self._data.get("prometheus_url", "") diff --git a/lib/charms/grafana_k8s/v0/grafana_dashboard.py b/lib/charms/grafana_k8s/v0/grafana_dashboard.py index de8715b..1f1bc4f 100644 --- a/lib/charms/grafana_k8s/v0/grafana_dashboard.py +++ b/lib/charms/grafana_k8s/v0/grafana_dashboard.py @@ -219,7 +219,7 @@ def __init__(self, *args): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 34 +LIBPATCH = 35 logger = logging.getLogger(__name__) @@ -1195,6 +1195,7 @@ def _on_grafana_dashboard_relation_created(self, event: RelationCreatedEvent) -> `grafana_dashboaard` relationship is joined """ if self._charm.unit.is_leader(): + self._update_all_dashboards_from_dir() self._upset_dashboards_on_relation(event.relation) def _on_grafana_dashboard_relation_changed(self, event: RelationChangedEvent) -> None: diff --git a/lib/charms/loki_k8s/v0/loki_push_api.py b/lib/charms/loki_k8s/v0/loki_push_api.py index 1547a3b..9f9372d 100644 --- a/lib/charms/loki_k8s/v0/loki_push_api.py +++ b/lib/charms/loki_k8s/v0/loki_push_api.py @@ -480,7 +480,7 @@ def _alert_rules_error(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 21 +LIBPATCH = 22 logger = logging.getLogger(__name__) @@ -1773,6 +1773,8 @@ def __init__( recursive: bool = False, container_name: str = "", promtail_resource_name: Optional[str] = None, + *, # TODO: In v1, move the star up so everything after 'charm' is a kwarg + insecure_skip_verify: bool = False, ): super().__init__(charm, relation_name, alert_rules_path, recursive) self._charm = charm @@ -1792,6 +1794,7 @@ def __init__( self._is_syslog = enable_syslog self.topology = JujuTopology.from_charm(charm) self._promtail_resource_name = promtail_resource_name or "promtail-bin" + self.insecure_skip_verify = insecure_skip_verify # architecture used for promtail binary arch = platform.processor() @@ -2153,8 +2156,15 @@ def _current_config(self) -> dict: @property def _promtail_config(self) -> dict: - """Generates the config file for Promtail.""" + """Generates the config file for Promtail. + + Reference: https://grafana.com/docs/loki/latest/send-data/promtail/configuration + """ config = {"clients": self._clients_list()} + if self.insecure_skip_verify: + for client in config["clients"]: + client["tls_config"] = {"insecure_skip_verify": True} + config.update(self._server_config()) config.update(self._positions()) config.update(self._scrape_configs()) diff --git a/lib/charms/observability_libs/v0/cert_handler.py b/lib/charms/observability_libs/v0/cert_handler.py index 15087be..db14e00 100644 --- a/lib/charms/observability_libs/v0/cert_handler.py +++ b/lib/charms/observability_libs/v0/cert_handler.py @@ -33,8 +33,10 @@ 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: @@ -62,7 +64,16 @@ LIBID = "b5cd5cd580f3428fa5f59a8876dcbe6a" LIBAPI = 0 -LIBPATCH = 7 +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): @@ -88,7 +99,7 @@ def __init__( peer_relation_name: str, certificates_relation_name: str = "certificates", cert_subject: Optional[str] = None, - extra_sans_dns: Optional[List[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. @@ -111,7 +122,9 @@ def __init__( 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 - self.sans_dns = list(set(filter(None, (extra_sans_dns or [socket.getfqdn()])))) + 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 @@ -168,33 +181,40 @@ def _peer_relation(self) -> Optional[Relation]: return self.charm.model.get_relation(self.peer_relation_name, None) def _on_peer_relation_created(self, _): - """Generate the private key and store it in a peer relation.""" - # We're in "relation-created", so the relation should be there - - # Just in case we already have a private key, do not overwrite it. - # Not sure how this could happen. - # 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() + """Generate the CSR if the certificates relation is ready.""" + self._generate_privkey() - # Generate CSR here, in case peer events fired after tls-certificate relation events + # 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 and request the certificate creation.""" + """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. @@ -224,11 +244,17 @@ def _generate_csr( # 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 - assert private_key is not None # for type checker + 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: @@ -237,7 +263,12 @@ def _generate_csr( new_certificate_signing_request=csr, ) else: - logger.info("Creating CSR for %s with DNS %s", self.cert_subject, self.sans_dns) + 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 diff --git a/lib/charms/prometheus_k8s/v0/prometheus_scrape.py b/lib/charms/prometheus_k8s/v0/prometheus_scrape.py index 4ea89a1..e4297aa 100644 --- a/lib/charms/prometheus_k8s/v0/prometheus_scrape.py +++ b/lib/charms/prometheus_k8s/v0/prometheus_scrape.py @@ -362,7 +362,7 @@ def _on_scrape_targets_changed(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 41 +LIBPATCH = 42 PYDEPS = ["cosl"] @@ -604,12 +604,12 @@ def render_alertmanager_static_configs(alertmanagers: List[str]): return { "alertmanagers": [ { + # For https we still do not render a `tls_config` section because + # certs are expected to be made available by the charm via the + # `update-ca-certificates` mechanism. "scheme": scheme, "path_prefix": path_prefix, "static_configs": [{"targets": netlocs}], - # FIXME figure out how to get alertmanager's ca_file into here - # Without this, prom errors: "x509: certificate signed by unknown authority" - "tls_config": {"insecure_skip_verify": True}, } for (scheme, path_prefix), netlocs in paths.items() ] @@ -1176,16 +1176,8 @@ def _static_scrape_config(self, relation) -> list: scrape_configs, hosts, topology ) - # If scheme is https but no ca section present, then auto add "insecure_skip_verify", - # otherwise scraping errors out with "x509: certificate signed by unknown authority". - # https://prometheus.io/docs/prometheus/latest/configuration/configuration/#tls_config - for scrape_config in scrape_configs: - tls_config = scrape_config.get("tls_config", {}) - ca_present = "ca" in tls_config or "ca_file" in tls_config - if scrape_config.get("scheme") == "https" and not ca_present: - tls_config["insecure_skip_verify"] = True - scrape_config["tls_config"] = tls_config - + # For https scrape targets we still do not render a `tls_config` section because certs + # are expected to be made available by the charm via the `update-ca-certificates` mechanism. return scrape_configs def _relation_hosts(self, relation: Relation) -> Dict[str, Tuple[str, str]]: diff --git a/lib/charms/prometheus_k8s/v1/prometheus_remote_write.py b/lib/charms/prometheus_k8s/v1/prometheus_remote_write.py index de1be49..049c8fa 100644 --- a/lib/charms/prometheus_k8s/v1/prometheus_remote_write.py +++ b/lib/charms/prometheus_k8s/v1/prometheus_remote_write.py @@ -46,7 +46,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 3 PYDEPS = ["cosl"] @@ -211,7 +211,7 @@ def _validate_relation_by_interface_and_direction( actual_relation_interface = relation.interface_name if actual_relation_interface != expected_relation_interface: raise RelationInterfaceMismatchError( - relation_name, expected_relation_interface, actual_relation_interface + relation_name, expected_relation_interface, actual_relation_interface or "None" ) if expected_relation_role == RelationRole.provides: @@ -394,7 +394,7 @@ def __init__(self, *args): ``` """ - on = PrometheusRemoteWriteConsumerEvents() + on = PrometheusRemoteWriteConsumerEvents() # pyright: ignore def __init__( self, @@ -458,7 +458,7 @@ def _on_relation_broken(self, event: RelationBrokenEvent) -> None: self.on.endpoints_changed.emit(relation_id=event.relation.id) def _handle_endpoints_changed(self, event: RelationEvent) -> None: - if self._charm.unit.is_leader(): + if self._charm.unit.is_leader() and event.app is not None: ev = json.loads(event.relation.data[event.app].get("event", "{}")) if ev: @@ -591,7 +591,7 @@ def __init__(self, *args): name to differentiate between "incoming" and "outgoing" remote write interactions is necessary. """ - on = PrometheusRemoteWriteProviderEvents() + on = PrometheusRemoteWriteProviderEvents() # pyright: ignore def __init__( self, @@ -838,7 +838,7 @@ def _inject_alert_expr_labels(self, rules: Dict[str, Any]) -> Dict[str, Any]: # Inject topology and put it back in the list rule["expr"] = self._tool.inject_label_matchers( re.sub(r"%%juju_topology%%,?", "", rule["expr"]), - topology.label_matcher_dict, + topology.alert_expression_dict, ) except KeyError: # Some required JujuTopology key is missing. Just move on. diff --git a/lib/charms/tls_certificates_interface/v2/tls_certificates.py b/lib/charms/tls_certificates_interface/v2/tls_certificates.py index fc2f450..99741f5 100644 --- a/lib/charms/tls_certificates_interface/v2/tls_certificates.py +++ b/lib/charms/tls_certificates_interface/v2/tls_certificates.py @@ -276,7 +276,6 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven import json import logging import uuid -from collections import defaultdict from contextlib import suppress from datetime import datetime, timedelta from ipaddress import IPv4Address @@ -288,7 +287,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven 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] +from jsonschema import exceptions, validate # type: ignore[import-untyped] from ops.charm import ( CharmBase, CharmEvents, @@ -299,7 +298,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven ) from ops.framework import EventBase, EventSource, Handle, Object from ops.jujuversion import JujuVersion -from ops.model import SecretNotFoundError +from ops.model import ModelError, Relation, RelationDataContent, SecretNotFoundError # The unique Charmhub library identifier, never change it LIBID = "afd8c2bccf834997afce12c2706d2ede" @@ -309,7 +308,7 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 10 +LIBPATCH = 20 PYDEPS = ["cryptography", "jsonschema"] @@ -336,7 +335,10 @@ def _on_all_certificates_invalidated(self, event: AllCertificatesInvalidatedEven "type": "array", "items": { "type": "object", - "properties": {"certificate_signing_request": {"type": "string"}}, + "properties": { + "certificate_signing_request": {"type": "string"}, + "ca": {"type": "boolean"}, + }, "required": ["certificate_signing_request"], }, } @@ -537,22 +539,31 @@ def restore(self, snapshot: dict): class CertificateCreationRequestEvent(EventBase): """Charm Event triggered when a TLS certificate is required.""" - def __init__(self, handle: Handle, certificate_signing_request: str, relation_id: int): + 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): @@ -589,23 +600,26 @@ def restore(self, snapshot: dict): self.chain = snapshot["chain"] -def _load_relation_data(raw_relation_data: dict) -> dict: +def _load_relation_data(relation_data_content: RelationDataContent) -> dict: """Loads relation data from the relation data bag. Json loads all data. Args: - raw_relation_data: Relation data from the databag + relation_data_content: Relation data from the databag Returns: dict: Relation data in dict format. """ certificate_data = dict() - for key in raw_relation_data: - try: - certificate_data[key] = json.loads(raw_relation_data[key]) - except (json.decoder.JSONDecodeError, TypeError): - certificate_data[key] = raw_relation_data[key] + 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 @@ -641,6 +655,17 @@ def generate_ca( 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) @@ -658,6 +683,7 @@ def generate_ca( ), critical=False, ) + .add_extension(key_usage, critical=True) .add_extension( x509.BasicConstraints(ca=True, path_length=None), critical=True, @@ -674,6 +700,7 @@ def generate_certificate( 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. @@ -684,13 +711,15 @@ def generate_certificate( 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 - issuer = x509.load_pem_x509_certificate(ca).issuer + 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 = ( @@ -701,6 +730,19 @@ def generate_certificate( .serial_number(x509.random_serial_number()) .not_valid_before(datetime.utcnow()) .not_valid_after(datetime.utcnow() + timedelta(days=validity)) + .add_extension( + x509.AuthorityKeyIdentifier( + key_identifier=ca_pem.extensions.get_extension_for_class( + x509.SubjectKeyIdentifier + ).value.key_identifier, + authority_cert_issuer=None, + authority_cert_serial_number=None, + ), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(csr_object.public_key()), critical=False + ) ) extensions_list = csr_object.extensions @@ -731,6 +773,30 @@ def generate_certificate( extension.value, critical=extension.critical, ) + + if is_ca: + certificate_builder = certificate_builder.add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + certificate_builder = certificate_builder.add_extension( + 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, + ), + critical=True, + ) + else: + certificate_builder = certificate_builder.add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=False + ) + certificate_builder._version = x509.Version.v3 cert = certificate_builder.sign(private_key, hashes.SHA256()) # type: ignore[arg-type] return cert.public_bytes(serialization.Encoding.PEM) @@ -828,7 +894,7 @@ def generate_csr( 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 if critical additional extension objects. + additional_critical_extensions (list): List of critical additional extension objects. Object must be a x509 ExtensionType. Returns: @@ -898,6 +964,22 @@ def __init__(self, charm: CharmBase, relationship_name: str): 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, @@ -932,7 +1014,7 @@ def _add_certificate( "ca": ca, "chain": chain, } - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + 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: @@ -965,7 +1047,7 @@ def _remove_certificate( raise RuntimeError( f"Relation {self.relationship_name} with relation id {relation_id} does not exist" ) - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + 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: @@ -1000,7 +1082,7 @@ def revoke_all_certificates(self) -> None: 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 = _load_relation_data(relation.data[self.charm.app]) + 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 @@ -1062,7 +1144,7 @@ def remove_certificate(self, certificate: str) -> None: def get_issued_certificates( self, relation_id: Optional[int] = None - ) -> Dict[str, Dict[str, str]]: + ) -> Dict[str, List[Dict[str, str]]]: """Returns a dictionary of issued certificates. It returns certificates from all relations if relation_id is not specified. @@ -1071,7 +1153,7 @@ def get_issued_certificates( Returns: dict: Certificates per application name. """ - certificates: Dict[str, Dict[str, str]] = defaultdict(dict) + certificates: Dict[str, List[Dict[str, str]]] = {} relations = ( [ relation @@ -1082,13 +1164,19 @@ def get_issued_certificates( else self.model.relations.get(self.relationship_name, []) ) for relation in relations: - provider_relation_data = _load_relation_data(relation.data[self.charm.app]) + 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].update( # type: ignore[union-attr] - {certificate["certificate_signing_request"]: certificate["certificate"]} + 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: @@ -1106,9 +1194,13 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None: Returns: None """ - assert event.unit is not 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 = _load_relation_data(event.relation.data[self.charm.app]) + 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 @@ -1118,15 +1210,19 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None: certificate_creation_request["certificate_signing_request"] for certificate_creation_request in provider_certificates ] - requirer_unit_csrs = [ - certificate_creation_request["certificate_signing_request"] + 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_signing_request in requirer_unit_csrs: - if certificate_signing_request not in provider_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_signing_request, + 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) @@ -1147,7 +1243,7 @@ def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None ) if not certificates_relation: raise RuntimeError(f"Relation {self.relationship_name} does not exist") - provider_relation_data = _load_relation_data(certificates_relation.data[self.charm.app]) + 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]) @@ -1164,28 +1260,47 @@ def _revoke_certificates_for_which_no_csr_exists(self, relation_id: int) -> None ) self.remove_certificate(certificate=certificate["certificate"]) - def get_requirer_csrs_with_no_certs( - self, + def get_outstanding_certificate_requests( + self, relation_id: Optional[int] = None ) -> List[Dict[str, Union[int, str, List[Dict[str, str]]]]]: - """Filters the requirer's units csrs. + """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 + } + ] + } + ] - Keeps the ones for which no certificate was provided. + 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()) + 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 self.certificate_issued_for_csr( + 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, ): - unit_csr_mapping["unit_csrs"].remove(csr) # type: ignore[union-attr, arg-type] - if len(unit_csr_mapping["unit_csrs"]) == 0: # type: ignore[arg-type] - all_unit_csr_mappings.remove(unit_csr_mapping) - return all_unit_csr_mappings + 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 @@ -1226,20 +1341,24 @@ def get_requirer_csrs( ) return unit_csr_mappings - def certificate_issued_for_csr(self, app_name: str, csr: str) -> bool: + 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()[app_name] - for request, cert in issued_certificates_per_csr.items(): - if request == csr: - return csr_matches_certificate(csr, cert) + 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 @@ -1278,8 +1397,17 @@ def __init__( self.framework.observe(charm.on.update_status, self._on_update_status) @property - def _requirer_csrs(self) -> List[Dict[str, str]]: - """Returns list of requirer's CSRs from relation data.""" + 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") @@ -1302,11 +1430,12 @@ def _provider_certificates(self) -> List[Dict[str, str]]: return [] return provider_relation_data.get("certificates", []) - def _add_requirer_csr(self, csr: str) -> None: + 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 @@ -1317,7 +1446,10 @@ def _add_requirer_csr(self, csr: str) -> None: f"Relation {self.relationship_name} does not exist - " f"The certificate request can't be completed" ) - new_csr_dict = {"certificate_signing_request": csr} + 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 @@ -1341,18 +1473,22 @@ def _remove_requirer_csr(self, csr: str) -> None: f"The certificate request can't be completed" ) requirer_csrs = copy.deepcopy(self._requirer_csrs) - csr_dict = {"certificate_signing_request": csr} - if csr_dict not in requirer_csrs: - logger.info("CSR not in relation data - Doing nothing") + if not requirer_csrs: + logger.info("No CSRs in relation data - Doing nothing") return - requirer_csrs.remove(csr_dict) + 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) -> None: + 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 @@ -1363,7 +1499,7 @@ def request_certificate_creation(self, certificate_signing_request: bytes) -> No 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()) + 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: @@ -1642,7 +1778,10 @@ def csr_matches_certificate(csr: str, cert: str) -> bool: format=serialization.PublicFormat.SubjectPublicKeyInfo, ): return False - if csr_object.subject != cert_object.subject: + 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.")