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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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:
|
||||
|
@@ -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>
|
||||
|
27
charms/keystone-k8s/src/templates/apache2-saml-params
Normal file
27
charms/keystone-k8s/src/templates/apache2-saml-params
Normal 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 -%}
|
@@ -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>
|
||||
|
@@ -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.
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
3
charms/keystone-saml-k8s/.sunbeam-build.yaml
Normal file
3
charms/keystone-saml-k8s/.sunbeam-build.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
external-libraries: []
|
||||
internal-libraries: []
|
||||
templates: []
|
202
charms/keystone-saml-k8s/LICENSE
Normal file
202
charms/keystone-saml-k8s/LICENSE
Normal 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.
|
19
charms/keystone-saml-k8s/README.md
Normal file
19
charms/keystone-saml-k8s/README.md
Normal 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
|
||||
```
|
73
charms/keystone-saml-k8s/charmcraft.yaml
Normal file
73
charms/keystone-saml-k8s/charmcraft.yaml
Normal 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
|
@@ -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
|
60
charms/keystone-saml-k8s/pyproject.toml
Normal file
60
charms/keystone-saml-k8s/pyproject.toml
Normal 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"]
|
3
charms/keystone-saml-k8s/rebuild
Normal file
3
charms/keystone-saml-k8s/rebuild
Normal 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
|
1
charms/keystone-saml-k8s/requirements.txt
Normal file
1
charms/keystone-saml-k8s/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
ops ~= 2.17
|
64
charms/keystone-saml-k8s/src/certs.py
Normal file
64
charms/keystone-saml-k8s/src/certs.py
Normal 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
|
174
charms/keystone-saml-k8s/src/charm.py
Executable file
174
charms/keystone-saml-k8s/src/charm.py
Executable 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)
|
15
charms/keystone-saml-k8s/tests/unit/__init__.py
Normal file
15
charms/keystone-saml-k8s/tests/unit/__init__.py
Normal 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."""
|
72
charms/keystone-saml-k8s/tests/unit/test_certs_utils.py
Normal file
72
charms/keystone-saml-k8s/tests/unit/test_certs_utils.py
Normal 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)
|
358
charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py
Normal file
358
charms/keystone-saml-k8s/tests/unit/test_keystone_saml_charm.py
Normal 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),
|
||||
)
|
@@ -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
|
||||
|
||||
|
@@ -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: '^$'
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user