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