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 <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira
2025-08-07 16:07:22 +03:00
parent adc6c57496
commit 204fb83a27
29 changed files with 2018 additions and 32 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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 = """<?xml version="1.0" encoding="utf-8"?>
<EntityDescriptor entityID="%(entity_id)s" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="true">
<KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>%(sp_cert)s</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>%(sp_cert)s</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="%(base_url)s/logout"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="%(base_url)s/postResponse" index="0"/>
</SPSSODescriptor>
</EntityDescriptor>
"""
@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:

View File

@@ -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 }}
<Location {{ fid.redirect_uri_path }}>
<Location {{ fid.oidc_redirect_uri_path }}>
AuthType auth-openidc
Require valid-user
</Location>
@@ -34,7 +34,7 @@
Require claim iss:{{provider.issuer_url}}
</RequireAll>
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
</Location>

View File

@@ -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 -%}
<Location {{fid.public_url_path}}/OS-FEDERATION/identity_providers/{{ provider.name }}/protocols/{{ provider.protocol }}/auth>
{{ mellon | safe }}
</Location>
<Location {{fid.public_url_path}}/auth/OS-FEDERATION/identity_providers/{{ provider.name }}/protocols/{{ provider.protocol }}/websso>
{{ mellon | safe }}
</Location>
{% endfor -%}
{% endif -%}

View File

@@ -1,6 +1,9 @@
Listen 0.0.0.0:{{ ks_config.public_port }}
<VirtualHost *:{{ 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 }}
</IfVersion>
</Directory>
{% include "apache2-oidc-params" %}
{% include "apache2-saml-params" %}
</VirtualHost>

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
external-libraries: []
internal-libraries: []
templates: []

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -0,0 +1,3 @@
# This file is used to trigger a build.
# Change uuid to trigger a new build.
eba56d58-2d06-49c1-aadc-3638901ae6b6

View File

@@ -0,0 +1 @@
ops ~= 2.17

View File

@@ -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

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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 = "<xml>test metadata</xml>"
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 = "<xml>test metadata</xml>"
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 = "<xml>test metadata</xml>"
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(
"<xml>test metadata</xml>",
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 = "<xml>test metadata</xml>"
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 = "<xml>test metadata</xml>"
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),
)

View File

@@ -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

View File

@@ -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: '^$'

View File

@@ -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

View File

@@ -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:

View File

@@ -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