From 204fb83a2783afe29e5795915d6edea7b34e6d18 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Thu, 7 Aug 2025 16:07:22 +0300 Subject: [PATCH] Add SAML2 support This change adds SAML2 support through the use of a new keystone SAML integrator charm (keystone-saml-k8s). Needed changes have also been made in the keystone charm to make use of the new relation. A new option has also been added to keystone-k8s through which a secret can be specified which should contain the x509 certificate an the corresponding key from which it was derived, used to generate the keystone SP metadata file. Change-Id: Id9b6ab2a51891ac378a2cb406dbe3a456bc24fc4 Signed-off-by: Gabriel Adrian Samfira --- .gitignore | 1 + .../horizon_k8s/v0/trusted_dashboard.py | 2 +- charms/keystone-k8s/.sunbeam-build.yaml | 1 + charms/keystone-k8s/charmcraft.yaml | 25 ++ charms/keystone-k8s/src/charm.py | 363 +++++++++++++++- .../src/templates/apache2-oidc-params | 6 +- .../src/templates/apache2-saml-params | 27 ++ .../src/templates/wsgi-keystone.conf.j2 | 4 + charms/keystone-k8s/src/utils/certs.py | 18 + charms/keystone-k8s/src/utils/manager.py | 67 ++- .../tests/unit/test_keystone_charm.py | 57 +++ charms/keystone-saml-k8s/.sunbeam-build.yaml | 3 + charms/keystone-saml-k8s/LICENSE | 202 +++++++++ charms/keystone-saml-k8s/README.md | 19 + charms/keystone-saml-k8s/charmcraft.yaml | 73 ++++ .../keystone_saml_k8s/v1/keystone_saml.py | 393 ++++++++++++++++++ charms/keystone-saml-k8s/pyproject.toml | 60 +++ charms/keystone-saml-k8s/rebuild | 3 + charms/keystone-saml-k8s/requirements.txt | 1 + charms/keystone-saml-k8s/src/certs.py | 64 +++ charms/keystone-saml-k8s/src/charm.py | 174 ++++++++ .../keystone-saml-k8s/tests/unit/__init__.py | 15 + .../tests/unit/test_certs_utils.py | 72 ++++ .../tests/unit/test_keystone_saml_charm.py | 358 ++++++++++++++++ tests/identity/smoke.yaml.j2 | 18 + tests/identity/tests.yaml | 3 + zuul.d/jobs.yaml | 16 + zuul.d/project-templates.yaml | 4 + zuul.d/zuul.yaml | 1 + 29 files changed, 2018 insertions(+), 32 deletions(-) create mode 100644 charms/keystone-k8s/src/templates/apache2-saml-params create mode 100644 charms/keystone-saml-k8s/.sunbeam-build.yaml create mode 100644 charms/keystone-saml-k8s/LICENSE create mode 100644 charms/keystone-saml-k8s/README.md create mode 100644 charms/keystone-saml-k8s/charmcraft.yaml create mode 100644 charms/keystone-saml-k8s/lib/charms/keystone_saml_k8s/v1/keystone_saml.py create mode 100644 charms/keystone-saml-k8s/pyproject.toml create mode 100644 charms/keystone-saml-k8s/rebuild create mode 100644 charms/keystone-saml-k8s/requirements.txt create mode 100644 charms/keystone-saml-k8s/src/certs.py create mode 100755 charms/keystone-saml-k8s/src/charm.py create mode 100644 charms/keystone-saml-k8s/tests/unit/__init__.py create mode 100644 charms/keystone-saml-k8s/tests/unit/test_certs_utils.py create mode 100644 charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py diff --git a/.gitignore b/.gitignore index ea498748..c9d2f526 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,6 @@ charms/*/.stestr.conf charms/*/lib/ charms/*/src/templates/parts/ !charms/horizon-k8s/lib/ +!charms/keystone-saml-k8s/lib/ # artefacts from functional tests tempest.log diff --git a/charms/horizon-k8s/lib/charms/horizon_k8s/v0/trusted_dashboard.py b/charms/horizon-k8s/lib/charms/horizon_k8s/v0/trusted_dashboard.py index 49670bbd..85dd492e 100644 --- a/charms/horizon-k8s/lib/charms/horizon_k8s/v0/trusted_dashboard.py +++ b/charms/horizon-k8s/lib/charms/horizon_k8s/v0/trusted_dashboard.py @@ -296,7 +296,7 @@ class TrustedDashboardRequirer(Object): } if not requirer_data["dashboard-url"]: - logger.info("No trustwed dashboard found in relation data.") + logger.info("No trusted dashboard found in relation data.") return _validate_data(requirer_data, TRUSTED_DASHBOARD_PROVIDER_JSON_SCHEMA) diff --git a/charms/keystone-k8s/.sunbeam-build.yaml b/charms/keystone-k8s/.sunbeam-build.yaml index 296931fa..1cc7fef9 100644 --- a/charms/keystone-k8s/.sunbeam-build.yaml +++ b/charms/keystone-k8s/.sunbeam-build.yaml @@ -10,6 +10,7 @@ external-libraries: - charms.kratos_external_idp_integrator.v0.kratos_external_provider internal-libraries: - charms.horizon_k8s.v0.trusted_dashboard + - charms.keystone_saml_k8s.v1.keystone_saml templates: - parts/section-database - parts/database-connection diff --git a/charms/keystone-k8s/charmcraft.yaml b/charms/keystone-k8s/charmcraft.yaml index 96a0899a..33b3179e 100644 --- a/charms/keystone-k8s/charmcraft.yaml +++ b/charms/keystone-k8s/charmcraft.yaml @@ -51,6 +51,28 @@ config: type: boolean default: false description: Enable notifications to send to telemetry. + saml-x509-keypair: + type: secret + default: !!null "" + description: | + The SAML2 x509 certificates. This certificate is used by SAML2 for two purposes: + * Sign messages between the SP and the IDP + * Encrypt messages. This is rarely used as in the majority of cases, SAML2 traffic is + sent over https. + This certificate will be part of the SAML2 metadata. + The secret is expected to have two keys: + + { + "certificate": "contents of the certificate", + "key": "contents of the key" + } + + You can upload the secrets by running: + + juju add-secret saml-secret \ + certificate#file=/path/to/cert.pem \ + key#file=/path/to/corresponding/key + juju grant-secret saml-secret keystone actions: get-admin-password: @@ -171,6 +193,9 @@ requires: external-idp: interface: external_provider optional: true + keystone-saml: + interface: keystone_saml + optional: true provides: identity-service: diff --git a/charms/keystone-k8s/src/charm.py b/charms/keystone-k8s/src/charm.py index 4d690ea4..744bebf9 100755 --- a/charms/keystone-k8s/src/charm.py +++ b/charms/keystone-k8s/src/charm.py @@ -30,6 +30,7 @@ import binascii import json import logging import os +import re import tempfile from collections import ( defaultdict, @@ -78,6 +79,9 @@ from charms.hydra.v0.oauth import ( ClientConfig, OAuthRequirer, ) +from charms.keystone_saml_k8s.v1.keystone_saml import ( + KeystoneSAMLRequirer, +) from ops.charm import ( ActionEvent, RelationChangedEvent, @@ -116,6 +120,28 @@ OAUTH_GRANT_TYPES = [ "client_credentials", "refresh_token", ] +_MELLON_SP_TEMPLATE = """ + + + + + + %(sp_cert)s + + + + + + + %(sp_cert)s + + + + + + + +""" @sunbeam_tracing.trace_type @@ -154,9 +180,10 @@ class KeystoneConfigAdapter(sunbeam_contexts.ConfigContext): "service_tenant_id": self.charm.service_project_id, "admin_domain_name": self.charm.admin_domain_name, "admin_domain_id": self.charm.admin_domain_id, - "auth_methods": "external,password,token,oauth1,openid,mapped,application_credential", + "auth_methods": "external,password,token,oauth1,openid,saml2,mapped,application_credential", "default_domain_id": self.charm.default_domain_id, "public_port": self.charm.service_port, + "server_name": self.charm.server_name, "debug": config["debug"], "token_expiration": 3600, # 1 hour "allow_expired_window": 169200, # 2 days - 1 hour @@ -488,9 +515,8 @@ class OAuthRequiresHandler(_BaseIDPHandler): ctxt = { "oidc_crypto_passphrase": oidc_secret, "oidc_providers": provider_info, - "redirect_uri": self.oidc_redirect_uri, - "redirect_uri_path": urlparse(self.oidc_redirect_uri).path, - "public_url_path": urlparse(self.charm.public_endpoint).path, + "oidc_redirect_uri": self.oidc_redirect_uri, + "oidc_redirect_uri_path": urlparse(self.oidc_redirect_uri).path, } return ctxt @@ -501,6 +527,165 @@ class OAuthRequiresHandler(_BaseIDPHandler): return False +class KeystoneSAML2RequiresHandler(sunbeam_rhandlers.RelationHandler): + """Handler for keystone-saml relation.""" + + def setup_event_handler(self) -> ops.framework.Object: + """Configure event handlers for the keystone-saml relation.""" + saml = KeystoneSAMLRequirer( + self.charm, relation_name=self.relation_name + ) + + self.framework.observe( + saml.on.changed, + self._saml_relation_changed, + ) + + self.framework.observe( + self.charm.on.keystone_saml_relation_changed, + self._saml_relation_changed, + ) + return saml + + def _saml_relation_changed(self, event): + self.callback_f(event) + + def set_requirer_info(self, event): + """Set SAML2 requirer info.""" + providers = self.interface.get_providers() + if not providers: + return {} + + # Set provider info for all providers. + for provider in providers: + if not provider.get("name", None): + continue + relation_id = provider.pop("relation_id", None) + if not relation_id: + continue + sp_url = self._get_sp_url(provider) + acs_url = f"{sp_url}/postResponse" + logout_url = f"{sp_url}/logout" + metadata_url = f"{sp_url}/metadata" + self.interface.set_requirer_info( + { + "acs-url": acs_url, + "logout-url": logout_url, + "metadata-url": metadata_url, + }, + relation_id=relation_id, + ) + + def get_saml_providers(self): + """Get all SAML2 providers.""" + providers = self.interface.get_providers() + + data = [] + for provider in providers: + data.append( + { + "name": provider["name"], + "protocol": "saml2", + "description": provider["label"], + } + ) + if not data: + return {} + return {"federated-providers": data} + + def _get_sp_url(self, provider: Mapping[str, str]): + provider_name = provider["name"] + sp_url = ( + f"{self.charm.public_endpoint}/OS-FEDERATION/" + f"identity_providers/{provider_name}/protocols/" + "saml2/auth/mellon" + ) + return sp_url + + def _ensure_provider_metadata_files( + self, provider: Mapping[str, str], sp_k_c: Mapping[str, str] + ) -> Mapping[str, Mapping[str, str]]: + provider_name = provider["name"] + metadata = provider.get("metadata", "") + if not metadata: + logger.warning( + f"no metadata was received from remote " + f"charm for provider {provider_name}" + ) + return {} + sp_url = self._get_sp_url(provider) + urn = f"urn:saml2:{provider_name}" + sp_meta = f"saml_{provider_name}_keystone_metadata.xml" + idp_meta = f"saml_{provider_name}_idp_metadata.xml" + sp_file_path = f"{manager.SAML_PROVIDER_FOLDER}/{sp_meta}" + idp_file_path = f"{manager.SAML_PROVIDER_FOLDER}/{idp_meta}" + + cert = self.charm._get_certificate_body(sp_k_c["cert"]) + if not cert: + logger.warning("could not extract keystone SP certificate body") + return {} + + return { + "idp_metadata_file": { + "data": provider["metadata"], + "name": idp_meta, + "path": idp_file_path, + }, + "sp_metadata_file": { + "data": _MELLON_SP_TEMPLATE + % { + "entity_id": urn, + "sp_cert": cert, + "base_url": sp_url, + }, + "name": sp_meta, + "path": sp_file_path, + }, + } + + def context(self): + """Configuration context.""" + ctx = {} + providers = self.interface.get_providers() + files_to_write = {} + if not providers: + self.charm.keystone_manager.write_saml_metadata(files_to_write) + return {} + + sp_key_and_cert = self.charm.ensure_saml_cert_and_key() + if not sp_key_and_cert: + return {} + + ctx["saml2_sp_cert_file"] = manager.SAML_CERT_PATH + ctx["saml2_sp_key_file"] = manager.SAML_KEY_PATH + + ctx["saml_providers"] = [] + for provider in providers: + meta_files = self._ensure_provider_metadata_files( + provider, sp_key_and_cert + ) + if not meta_files: + return {} + + idp_meta = meta_files["idp_metadata_file"] + sp_meta = meta_files["sp_metadata_file"] + files_to_write[idp_meta["name"]] = idp_meta["data"] + files_to_write[sp_meta["name"]] = sp_meta["data"] + provider_info = { + "sp_metadata_file": sp_meta["path"], + "idp_metadata_file": idp_meta["path"], + "name": provider["name"], + "protocol": "saml2", + } + ctx["saml_providers"].append(provider_info) + self.charm.keystone_manager.write_saml_metadata(files_to_write) + return ctx + + def ready(self): + """Check if handler is ready.""" + return bool(self.context()) + + class ExternalIDPRequiresHandler(_BaseIDPHandler): """Handler for external-idp relation.""" @@ -637,8 +822,8 @@ class ExternalIDPRequiresHandler(_BaseIDPHandler): return { "oidc_providers": providers, "oidc_crypto_passphrase": oidc_secret, - "redirect_uri": self.oidc_redirect_uri, - "redirect_uri_path": urlparse(self.oidc_redirect_uri).path, + "oidc_redirect_uri": self.oidc_redirect_uri, + "oidc_redirect_uri_path": urlparse(self.oidc_redirect_uri).path, "public_url_path": urlparse(self.charm.public_endpoint).path, } @@ -740,6 +925,7 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): RECEIVE_CA_CERT_RELATION_NAME = "receive-ca-cert" TRUSTED_DASHBOARD = "trusted-dashboard" EXTERNAL_IDP = "external-idp" + KEYSTONE_SAML = "keystone-saml" def __init__(self, framework): super().__init__(framework) @@ -789,12 +975,20 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): self._list_ca_certs_action, ) + self.framework.observe( + self.on.secret_changed, + self.configure_charm, + ) + def merged_fid_contexts(self): """Create a merged context from oauth and external_idp.""" oidc_ctx = self.oauth.context() external_idp_ctx = self.external_idp.context() + saml_ctx = self.keystone_saml.context() + ctx = { "oidc_providers": [], + "saml_providers": [], } if oidc_ctx: ctx.update(oidc_ctx) @@ -803,6 +997,11 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): if providers: ctx["oidc_providers"].extend(providers) ctx.update(external_idp_ctx) + if saml_ctx: + ctx.update(saml_ctx) + + ctx["public_url_path"] = urlparse(self.public_endpoint).path + ctx["public_endpoint"] = self.public_endpoint return ctx def _handle_trusted_dashboard_changed(self, event: RelationChangedEvent): @@ -818,7 +1017,8 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): return oauth_providers = self.oauth.get_oidc_providers() external_providers = self.external_idp.get_oidc_providers() - if not oauth_providers and not external_providers: + saml_providers = self.keystone_saml.get_saml_providers() + if not any([oauth_providers, external_providers, saml_providers]): logger.debug("No OAuth relations found, skipping update") return data = {"federated-providers": []} @@ -830,13 +1030,18 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): data["federated-providers"].extend( external_providers.get("federated-providers", []) ) + if saml_providers: + data["federated-providers"].extend( + saml_providers.get("federated-providers", []) + ) if not data["federated-providers"]: return self.trusted_dashboard.set_requirer_info(data) - def _handle_oauth_info_changed(self, event: RelationChangedEvent): - """Handle OAuth info changed event.""" + def _handle_fid_providers_changed(self, event: RelationChangedEvent): + """Handle federated providers info changed event.""" self._handle_update_trusted_dashboard(event) + self.keystone_saml.set_requirer_info(event) self.configure_charm(event) def _retrieve_or_set_secret( @@ -920,16 +1125,27 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): return "\n".join(combined) - def get_ca_bundles_from_oauth_relations(self) -> List[str]: + def get_ca_bundles_from_fid_relations(self) -> List[str]: """Get CA bundles from oauth relations.""" ca_certs = [] - all_provider_info = self.oauth.get_all_provider_info() - for provider in all_provider_info: - provider_info = provider.get("info", None) - if not provider_info: + oauth_provider_info = self.oauth.get_all_provider_info() + external_idp_info = self.external_idp.get_all_provider_info() + saml_provider_info = self.keystone_saml.interface.get_providers() + for provider in oauth_provider_info: + ca_chain = provider.get("ca_chain", []) + if not ca_chain: continue - if provider_info.ca_chain: - ca_certs.extend(provider_info.ca_chain) + ca_certs.extend(ca_chain) + for provider in external_idp_info: + ca_chain = provider.get("ca_chain", []) + if not ca_chain: + continue + ca_certs.extend(ca_chain) + for provider in saml_provider_info: + ca_chain = provider.get("ca_chain", []) + if not ca_chain: + continue + ca_certs.extend(ca_chain) return ca_certs def sync_oidc_providers(self): @@ -945,6 +1161,89 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): self.keystone_manager.setup_oidc_metadata_folder() self.keystone_manager.write_oidc_metadata(files) + def _get_certificate_body(self, cert) -> str: + match = re.match( + pattern="(-----BEGIN CERTIFICATE-----)(.*?)(-----END CERTIFICATE-----)", + string=cert, + flags=re.DOTALL, + ) + if not match: + logger.warning( + "The supplied x509 certificate is not in PEM format." + ) + return "" + + groups = match.groups() + if len(groups) != 3: + logger.warning( + "The supplied x509 certificate seems to be a chain." + ) + return "" + + return groups[1].strip() + + def ensure_saml_cert_and_key(self) -> Mapping[str, str]: + """Ensure the SAM2 SP cert and key state match the config. + + If the saml-x509-keypair charm option is set, we need to ensure that + the secret holding the certificare and key are read, that the cert + matches the key and that we write it to disk. If the config is not set + we need to make sure we remove the cert and key. + """ + self.keystone_manager.setup_saml2_metadata_folder() + cert_secret_id = self.model.config.get("saml-x509-keypair") + saml_provider_info = self.keystone_saml.interface.get_providers() + if not cert_secret_id: + self.keystone_manager.remove_saml_key_and_cert() + if saml_provider_info: + raise sunbeam_guard.BlockedExceptionError( + "You have SAML providers configured but no x509 cert " + "and key. Please set saml-x509-keypair." + ) + return {} + try: + cert_secret = self.model.get_secret(id=cert_secret_id) + except SecretNotFoundError: + raise sunbeam_guard.BlockedExceptionError( + f"Could not find saml2 secret with id {cert_secret_id}" + ) + cert_data = cert_secret.get_content(refresh=True) + key = cert_data.get("key", None) + cert = cert_data.get("certificate", None) + + if key is None and cert is None: + self.keystone_manager.remove_saml_key_and_cert() + if saml_provider_info: + raise sunbeam_guard.BlockedExceptionError( + "You have SAML providers configured but no x509 cert " + "and key. Please set saml-x509-keypair." + ) + return {} + + key_and_cert = (cert, key) + if any(key_and_cert) and not all(key_and_cert): + raise sunbeam_guard.BlockedExceptionError( + "Both key and certificate keys are required for " + "saml-x509-keypair secret." + ) + if not certs.cert_and_key_match(cert.encode(), key.encode()): + raise sunbeam_guard.BlockedExceptionError( + "The supplied x509 certificate in the saml-x509-keypair secret " + "is not derived from the supplied key." + ) + + if not self._get_certificate_body(cert): + raise sunbeam_guard.BlockedExceptionError( + "The supplied x509 certificate in the saml-x509-keypair secret " + "must not be a chain and must be in PEM format." + ) + + self.keystone_manager.ensure_saml_cert_and_key_state(cert, key) + return { + "cert": cert, + "key": key, + } + def get_oidc_secret(self): """Get the OIDC secret from the peers relation.""" oidc_secret_id = self.peers.get_app_data("oidc-crypto-passphrase") @@ -1486,17 +1785,25 @@ export OS_AUTH_VERSION=3 self.oauth = OAuthRequiresHandler( self, OAUTH, - self._handle_oauth_info_changed, + self._handle_fid_providers_changed, ) handlers.append(self.oauth) if self.can_add_handler(self.EXTERNAL_IDP, handlers): self.external_idp = ExternalIDPRequiresHandler( self, self.EXTERNAL_IDP, - self._handle_oauth_info_changed, + self._handle_fid_providers_changed, ) handlers.append(self.external_idp) + if self.can_add_handler(self.KEYSTONE_SAML, handlers): + self.keystone_saml = KeystoneSAML2RequiresHandler( + self, + self.KEYSTONE_SAML, + self._handle_fid_providers_changed, + ) + handlers.append(self.keystone_saml) + return super().get_relation_handlers(handlers) @property @@ -2059,6 +2366,25 @@ export OS_AUTH_VERSION=3 return self.internal_endpoint + @property + def server_name(self): + """Server name directive for keystone virtual host. + + When behind a reverse proxy, apache2 may not be able to properly determine + the public facing protocol, hostname and port. The mod-auth-mellon plugin + unlike the mod-auth-openid plugin, does not implement handling for the X-Forwarded + header. It uses apache primitives to determine the URL it should serve, and those + values are taken directly from the virtual host. + + To get a working setup with mellon (probably shib as well), we need to "virtualize" + the server name in the virtual host. In the ServerName directive we need to include + both the scheme and the port (if non standard). + """ + if not self.ingress_public or not self.ingress_public.url: + return "" + parsed = urlparse(self.ingress_public.url) + return f"{parsed.scheme}://{parsed.netloc}" + @property def healthcheck_http_url(self) -> str: """Healthcheck HTTP URL for the service.""" @@ -2243,6 +2569,7 @@ export OS_AUTH_VERSION=3 pre_update_fernet_ready = self.unit_fernet_bootstrapped() self.update_fernet_keys_from_peer() self.keystone_manager.write_combined_ca() + self.keystone_saml.set_requirer_info(event) # If the wsgi service was running with no tokens it will be in a # wedged state so restart it. if self.unit_fernet_bootstrapped() and not pre_update_fernet_ready: diff --git a/charms/keystone-k8s/src/templates/apache2-oidc-params b/charms/keystone-k8s/src/templates/apache2-oidc-params index 3d5ac921..f2d7cbc7 100644 --- a/charms/keystone-k8s/src/templates/apache2-oidc-params +++ b/charms/keystone-k8s/src/templates/apache2-oidc-params @@ -8,9 +8,9 @@ OIDCSessionType client-cookie:persistent OIDCCryptoPassphrase {{ fid.oidc_crypto_passphrase }} OIDCMetadataDir /etc/apache2/oidc-metadata - OIDCRedirectURI {{ fid.redirect_uri }} + OIDCRedirectURI {{ fid.oidc_redirect_uri }} - + AuthType auth-openidc Require valid-user @@ -34,7 +34,7 @@ Require claim iss:{{provider.issuer_url}} - OIDCDiscoverURL {{ fid.redirect_uri }}?iss={{provider.encoded_issuer_url}} + OIDCDiscoverURL {{ fid.oidc_redirect_uri }}?iss={{provider.encoded_issuer_url}} OIDCUnAuthAction auth true OIDCUnAutzAction auth true diff --git a/charms/keystone-k8s/src/templates/apache2-saml-params b/charms/keystone-k8s/src/templates/apache2-saml-params new file mode 100644 index 00000000..ed5005c5 --- /dev/null +++ b/charms/keystone-k8s/src/templates/apache2-saml-params @@ -0,0 +1,27 @@ +{% if fid and fid.saml_providers -%} +{% for provider in fid.saml_providers -%} + {% set mellon -%} + MellonEnable "info" + MellonSPPrivateKeyFile {{ fid.saml2_sp_key_file }} + MellonSPCertFile {{ fid.saml2_sp_cert_file }} + MellonSPMetadataFile {{ provider.sp_metadata_file }} + MellonIdPMetadataFile {{ provider.idp_metadata_file }} + MellonEndpointPath {{fid.public_url_path}}/OS-FEDERATION/identity_providers/{{ provider.name }}/protocols/{{ provider.protocol }}/auth/mellon + MellonIdP "IDP" + MellonMergeEnvVars On ";" + MellonSecureCookie On + MellonCookieSameSite None + + Require valid-user + AuthType Mellon + MellonEnable auth + {% endset -%} + + {{ mellon | safe }} + + + + {{ mellon | safe }} + +{% endfor -%} +{% endif -%} diff --git a/charms/keystone-k8s/src/templates/wsgi-keystone.conf.j2 b/charms/keystone-k8s/src/templates/wsgi-keystone.conf.j2 index bd4ea09a..1fa300af 100644 --- a/charms/keystone-k8s/src/templates/wsgi-keystone.conf.j2 +++ b/charms/keystone-k8s/src/templates/wsgi-keystone.conf.j2 @@ -1,6 +1,9 @@ Listen 0.0.0.0:{{ ks_config.public_port }} + {% if ks_config.server_name %} + ServerName {{ks_config.server_name}} + {% endif %} WSGIDaemonProcess keystone-public processes=4 threads=1 user=keystone group=keystone display-name=%{GROUP} python-path=/usr/lib/python3/site-packages WSGIProcessGroup keystone-public {% if ingress_internal and ingress_internal.ingress_path -%} @@ -26,4 +29,5 @@ Listen 0.0.0.0:{{ ks_config.public_port }} {% include "apache2-oidc-params" %} + {% include "apache2-saml-params" %} diff --git a/charms/keystone-k8s/src/utils/certs.py b/charms/keystone-k8s/src/utils/certs.py index a01751f8..5135dfe6 100644 --- a/charms/keystone-k8s/src/utils/certs.py +++ b/charms/keystone-k8s/src/utils/certs.py @@ -29,10 +29,28 @@ from cryptography import ( from cryptography.exceptions import ( InvalidSignature, ) +from cryptography.hazmat.backends import ( + default_backend, +) +from cryptography.hazmat.primitives import ( + serialization, +) logger = logging.getLogger(__name__) +def cert_and_key_match(certificate: bytes, key: bytes) -> bool: + """Checks if the supplied cert is derived from the supplied key.""" + crt = x509.load_pem_x509_certificate(certificate, default_backend()) + cert_pub_key = crt.public_key() + + private_key = serialization.load_pem_private_key( + key, password=None, backend=default_backend() + ) + private_public_key = private_key.public_key() + return cert_pub_key.public_numbers() == private_public_key.public_numbers() + + def certificate_is_valid(certificate: bytes) -> bool: """Returns whether a certificate is valid. diff --git a/charms/keystone-k8s/src/utils/manager.py b/charms/keystone-k8s/src/utils/manager.py index 7c40f0c4..343585cc 100644 --- a/charms/keystone-k8s/src/utils/manager.py +++ b/charms/keystone-k8s/src/utils/manager.py @@ -45,6 +45,10 @@ _OIDC_METADATA_FOLDER = "/etc/apache2/oidc-metadata" _KEYSTONE_COMBINED_CA = ( "/usr/local/share/ca-certificates/keystone-combined.crt" ) +SAML_METADATA_FOLDER = "/etc/apache2/saml2-metadata" +SAML_PROVIDER_FOLDER = f"{SAML_METADATA_FOLDER}/providers" +SAML_KEY_PATH = f"{SAML_METADATA_FOLDER}/saml_sp_key.pem" +SAML_CERT_PATH = f"{SAML_METADATA_FOLDER}/saml_sp_cert.pem" class KeystoneManager: @@ -135,13 +139,19 @@ class KeystoneManager: self._credential_setup() self._bootstrap() + def _ensure_metadata_folder(self, pth: str) -> None: + self.run_cmd(["sudo", "mkdir", "-p", pth]) + self.run_cmd(["sudo", "chown", "keystone:www-data", pth]) + self.run_cmd(["sudo", "chmod", "550", pth]) + def setup_oidc_metadata_folder(self): """Create the OIDC metadata folder and set permissions.""" - self.run_cmd(["sudo", "mkdir", "-p", _OIDC_METADATA_FOLDER]) - self.run_cmd( - ["sudo", "chown", "keystone:www-data", _OIDC_METADATA_FOLDER] - ) - self.run_cmd(["sudo", "chmod", "550", _OIDC_METADATA_FOLDER]) + self._ensure_metadata_folder(_OIDC_METADATA_FOLDER) + + def setup_saml2_metadata_folder(self): + """Create the SAML2 metadata folder and set permissions.""" + self._ensure_metadata_folder(SAML_METADATA_FOLDER) + self._ensure_metadata_folder(SAML_PROVIDER_FOLDER) def rotate_fernet_keys(self): """Rotate the fernet keys. @@ -205,7 +215,7 @@ class KeystoneManager: def write_combined_ca(self) -> None: """Write the combined CA to the container.""" ca_contents = self.charm.get_ca_and_chain() - oauth_ca_certs = self.charm.get_ca_bundles_from_oauth_relations() + oauth_ca_certs = self.charm.get_ca_bundles_from_fid_relations() container = self.charm.unit.get_container(self.container_name) if not ca_contents and not oauth_ca_certs: logger.debug( @@ -231,12 +241,13 @@ class KeystoneManager: ) self.run_cmd(["sudo", "update-ca-certificates", "--fresh"]) - def write_oidc_metadata(self, metadata: Mapping[str, str]) -> None: - """Write the OIDC metadata to the container.""" + def _write_metadata_files( + self, metadata: Mapping[str, str], meta_folder: str + ) -> None: container = self.charm.unit.get_container(self.container_name) for filename, contents in metadata.items(): container.push( - f"{_OIDC_METADATA_FOLDER}/{filename}", + f"{meta_folder}/{filename}", contents, user="keystone", group="www-data", @@ -244,11 +255,47 @@ class KeystoneManager: ) # remove old metadata files - files = container.list_files(_OIDC_METADATA_FOLDER) + files = container.list_files(meta_folder) for file in files: if file.name not in metadata: container.remove_path(file.path) + def write_oidc_metadata(self, metadata: Mapping[str, str]) -> None: + """Write the OIDC metadata to the container.""" + self._write_metadata_files(metadata, _OIDC_METADATA_FOLDER) + + def write_saml_metadata(self, metadata: Mapping[str, str]) -> None: + """Write the SAML2 metadata to the container.""" + self.setup_saml2_metadata_folder() + self._write_metadata_files(metadata, SAML_PROVIDER_FOLDER) + + def remove_saml_key_and_cert(self): + """Removes the SAML2 SP key and cert.""" + self.run_cmd(["sudo", "rm", "-f", SAML_KEY_PATH]) + self.run_cmd(["sudo", "rm", "-f", SAML_CERT_PATH]) + + def ensure_saml_cert_and_key_state(self, cert: str, key: str) -> None: + """Ensure that the SAML cert and key are written to disk.""" + if not key or not cert: + raise ValueError("key and cert are mandatory") + + self.setup_saml2_metadata_folder() + container = self.charm.unit.get_container(self.container_name) + container.push( + SAML_KEY_PATH, + key, + user="keystone", + group="www-data", + permissions=0o440, + ) + container.push( + SAML_CERT_PATH, + cert, + user="keystone", + group="www-data", + permissions=0o440, + ) + def read_keys(self, key_repository: str) -> Mapping[str, str]: """Pull the fernet keys from the on-disk repository.""" container = self.charm.unit.get_container(self.container_name) diff --git a/charms/keystone-k8s/tests/unit/test_keystone_charm.py b/charms/keystone-k8s/tests/unit/test_keystone_charm.py index fa4f3fc9..30a5eb96 100644 --- a/charms/keystone-k8s/tests/unit/test_keystone_charm.py +++ b/charms/keystone-k8s/tests/unit/test_keystone_charm.py @@ -92,6 +92,26 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase): ) return rel_id + def add_keystone_saml_relation(self) -> int: + """Add keystone-saml relation.""" + rel_id = self.harness.add_relation( + "keystone-saml", "keystone-saml-entra" + ) + self.harness.add_relation_unit(rel_id, "keystone-saml-entra/0") + self.harness.update_relation_data( + rel_id, "keystone-saml-entra/0", {"ingress-address": "10.0.0.99"} + ) + self.harness.update_relation_data( + rel_id, + "keystone-saml-entra", + { + "name": "entra", + "label": "Log in with Entra SAML2", + "metadata": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4=", + }, + ) + return rel_id + def add_id_relation(self) -> int: """Add amqp relation.""" rel_id = self.harness.add_relation("identity-service", "cinder") @@ -314,6 +334,43 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase): }, ) + def test_keystone_saml2_relation(self): + """Test responding to a teystone saml2 relation.""" + test_utils.add_complete_ingress_relation(self.harness) + self.harness.set_leader() + self.harness.container_pebble_ready("keystone") + test_utils.add_db_relation_credentials( + self.harness, test_utils.add_base_db_relation(self.harness) + ) + ks_saml_rel_id = self.add_keystone_saml_relation() + rel_data = self.harness.get_relation_data( + ks_saml_rel_id, self.harness.charm.unit.app.name + ) + rel_data_saml = self.harness.get_relation_data( + ks_saml_rel_id, "keystone-saml-entra" + ) + self.maxDiff = None + acs_url = "http://public-url/v3/OS-FEDERATION/identity_providers/entra/protocols/saml2/auth/mellon/postResponse" + logout_url = "http://public-url/v3/OS-FEDERATION/identity_providers/entra/protocols/saml2/auth/mellon/logout" + metadata_url = "http://public-url/v3/OS-FEDERATION/identity_providers/entra/protocols/saml2/auth/mellon/metadata" + self.assertEqual( + rel_data, + { + "acs-url": acs_url, + "logout-url": logout_url, + "metadata-url": metadata_url, + }, + ) + + self.assertEqual( + rel_data_saml, + { + "name": "entra", + "label": "Log in with Entra SAML2", + "metadata": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4=", + }, + ) + def test_sync_oidc_providers(self): """Tests that OIDC provider metadata is written to disk.""" secret_mock = MagicMock() diff --git a/charms/keystone-saml-k8s/.sunbeam-build.yaml b/charms/keystone-saml-k8s/.sunbeam-build.yaml new file mode 100644 index 00000000..2bf8d79b --- /dev/null +++ b/charms/keystone-saml-k8s/.sunbeam-build.yaml @@ -0,0 +1,3 @@ +external-libraries: [] +internal-libraries: [] +templates: [] \ No newline at end of file diff --git a/charms/keystone-saml-k8s/LICENSE b/charms/keystone-saml-k8s/LICENSE new file mode 100644 index 00000000..4b8b005b --- /dev/null +++ b/charms/keystone-saml-k8s/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Canonical Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/charms/keystone-saml-k8s/README.md b/charms/keystone-saml-k8s/README.md new file mode 100644 index 00000000..95bc695a --- /dev/null +++ b/charms/keystone-saml-k8s/README.md @@ -0,0 +1,19 @@ +# keystone-saml-k8s + +This charm allows conveying necessary SAML2 settings to the keystone charm, in order for keystone to create it's SAML2 identity provider configuration. + +## Deployment + +```bash +juju deploy keystone-saml-k8s keystone-saml-entra +juju config keystone-saml-entra \ + name="entra" \ + label="Log in with Entra SAML2" \ + metadata-url="https://login.microsoftonline.com/{YOUR_TENANT}/federationmetadata/2007-06/federationmetadata.xml?appid={YOUR_APP_ID}" +``` + +Integrate with keystone: + +```bash +juju relate keystone-saml-entra:keystone-saml keystone:keystone-saml +``` \ No newline at end of file diff --git a/charms/keystone-saml-k8s/charmcraft.yaml b/charms/keystone-saml-k8s/charmcraft.yaml new file mode 100644 index 00000000..549a004b --- /dev/null +++ b/charms/keystone-saml-k8s/charmcraft.yaml @@ -0,0 +1,73 @@ +type: charm +name: keystone-saml-k8s +title: Keystone SAML +summary: Integrator charm to enable saml2 providers in Keystone +description: | + Integrator charm to enable saml2 providers in Keystone. + +platforms: + ubuntu@24.04:amd64: + +parts: + charm: + build-packages: + - git + - libffi-dev + - libssl-dev + - pkg-config + charm-binary-python-packages: + - cryptography + - requests + charm-requirements: [requirements.txt] + +config: + options: + name: + description: | + The name of the IDP. + + This name will be used as a provider ID in keystone to identify the provider. + type: string + default: !!null "" + label: + description: | + The label of the IDP. + + The label will be used as a display name for this IDP. Typically, you would + set this to something like "Log in with Okta". This label will appear in Horizon, + in the provider drop down. + type: string + default: !!null "" + metadata-url: + description: | + The SAML2 metadata URL. + + The SAML2 metadata URL contains the URLs and signing keys we need to configure + the IDP. There are some well known patters for SAML2 urls when it comes to public + providers: + + * Okta: https://{yourOktaOrg}/app/{appId}/sso/saml/metadata + * Google: https://accounts.google.com/o/saml2/idp?idpid={idp-id} + * Entra ID: https://login.microsoftonline.com/{tenant}/federationmetadata/2007-06/federationmetadata.xml?appid={app_id} + + Other providers may have different URLs, but as long as they are reachable and + include a valid saml2 metadata response, they should work. + default: !!null "" + type: string + ca-chain: + description: | + The CA chain used to validate the IDP. + + If the IDP uses a certificate issued by a custom CA, set this option. The value must + be a base64 encoded version of the CA chain. + type: string + default: !!null "" + +provides: + keystone-saml: + interface: keystone_saml + limit: 1 + +actions: + get-keystone-sp-urls: + description: Get the keystone service provider URLs for this relation diff --git a/charms/keystone-saml-k8s/lib/charms/keystone_saml_k8s/v1/keystone_saml.py b/charms/keystone-saml-k8s/lib/charms/keystone_saml_k8s/v1/keystone_saml.py new file mode 100644 index 00000000..6dbbd8c3 --- /dev/null +++ b/charms/keystone-saml-k8s/lib/charms/keystone_saml_k8s/v1/keystone_saml.py @@ -0,0 +1,393 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +import base64 +import json +import logging +from dataclasses import dataclass +from typing import Dict, List, Mapping, Optional + +import jsonschema +from ops.charm import ( + CharmBase, + RelationBrokenEvent, + RelationChangedEvent, +) +from ops.framework import EventBase, EventSource, Handle, Object, ObjectEvents +from ops.model import Relation, TooManyRelatedAppsError + +# The unique Charmhub library identifier, never change it +LIBID = "0cec5003349d4cac9adeb7dfc958d097" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +PYDEPS = ["jsonschema"] + +DEFAULT_RELATION_NAME = "keystone-saml" +logger = logging.getLogger(__name__) + +PROVIDER_JSON_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "metadata": { + "type": "string", + "description": "The IDP metadata.", + }, + "name": { + "type": "string", + "description": "The provider ID that will be used for this IDP.", + }, + "label": { + "type": "string", + "description": "The label which will be used in the dashboard.", + }, + "ca_chain": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "A CA chain that the requirer needs in order to trust the IDP." + }, + }, + "additionalProperties": False, + "required": ["metadata", "name", "label"] +} + +REQUIRER_JSON_SCHEMA = { + "type": "object", + "properties": { + "acs-url": { + "type": "string", + "description": "The assertion consumer service (acs) URL." + }, + "logout-url": { + "type": "string", + "description": "The SP logout URL." + }, + "metadata-url": { + "type": "string", + "description": "The metadata URL for the keystone SP." + }, + }, + "additionalProperties": False, + "required": ["acs-url", "logout-url", "metadata-url"] +} + + +class DataValidationError(RuntimeError): + """Raised when data validation fails on relation data.""" + + +def _validate_data(data: Dict, schema: Dict) -> None: + """Checks whether `data` matches `schema`. + + Will raise DataValidationError if the data is not valid, else return None. + """ + try: + jsonschema.validate(instance=data, schema=schema) + except jsonschema.ValidationError as e: + raise DataValidationError(data, schema) from e + + +def _load_data(data: Mapping, schema: Optional[Dict] = None) -> Dict: + """Parses nested fields and checks whether `data` matches `schema`.""" + ret = {} + for k, v in data.items(): + try: + ret[k] = json.loads(v) + except json.JSONDecodeError: + ret[k] = v + + if schema: + _validate_data(ret, schema) + return ret + + +def _dump_data(data: Dict, schema: Optional[Dict] = None) -> Dict: + if schema: + _validate_data(data, schema) + + ret = {} + for k, v in data.items(): + if isinstance(v, (list, dict)): + try: + ret[k] = json.dumps(v) + except json.JSONDecodeError as e: + raise DataValidationError(f"Failed to encode relation json: {e}") + elif isinstance(v, bool): + ret[k] = str(v) + else: + ret[k] = v + return ret + + +class KeystoneSAMLProviderChangedEvent(EventBase): + """Event to notify the charm that the information in the databag changed.""" + + def __init__( + self, handle: Handle, acs_url: str, metadata_url: str, logout_url: str + ): + super().__init__(handle) + self.acs_url = acs_url + self.metadata_url = metadata_url + self.logout_url = logout_url + + def snapshot(self) -> Dict: + """Save event.""" + return { + "acs_url": self.acs_url, + "metadata_url": self.metadata_url, + "logout_url": self.logout_url, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + super().restore(snapshot) + self.acs_url = snapshot["acs_url"] + self.metadata_url = snapshot["metadata_url"] + self.logout_url = snapshot["logout_url"] + + +class KeystoneSAMLProviderEvents(ObjectEvents): + """Event descriptor for events raised by `KeystoneSAMLProviderEvents`.""" + + changed = EventSource(KeystoneSAMLProviderChangedEvent) + + +class KeystoneSAMLProvider(Object): + + on = KeystoneSAMLProviderEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + ) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + + events = self._charm.on[relation_name] + self.framework.observe( + events.relation_changed, + self._on_relation_changed_event) + self.framework.observe( + events.relation_broken, + self._on_relation_broken_event) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Handle relation changed event.""" + data = event.relation.data[event.app] + if not data: + logger.info("No requirer relation data available.") + return + + try: + data = _load_data(data, REQUIRER_JSON_SCHEMA) + except DataValidationError as e: + logger.info(f"failed to validate relation data: {e}") + return + + self.on.changed.emit( + acs_url=data["acs-url"], + metadata_url=data["metadata-url"], + logout_url=data["logout-url"], + ) + + def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: + """Handle relation broken event.""" + logger.info("Relation broken, clearing keystone SP urls.") + self.on.changed.emit( + acs_url="", metadata_url="", logout_url="" + ) + + def set_provider_info(self, info: Mapping[str, str]) -> None: + if not self.model.unit.is_leader(): + return + + _validate_data(info, PROVIDER_JSON_SCHEMA) + + encoded = base64.b64encode(info["metadata"].encode()) + info["metadata"] = encoded.decode() + rel_data = _dump_data(info, PROVIDER_JSON_SCHEMA) + for relation in self.model.relations[self._relation_name]: + relation.data[self.model.app].update(rel_data) + + @property + def requirer_data(self) -> Mapping[str, str]: + relation = self.model.get_relation(relation_name=self._relation_name) + if not relation or not relation.app: + return {} + + rel_data = relation.data[relation.app] + if not rel_data: + return {} + + try: + data = _load_data( + relation.data[relation.app], + REQUIRER_JSON_SCHEMA, + ) + except DataValidationError as e: + logger.info(f"failed to validate relation data: {e}") + return {} + + return data + + +class KeystoneSAMLRequirerChangedEvent(EventBase): + """Event to notify the charm that the information in the databag changed.""" + + def __init__( + self, handle: Handle, metadata: str, name: str, label: str, ca_chain: str + ): + super().__init__(handle) + self.metadata = metadata + self.name = name + self.label = label + self.ca_chain = ca_chain + + def snapshot(self) -> Dict: + """Save event.""" + return { + "metadata": self.metadata, + "name": self.name, + "label": self.label, + "ca_chain": self.ca_chain, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + super().restore(snapshot) + self.metadata = snapshot["metadata"] + self.name = snapshot["name"] + self.label = snapshot["label"] + self.ca_chain = snapshot["ca_chain"] + + +class KeystoneSAMLRequirerEvents(ObjectEvents): + """Event descriptor for events raised by `KeystoneSAMLRequirerEvents`.""" + + changed = EventSource(KeystoneSAMLRequirerChangedEvent) + + +class KeystoneSAMLRequirer(Object): + + on = KeystoneSAMLRequirerEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str = DEFAULT_RELATION_NAME, + ) -> None: + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + + events = self._charm.on[relation_name] + self.framework.observe( + events.relation_changed, + self._on_relation_changed_event) + self.framework.observe( + events.relation_broken, + self._on_relation_broken_event) + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Handle relation changed event.""" + try: + data = _load_data( + event.relation.data[event.relation.app], + PROVIDER_JSON_SCHEMA, + ) + except DataValidationError as e: + logger.error(f"failed to validate relation data: {e}") + return + if not data: + logger.info("No requirer relation data available.") + return + + + self.on.changed.emit( + metadata=data["metadata"], + name=data["name"], + label=data["label"], + ca_chain=data.get("ca_chain", []), + ) + + + def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None: + """Handle relation broken event.""" + + logger.info("Relation broken, clearing data.") + self.on.changed.emit( + metadata="", + name="", + label="", + ca_chain=[], + ) + + @property + def relations(self) -> list[Relation]: + return [ + relation + for relation in self._charm.model.relations[self._relation_name] + if relation.active + ] + + def set_requirer_info( + self, info: Mapping[str, str], relation_id: int + ) -> None: + if not self.model.unit.is_leader(): + return + + relation = self.model.get_relation( + relation_name=self._relation_name, relation_id=relation_id + ) + if not relation: + return + + rel_data = _dump_data(info, REQUIRER_JSON_SCHEMA) + relation.data[self.model.app].update(rel_data) + + def get_providers(self) -> List[Mapping[str, str]]: + providers = [] + names = [] + + for relation in self.relations: + if not relation or not relation.app: + continue + + rel_data = relation.data[relation.app] + if not rel_data: + continue + + try: + data = _load_data( + relation.data[relation.app], + PROVIDER_JSON_SCHEMA, + ) + except DataValidationError as e: + logger.error(f"failed to validate relation data: {e}") + continue + + try: + decoded = base64.b64decode(data["metadata"]).decode() + data["metadata"] = decoded + except Exception as e: + logger.error(f"failed to decode metadata: {e}") + continue + if data["name"] in names: + raise ValueError( + f"duplicate provider name in relation data: {data['name']}" + ) + names.append(data["name"]) + data["relation_id"] = relation.id + providers.append(data) + return providers diff --git a/charms/keystone-saml-k8s/pyproject.toml b/charms/keystone-saml-k8s/pyproject.toml new file mode 100644 index 00000000..fca10c0f --- /dev/null +++ b/charms/keystone-saml-k8s/pyproject.toml @@ -0,0 +1,60 @@ +# Testing tools configuration + +[project] +name = "keystone-saml-k8s" +version = "2025.1" +requires-python = "~=3.12.0" + +dependencies = [ + "cryptography", + "jsonschema", + "pydantic", + "lightkube", + "lightkube-models", + "ops", + "pwgen", + "tenacity", # From ops_sunbeam + "opentelemetry-api~=1.21.0", # charm_tracing library -> opentelemetry-sdk requires 1.21.0 +] + + +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Linting tools configuration +[tool.ruff] +line-length = 99 +lint.select = ["E", "W", "F", "C", "N", "D", "I001"] +lint.extend-ignore = [ + "D105", + "D107", + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +extend-exclude = ["__pycache__", "*.egg_info"] +lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" + +[tool.pyright] +include = ["src/**.py"] diff --git a/charms/keystone-saml-k8s/rebuild b/charms/keystone-saml-k8s/rebuild new file mode 100644 index 00000000..9151076d --- /dev/null +++ b/charms/keystone-saml-k8s/rebuild @@ -0,0 +1,3 @@ +# This file is used to trigger a build. +# Change uuid to trigger a new build. +eba56d58-2d06-49c1-aadc-3638901ae6b6 \ No newline at end of file diff --git a/charms/keystone-saml-k8s/requirements.txt b/charms/keystone-saml-k8s/requirements.txt new file mode 100644 index 00000000..0356c38b --- /dev/null +++ b/charms/keystone-saml-k8s/requirements.txt @@ -0,0 +1 @@ +ops ~= 2.17 diff --git a/charms/keystone-saml-k8s/src/certs.py b/charms/keystone-saml-k8s/src/certs.py new file mode 100644 index 00000000..b64095ed --- /dev/null +++ b/charms/keystone-saml-k8s/src/certs.py @@ -0,0 +1,64 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helper functions to verify CA chains.""" + +import re +from typing import ( + List, +) + +from cryptography import ( + x509, +) +from cryptography.hazmat.backends import ( + default_backend, +) + + +def parse_cert_chain(pem_data: str) -> List[str]: + """Return a list of pem certs from a combined pem file.""" + parsed_certs = [] + if not pem_data: + return [] + + ca_chain = re.findall( + r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", + pem_data, + re.DOTALL, + ) + + for idx, pem_cert in enumerate(ca_chain): + try: + x509.load_pem_x509_certificate( + pem_cert.encode(), default_backend() + ) + parsed_certs.append(pem_cert) + except Exception as e: + raise ValueError( + f"Certificate #{idx + 1} is corrupted or invalid: {e}" + ) + + return parsed_certs + + +def is_valid_chain(chain: str) -> bool: + """Return true if the CA chain PEM is valid.""" + try: + parsed_chain = parse_cert_chain(chain) + except ValueError: + return False + if not parsed_chain: + return False + return True diff --git a/charms/keystone-saml-k8s/src/charm.py b/charms/keystone-saml-k8s/src/charm.py new file mode 100755 index 00000000..281d033b --- /dev/null +++ b/charms/keystone-saml-k8s/src/charm.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Charm the service. + +Refer to the following tutorial that will help you +develop a new k8s charm using the Operator Framework: + +https://juju.is/docs/sdk/create-a-minimal-kubernetes-charm +""" + +import base64 +import logging +import tempfile +from typing import ( + List, +) + +import ops +import requests +from certs import ( + is_valid_chain, + parse_cert_chain, +) +from charms.keystone_saml_k8s.v1.keystone_saml import ( + KeystoneSAMLProvider, + KeystoneSAMLProviderChangedEvent, +) + +# Log messages can be retrieved using juju debug-log +logger = logging.getLogger(__name__) + + +class KeystoneSamlK8SCharm(ops.CharmBase): + """Charm the service.""" + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.saml_provider = KeystoneSAMLProvider(self) + + # Lifecycle events + self.framework.observe( + self.on.config_changed, + self._on_config_changed, + ) + self.framework.observe( + self.on.keystone_saml_relation_joined, + self._on_config_changed, + ) + + # keystone saml provider + self.framework.observe( + self.saml_provider.on.changed, + self._on_saml_changed, + ) + + # Action events + self.framework.observe( + self.on.get_keystone_sp_urls_action, + self._on_get_keystone_sp_urls, + ) + + def _on_saml_changed( + self, event: KeystoneSAMLProviderChangedEvent + ) -> None: + if not self.saml_provider.requirer_data: + self.unit.status = ops.WaitingStatus( + "Waiting for the requirer charm to set SP urls" + ) + return + self.unit.status = ops.ActiveStatus("Provider is ready") + + def _on_get_keystone_sp_urls(self, event: ops.ActionEvent) -> None: + urls = self.saml_provider.requirer_data + if not urls: + event.fail("No keystone SP urls found.") + return + event.set_results(urls) + + def _get_missing_config(self) -> List[str]: + required = ["name", "label", "metadata-url"] + missing = [] + for i in required: + val = self.config.get(i, "") + if not val: + missing.append(i) + return missing + + def _ensure_ca_chain_is_valid(self) -> bool: + chain = self.config.get("ca-chain", "") + if not chain: + # not having a ca-chain is valid + return True + return is_valid_chain(chain) + + def _get_idp_metadata(self) -> str: + metadata_url = self.config.get("metadata-url", "") + if not metadata_url: + return "" + + with tempfile.NamedTemporaryFile() as fd: + verify = True + cachain = self.config.get("ca-chain", "") + if cachain: + verify = fd.name + data = base64.b64decode(cachain) + fd.write(data) + fd.flush() + metadata = requests.get(metadata_url, verify=verify) + metadata.raise_for_status() + return metadata.text + + def _on_config_changed(self, event: ops.HookEvent) -> None: + missing = self._get_missing_config() + if missing: + self.unit.status = ops.BlockedStatus( + f"Missing required config(s): {', '.join(missing)}" + ) + return + + if not self._ensure_ca_chain_is_valid(): + self.unit.status = ops.BlockedStatus("Invalid ca-chain in config") + return + + try: + metadata = self._get_idp_metadata() + except Exception as e: + logger.error(f"failed to get metadata: {e}") + self.unit.status = ops.BlockedStatus("Failed to get IDP metadata") + return + + try: + ca_chain = [] + config_chain = self.config.get("ca-chain", "") + if config_chain: + ca_chain = parse_cert_chain( + base64.b64decode(config_chain).decode() + ) + except Exception as e: + logger.error(f"failed to parse ca chain: {e}") + self.unit.status = ops.BlockedStatus( + "Failed parse configured CA chain" + ) + return + + rel_data = { + "metadata": metadata, + "name": self.config["name"], + "label": self.config["label"], + "ca_chain": ca_chain, + } + if not self.saml_provider.requirer_data: + self.unit.status = ops.WaitingStatus( + "Waiting for keystone to set SP URLs" + ) + else: + self.unit.status = ops.ActiveStatus("Provider is ready") + self.saml_provider.set_provider_info(rel_data) + + +if __name__ == "__main__": # pragma: nocover + ops.main(KeystoneSamlK8SCharm) diff --git a/charms/keystone-saml-k8s/tests/unit/__init__.py b/charms/keystone-saml-k8s/tests/unit/__init__.py new file mode 100644 index 00000000..000f6d36 --- /dev/null +++ b/charms/keystone-saml-k8s/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for keystone-saml-k8s.""" diff --git a/charms/keystone-saml-k8s/tests/unit/test_certs_utils.py b/charms/keystone-saml-k8s/tests/unit/test_certs_utils.py new file mode 100644 index 00000000..a6e2dbdd --- /dev/null +++ b/charms/keystone-saml-k8s/tests/unit/test_certs_utils.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test certificate utilities.""" + +import unittest + +import certs +import ops_sunbeam.test_utils as test_utils + + +class TestCertificateUtils(unittest.TestCase): + """Test certificate utility functions.""" + + def setUp(self): + """Set up test fixtures.""" + # Use the project's standard test certificate + self.valid_cert_pem = test_utils.TEST_CA + + def test_parse_cert_chain_empty(self): + """Test parsing empty certificate chain.""" + result = certs.parse_cert_chain("") + self.assertEqual(result, []) + + def test_parse_cert_chain_valid(self): + """Test parsing valid certificate chain.""" + result = certs.parse_cert_chain(self.valid_cert_pem) + self.assertEqual(len(result), 1) + self.assertEqual(result[0], self.valid_cert_pem) + + def test_parse_cert_chain_invalid(self): + """Test parsing invalid certificate.""" + invalid_pem = """-----BEGIN CERTIFICATE----- +INVALID_DATA +-----END CERTIFICATE-----""" + with self.assertRaises(ValueError) as context: + certs.parse_cert_chain(invalid_pem) + self.assertIn( + "Certificate #1 is corrupted or invalid", str(context.exception) + ) + + def test_is_valid_chain_empty(self): + """Test validation of empty chain.""" + # Empty chain should be invalid + result = certs.is_valid_chain("") + self.assertFalse(result) + + def test_is_valid_chain_valid(self): + """Test validation of valid chain.""" + result = certs.is_valid_chain(self.valid_cert_pem) + self.assertTrue(result) + + def test_is_valid_chain_invalid(self): + """Test validation of invalid chain.""" + invalid_pem = """-----BEGIN CERTIFICATE----- +INVALID_DATA +-----END CERTIFICATE-----""" + result = certs.is_valid_chain(invalid_pem) + self.assertFalse(result) diff --git a/charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py b/charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py new file mode 100644 index 00000000..0221a6bc --- /dev/null +++ b/charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Define keystone-saml-k8s tests.""" + +import base64 +import json +import unittest.mock as mock + +import charm +import ops_sunbeam.test_utils as test_utils +from ops.testing import ( + ActionFailed, + Harness, +) + + +class TestKeystoneSamlK8SCharm(test_utils.CharmTestCase): + """Test Keystone SAML charm.""" + + PATCHES = [] + + def setUp(self): + """Run test setup.""" + super().setUp(charm, self.PATCHES) + self.harness = Harness(charm.KeystoneSamlK8SCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def add_keystone_saml_relation(self) -> int: + """Add keystone-saml relation.""" + rel_id = self.harness.add_relation("keystone-saml", "keystone") + self.harness.add_relation_unit(rel_id, "keystone/0") + return rel_id + + def test_missing_config(self): + """Test charm with missing configuration.""" + self.harness.set_leader() + + # Trigger config changed without setting config + self.harness.charm.on.config_changed.emit() + + # Should be in BlockedStatus due to missing config + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.BlockedStatus + ) + self.assertIn( + "Missing required config", + str(self.harness.charm.unit.status.message), + ) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_valid_config_no_relation(self, mock_is_valid, mock_get): + """Test charm with valid config but no relation.""" + self.harness.set_leader() + mock_is_valid.return_value = True + + # Mock the metadata response + mock_response = mock.MagicMock() + mock_response.text = "test metadata" + mock_get.return_value = mock_response + + # Set valid configuration + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + } + ) + + # Should be waiting for keystone + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.WaitingStatus + ) + self.assertIn( + "Waiting for keystone", + str(self.harness.charm.unit.status.message), + ) + + # Verify metadata was fetched + mock_get.assert_called_once_with( + "https://example.com/metadata", verify=True + ) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_valid_config_with_relation(self, mock_is_valid, mock_get): + """Test charm with valid config and relation.""" + self.harness.set_leader() + mock_is_valid.return_value = True + + # Mock the metadata response + mock_response = mock.MagicMock() + mock_response.text = "test metadata" + mock_get.return_value = mock_response + + # Add relation with SP URLs + rel_id = self.add_keystone_saml_relation() + self.harness.update_relation_data( + rel_id, + "keystone", + { + "acs-url": "https://keystone.example.com/acs", + "logout-url": "https://keystone.example.com/logout", + "metadata-url": "https://keystone.example.com/metadata", + }, + ) + + # Set valid configuration + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + } + ) + + # Should be active + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.ActiveStatus + ) + self.assertEqual( + "Provider is ready", + str(self.harness.charm.unit.status.message), + ) + + @mock.patch("charm.is_valid_chain") + def test_invalid_ca_chain(self, mock_is_valid): + """Test charm with invalid CA chain.""" + self.harness.set_leader() + mock_is_valid.return_value = False + + # Set config with invalid CA chain + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + "ca-chain": base64.b64encode(b"invalid-chain").decode(), + } + ) + + # Should be blocked due to invalid CA chain + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.BlockedStatus + ) + self.assertEqual( + "Invalid ca-chain in config", + str(self.harness.charm.unit.status.message), + ) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_metadata_fetch_error(self, mock_is_valid, mock_get): + """Test charm when metadata fetch fails.""" + self.harness.set_leader() + mock_is_valid.return_value = True + mock_get.side_effect = Exception("Network error") + + # Set valid configuration + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + } + ) + + # Should be blocked due to metadata fetch error + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.BlockedStatus + ) + self.assertEqual( + "Failed to get IDP metadata", + str(self.harness.charm.unit.status.message), + ) + + def test_get_keystone_sp_urls_action_no_urls(self): + """Test get-keystone-sp-urls action without URLs.""" + self.harness.set_leader() + + # Run the action - should fail since no relation data + with self.assertRaises(ActionFailed) as context: + self.harness.run_action("get-keystone-sp-urls") + + self.assertEqual("No keystone SP urls found.", str(context.exception)) + + def test_get_keystone_sp_urls_action_with_urls(self): + """Test get-keystone-sp-urls action with URLs.""" + self.harness.set_leader() + + # Add relation with SP URLs + rel_id = self.add_keystone_saml_relation() + self.harness.update_relation_data( + rel_id, + "keystone", + { + "acs-url": "https://keystone.example.com/acs", + "logout-url": "https://keystone.example.com/logout", + "metadata-url": "https://keystone.example.com/metadata", + }, + ) + + # Run the action + action_event = self.harness.run_action("get-keystone-sp-urls") + + # Should return the URLs + results = action_event.results + self.assertEqual( + "https://keystone.example.com/acs", results.get("acs-url") + ) + self.assertEqual( + "https://keystone.example.com/logout", results.get("logout-url") + ) + self.assertEqual( + "https://keystone.example.com/metadata", + results.get("metadata-url"), + ) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_config_changed_with_valid_ca_chain(self, mock_is_valid, mock_get): + """Test config changed with valid CA chain sets relation data.""" + self.harness.set_leader() + mock_is_valid.return_value = True + + # Mock the metadata response + mock_response = mock.MagicMock() + mock_response.text = "test metadata" + mock_get.return_value = mock_response + + # Add relation + rel_id = self.add_keystone_saml_relation() + + # Create base64 encoded CA chain (using test certificate) + ca_chain_bytes = test_utils.TEST_CA.encode() + ca_chain_b64 = base64.b64encode(ca_chain_bytes).decode() + + # Set configuration with CA chain + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + "ca-chain": ca_chain_b64, + } + ) + + # Verify relation data was set with parsed CA chain + rel_data = self.harness.get_relation_data( + rel_id, self.harness.charm.app.name + ) + self.assertEqual("test-provider", rel_data.get("name")) + self.assertEqual("Test Provider", rel_data.get("label")) + # Metadata is base64 encoded in relation data + self.assertEqual( + "test metadata", + base64.b64decode(rel_data.get("metadata")).decode(), + ) + + # CA chain should be JSON-serialized list of PEM certificates + ca_chain_str = rel_data.get("ca_chain") + self.assertIsNotNone(ca_chain_str) + ca_chain = json.loads(ca_chain_str) + self.assertIsInstance(ca_chain, list) + self.assertEqual(len(ca_chain), 1) + self.assertEqual(ca_chain[0], test_utils.TEST_CA) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_config_changed_without_ca_chain(self, mock_is_valid, mock_get): + """Test config changed without CA chain sets empty ca_chain list.""" + self.harness.set_leader() + mock_is_valid.return_value = True + + # Mock the metadata response + mock_response = mock.MagicMock() + mock_response.text = "test metadata" + mock_get.return_value = mock_response + + # Add relation + rel_id = self.add_keystone_saml_relation() + + # Set configuration without CA chain + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + } + ) + + # Verify relation data was set with empty CA chain + rel_data = self.harness.get_relation_data( + rel_id, self.harness.charm.app.name + ) + ca_chain_str = rel_data.get("ca_chain") + self.assertIsNotNone(ca_chain_str) + # CA chain should be JSON-serialized empty list + import json + + ca_chain = json.loads(ca_chain_str) + self.assertIsInstance(ca_chain, list) + self.assertEqual(len(ca_chain), 0) + + @mock.patch("charm.requests.get") + @mock.patch("charm.is_valid_chain") + def test_config_changed_ca_chain_parse_error( + self, mock_is_valid, mock_get + ): + """Test config changed with CA chain parse error.""" + self.harness.set_leader() + mock_is_valid.return_value = True + + # Mock the metadata response + mock_response = mock.MagicMock() + mock_response.text = "test metadata" + mock_get.return_value = mock_response + + # Set config with malformed base64 CA chain that will cause + # parse error. This creates a malformed certificate that will + # fail when parse_cert_chain tries to validate it + invalid_b64 = base64.b64encode( + b"-----BEGIN CERTIFICATE-----\nINVALID\n-----END CERTIFICATE-----" + ).decode() + + self.harness.update_config( + { + "name": "test-provider", + "label": "Test Provider", + "metadata-url": "https://example.com/metadata", + "ca-chain": invalid_b64, + } + ) + + # Should be blocked due to CA chain parse error + self.assertIsInstance( + self.harness.charm.unit.status, charm.ops.BlockedStatus + ) + self.assertEqual( + "Failed parse configured CA chain", + str(self.harness.charm.unit.status.message), + ) diff --git a/tests/identity/smoke.yaml.j2 b/tests/identity/smoke.yaml.j2 index 4a7af360..90c52804 100644 --- a/tests/identity/smoke.yaml.j2 +++ b/tests/identity/smoke.yaml.j2 @@ -40,6 +40,22 @@ applications: credential-keys: 5M resources: keystone-image: ghcr.io/canonical/keystone:2025.1 + keystone-saml: + {% if keystone_saml_k8s is defined and keystone_saml_k8s is sameas true -%} + charm: ../../../keystone-saml-k8s.charm + {% else -%} + charm: ch:keystone-saml-k8s + channel: 2025.1/edge + {% endif -%} + base: ubuntu@24.04 + scale: 1 + trust: true + options: + name: "test-idp" + label: "Log in with test IDP" + # This will fail. We need an actual IDP to test with, but we need to deploy this + # charm as part of the tests. + metadata-url: "https://idp.example.com/metadata.xml" horizon: {% if horizon_k8s is defined and horizon_k8s is sameas true -%} charm: ../../../horizon-k8s.charm @@ -75,4 +91,6 @@ relations: - horizon:ingress-internal - - keystone:send-ca-cert - horizon:receive-ca-cert +- - keystone:keystone-saml + - keystone-saml:keystone-saml diff --git a/tests/identity/tests.yaml b/tests/identity/tests.yaml index c8ba5826..5761fdd2 100644 --- a/tests/identity/tests.yaml +++ b/tests/identity/tests.yaml @@ -48,6 +48,9 @@ target_deploy_status: keystone: workload-status: active workload-status-message-regex: '^$' + keystone-saml: + workload-status: blocked + workload-status-message-regex: '^Failed to get IDP metadata$' glance: workload-status: active workload-status-message-regex: '^$' diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml index 3fb25ace..8fca823d 100644 --- a/zuul.d/jobs.yaml +++ b/zuul.d/jobs.yaml @@ -310,6 +310,18 @@ - rebuild vars: charm: keystone-ldap-k8s +- job: + name: charm-build-keystone-saml-k8s + description: Build sunbeam keystone-saml-k8s charm + run: playbooks/charm/build.yaml + timeout: 3600 + match-on-config-updates: false + files: + - ops-sunbeam/ops_sunbeam/ + - charms/keystone-saml-k8s/ + - rebuild + vars: + charm: keystone-saml-k8s - job: name: charm-build-openstack-exporter-k8s description: Build sunbeam openstack-exporter-k8s charm @@ -593,14 +605,18 @@ soft: true - name: charm-build-horizon-k8s soft: true + - name: charm-build-keystone-saml-k8s + soft: true files: - ops-sunbeam/ops_sunbeam/ - charms/horizon-k8s/ - charms/keystone-k8s/ + - charms/keystone-saml-k8s/ - rebuild - zuul.d/zuul.yaml vars: charm_jobs: + - charm-build-keystone-saml-k8s - charm-build-keystone-k8s - charm-build-horizon-k8s test_dir: tests/identity diff --git a/zuul.d/project-templates.yaml b/zuul.d/project-templates.yaml index 7a8aee57..a2938595 100644 --- a/zuul.d/project-templates.yaml +++ b/zuul.d/project-templates.yaml @@ -90,6 +90,8 @@ nodeset: ubuntu-jammy - charm-build-keystone-ldap-k8s: nodeset: ubuntu-jammy + - charm-build-keystone-saml-k8s: + nodeset: ubuntu-jammy - charm-build-openstack-exporter-k8s: nodeset: ubuntu-jammy - charm-build-openstack-hypervisor: @@ -159,6 +161,8 @@ nodeset: ubuntu-jammy - charm-build-keystone-ldap-k8s: nodeset: ubuntu-jammy + - charm-build-keystone-saml-k8s: + nodeset: ubuntu-jammy - charm-build-openstack-exporter-k8s: nodeset: ubuntu-jammy - charm-build-openstack-hypervisor: diff --git a/zuul.d/zuul.yaml b/zuul.d/zuul.yaml index dd0a1311..3c812657 100644 --- a/zuul.d/zuul.yaml +++ b/zuul.d/zuul.yaml @@ -45,6 +45,7 @@ magnum-k8s: 2025.1/edge masakari-k8s: 2025.1/edge keystone-ldap-k8s: 2025.1/edge + keystone-saml-k8s: 2025.1/edge openstack-exporter-k8s: 2025.1/edge openstack-hypervisor: 2025.1/edge openstack-images-sync-k8s: 2025.1/edge