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/*/lib/
|
||||||
charms/*/src/templates/parts/
|
charms/*/src/templates/parts/
|
||||||
!charms/horizon-k8s/lib/
|
!charms/horizon-k8s/lib/
|
||||||
|
!charms/keystone-saml-k8s/lib/
|
||||||
# artefacts from functional tests
|
# artefacts from functional tests
|
||||||
tempest.log
|
tempest.log
|
||||||
|
@@ -296,7 +296,7 @@ class TrustedDashboardRequirer(Object):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not requirer_data["dashboard-url"]:
|
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
|
return
|
||||||
|
|
||||||
_validate_data(requirer_data, TRUSTED_DASHBOARD_PROVIDER_JSON_SCHEMA)
|
_validate_data(requirer_data, TRUSTED_DASHBOARD_PROVIDER_JSON_SCHEMA)
|
||||||
|
@@ -10,6 +10,7 @@ external-libraries:
|
|||||||
- charms.kratos_external_idp_integrator.v0.kratos_external_provider
|
- charms.kratos_external_idp_integrator.v0.kratos_external_provider
|
||||||
internal-libraries:
|
internal-libraries:
|
||||||
- charms.horizon_k8s.v0.trusted_dashboard
|
- charms.horizon_k8s.v0.trusted_dashboard
|
||||||
|
- charms.keystone_saml_k8s.v1.keystone_saml
|
||||||
templates:
|
templates:
|
||||||
- parts/section-database
|
- parts/section-database
|
||||||
- parts/database-connection
|
- parts/database-connection
|
||||||
|
@@ -51,6 +51,28 @@ config:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
description: Enable notifications to send to telemetry.
|
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:
|
actions:
|
||||||
get-admin-password:
|
get-admin-password:
|
||||||
@@ -171,6 +193,9 @@ requires:
|
|||||||
external-idp:
|
external-idp:
|
||||||
interface: external_provider
|
interface: external_provider
|
||||||
optional: true
|
optional: true
|
||||||
|
keystone-saml:
|
||||||
|
interface: keystone_saml
|
||||||
|
optional: true
|
||||||
|
|
||||||
provides:
|
provides:
|
||||||
identity-service:
|
identity-service:
|
||||||
|
@@ -30,6 +30,7 @@ import binascii
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import (
|
from collections import (
|
||||||
defaultdict,
|
defaultdict,
|
||||||
@@ -78,6 +79,9 @@ from charms.hydra.v0.oauth import (
|
|||||||
ClientConfig,
|
ClientConfig,
|
||||||
OAuthRequirer,
|
OAuthRequirer,
|
||||||
)
|
)
|
||||||
|
from charms.keystone_saml_k8s.v1.keystone_saml import (
|
||||||
|
KeystoneSAMLRequirer,
|
||||||
|
)
|
||||||
from ops.charm import (
|
from ops.charm import (
|
||||||
ActionEvent,
|
ActionEvent,
|
||||||
RelationChangedEvent,
|
RelationChangedEvent,
|
||||||
@@ -116,6 +120,28 @@ OAUTH_GRANT_TYPES = [
|
|||||||
"client_credentials",
|
"client_credentials",
|
||||||
"refresh_token",
|
"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
|
@sunbeam_tracing.trace_type
|
||||||
@@ -154,9 +180,10 @@ class KeystoneConfigAdapter(sunbeam_contexts.ConfigContext):
|
|||||||
"service_tenant_id": self.charm.service_project_id,
|
"service_tenant_id": self.charm.service_project_id,
|
||||||
"admin_domain_name": self.charm.admin_domain_name,
|
"admin_domain_name": self.charm.admin_domain_name,
|
||||||
"admin_domain_id": self.charm.admin_domain_id,
|
"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,
|
"default_domain_id": self.charm.default_domain_id,
|
||||||
"public_port": self.charm.service_port,
|
"public_port": self.charm.service_port,
|
||||||
|
"server_name": self.charm.server_name,
|
||||||
"debug": config["debug"],
|
"debug": config["debug"],
|
||||||
"token_expiration": 3600, # 1 hour
|
"token_expiration": 3600, # 1 hour
|
||||||
"allow_expired_window": 169200, # 2 days - 1 hour
|
"allow_expired_window": 169200, # 2 days - 1 hour
|
||||||
@@ -488,9 +515,8 @@ class OAuthRequiresHandler(_BaseIDPHandler):
|
|||||||
ctxt = {
|
ctxt = {
|
||||||
"oidc_crypto_passphrase": oidc_secret,
|
"oidc_crypto_passphrase": oidc_secret,
|
||||||
"oidc_providers": provider_info,
|
"oidc_providers": provider_info,
|
||||||
"redirect_uri": self.oidc_redirect_uri,
|
"oidc_redirect_uri": self.oidc_redirect_uri,
|
||||||
"redirect_uri_path": urlparse(self.oidc_redirect_uri).path,
|
"oidc_redirect_uri_path": urlparse(self.oidc_redirect_uri).path,
|
||||||
"public_url_path": urlparse(self.charm.public_endpoint).path,
|
|
||||||
}
|
}
|
||||||
return ctxt
|
return ctxt
|
||||||
|
|
||||||
@@ -501,6 +527,165 @@ class OAuthRequiresHandler(_BaseIDPHandler):
|
|||||||
return False
|
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):
|
class ExternalIDPRequiresHandler(_BaseIDPHandler):
|
||||||
"""Handler for external-idp relation."""
|
"""Handler for external-idp relation."""
|
||||||
|
|
||||||
@@ -637,8 +822,8 @@ class ExternalIDPRequiresHandler(_BaseIDPHandler):
|
|||||||
return {
|
return {
|
||||||
"oidc_providers": providers,
|
"oidc_providers": providers,
|
||||||
"oidc_crypto_passphrase": oidc_secret,
|
"oidc_crypto_passphrase": oidc_secret,
|
||||||
"redirect_uri": self.oidc_redirect_uri,
|
"oidc_redirect_uri": self.oidc_redirect_uri,
|
||||||
"redirect_uri_path": urlparse(self.oidc_redirect_uri).path,
|
"oidc_redirect_uri_path": urlparse(self.oidc_redirect_uri).path,
|
||||||
"public_url_path": urlparse(self.charm.public_endpoint).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"
|
RECEIVE_CA_CERT_RELATION_NAME = "receive-ca-cert"
|
||||||
TRUSTED_DASHBOARD = "trusted-dashboard"
|
TRUSTED_DASHBOARD = "trusted-dashboard"
|
||||||
EXTERNAL_IDP = "external-idp"
|
EXTERNAL_IDP = "external-idp"
|
||||||
|
KEYSTONE_SAML = "keystone-saml"
|
||||||
|
|
||||||
def __init__(self, framework):
|
def __init__(self, framework):
|
||||||
super().__init__(framework)
|
super().__init__(framework)
|
||||||
@@ -789,12 +975,20 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
self._list_ca_certs_action,
|
self._list_ca_certs_action,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.framework.observe(
|
||||||
|
self.on.secret_changed,
|
||||||
|
self.configure_charm,
|
||||||
|
)
|
||||||
|
|
||||||
def merged_fid_contexts(self):
|
def merged_fid_contexts(self):
|
||||||
"""Create a merged context from oauth and external_idp."""
|
"""Create a merged context from oauth and external_idp."""
|
||||||
oidc_ctx = self.oauth.context()
|
oidc_ctx = self.oauth.context()
|
||||||
external_idp_ctx = self.external_idp.context()
|
external_idp_ctx = self.external_idp.context()
|
||||||
|
saml_ctx = self.keystone_saml.context()
|
||||||
|
|
||||||
ctx = {
|
ctx = {
|
||||||
"oidc_providers": [],
|
"oidc_providers": [],
|
||||||
|
"saml_providers": [],
|
||||||
}
|
}
|
||||||
if oidc_ctx:
|
if oidc_ctx:
|
||||||
ctx.update(oidc_ctx)
|
ctx.update(oidc_ctx)
|
||||||
@@ -803,6 +997,11 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
if providers:
|
if providers:
|
||||||
ctx["oidc_providers"].extend(providers)
|
ctx["oidc_providers"].extend(providers)
|
||||||
ctx.update(external_idp_ctx)
|
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
|
return ctx
|
||||||
|
|
||||||
def _handle_trusted_dashboard_changed(self, event: RelationChangedEvent):
|
def _handle_trusted_dashboard_changed(self, event: RelationChangedEvent):
|
||||||
@@ -818,7 +1017,8 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
return
|
return
|
||||||
oauth_providers = self.oauth.get_oidc_providers()
|
oauth_providers = self.oauth.get_oidc_providers()
|
||||||
external_providers = self.external_idp.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")
|
logger.debug("No OAuth relations found, skipping update")
|
||||||
return
|
return
|
||||||
data = {"federated-providers": []}
|
data = {"federated-providers": []}
|
||||||
@@ -830,13 +1030,18 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
data["federated-providers"].extend(
|
data["federated-providers"].extend(
|
||||||
external_providers.get("federated-providers", [])
|
external_providers.get("federated-providers", [])
|
||||||
)
|
)
|
||||||
|
if saml_providers:
|
||||||
|
data["federated-providers"].extend(
|
||||||
|
saml_providers.get("federated-providers", [])
|
||||||
|
)
|
||||||
if not data["federated-providers"]:
|
if not data["federated-providers"]:
|
||||||
return
|
return
|
||||||
self.trusted_dashboard.set_requirer_info(data)
|
self.trusted_dashboard.set_requirer_info(data)
|
||||||
|
|
||||||
def _handle_oauth_info_changed(self, event: RelationChangedEvent):
|
def _handle_fid_providers_changed(self, event: RelationChangedEvent):
|
||||||
"""Handle OAuth info changed event."""
|
"""Handle federated providers info changed event."""
|
||||||
self._handle_update_trusted_dashboard(event)
|
self._handle_update_trusted_dashboard(event)
|
||||||
|
self.keystone_saml.set_requirer_info(event)
|
||||||
self.configure_charm(event)
|
self.configure_charm(event)
|
||||||
|
|
||||||
def _retrieve_or_set_secret(
|
def _retrieve_or_set_secret(
|
||||||
@@ -920,16 +1125,27 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
|
|
||||||
return "\n".join(combined)
|
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."""
|
"""Get CA bundles from oauth relations."""
|
||||||
ca_certs = []
|
ca_certs = []
|
||||||
all_provider_info = self.oauth.get_all_provider_info()
|
oauth_provider_info = self.oauth.get_all_provider_info()
|
||||||
for provider in all_provider_info:
|
external_idp_info = self.external_idp.get_all_provider_info()
|
||||||
provider_info = provider.get("info", None)
|
saml_provider_info = self.keystone_saml.interface.get_providers()
|
||||||
if not provider_info:
|
for provider in oauth_provider_info:
|
||||||
|
ca_chain = provider.get("ca_chain", [])
|
||||||
|
if not ca_chain:
|
||||||
continue
|
continue
|
||||||
if provider_info.ca_chain:
|
ca_certs.extend(ca_chain)
|
||||||
ca_certs.extend(provider_info.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
|
return ca_certs
|
||||||
|
|
||||||
def sync_oidc_providers(self):
|
def sync_oidc_providers(self):
|
||||||
@@ -945,6 +1161,89 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
|||||||
self.keystone_manager.setup_oidc_metadata_folder()
|
self.keystone_manager.setup_oidc_metadata_folder()
|
||||||
self.keystone_manager.write_oidc_metadata(files)
|
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):
|
def get_oidc_secret(self):
|
||||||
"""Get the OIDC secret from the peers relation."""
|
"""Get the OIDC secret from the peers relation."""
|
||||||
oidc_secret_id = self.peers.get_app_data("oidc-crypto-passphrase")
|
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 = OAuthRequiresHandler(
|
||||||
self,
|
self,
|
||||||
OAUTH,
|
OAUTH,
|
||||||
self._handle_oauth_info_changed,
|
self._handle_fid_providers_changed,
|
||||||
)
|
)
|
||||||
handlers.append(self.oauth)
|
handlers.append(self.oauth)
|
||||||
if self.can_add_handler(self.EXTERNAL_IDP, handlers):
|
if self.can_add_handler(self.EXTERNAL_IDP, handlers):
|
||||||
self.external_idp = ExternalIDPRequiresHandler(
|
self.external_idp = ExternalIDPRequiresHandler(
|
||||||
self,
|
self,
|
||||||
self.EXTERNAL_IDP,
|
self.EXTERNAL_IDP,
|
||||||
self._handle_oauth_info_changed,
|
self._handle_fid_providers_changed,
|
||||||
)
|
)
|
||||||
handlers.append(self.external_idp)
|
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)
|
return super().get_relation_handlers(handlers)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -2059,6 +2366,25 @@ export OS_AUTH_VERSION=3
|
|||||||
|
|
||||||
return self.internal_endpoint
|
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
|
@property
|
||||||
def healthcheck_http_url(self) -> str:
|
def healthcheck_http_url(self) -> str:
|
||||||
"""Healthcheck HTTP URL for the service."""
|
"""Healthcheck HTTP URL for the service."""
|
||||||
@@ -2243,6 +2569,7 @@ export OS_AUTH_VERSION=3
|
|||||||
pre_update_fernet_ready = self.unit_fernet_bootstrapped()
|
pre_update_fernet_ready = self.unit_fernet_bootstrapped()
|
||||||
self.update_fernet_keys_from_peer()
|
self.update_fernet_keys_from_peer()
|
||||||
self.keystone_manager.write_combined_ca()
|
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
|
# If the wsgi service was running with no tokens it will be in a
|
||||||
# wedged state so restart it.
|
# wedged state so restart it.
|
||||||
if self.unit_fernet_bootstrapped() and not pre_update_fernet_ready:
|
if self.unit_fernet_bootstrapped() and not pre_update_fernet_ready:
|
||||||
|
@@ -8,9 +8,9 @@
|
|||||||
OIDCSessionType client-cookie:persistent
|
OIDCSessionType client-cookie:persistent
|
||||||
OIDCCryptoPassphrase {{ fid.oidc_crypto_passphrase }}
|
OIDCCryptoPassphrase {{ fid.oidc_crypto_passphrase }}
|
||||||
OIDCMetadataDir /etc/apache2/oidc-metadata
|
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
|
AuthType auth-openidc
|
||||||
Require valid-user
|
Require valid-user
|
||||||
</Location>
|
</Location>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
Require claim iss:{{provider.issuer_url}}
|
Require claim iss:{{provider.issuer_url}}
|
||||||
</RequireAll>
|
</RequireAll>
|
||||||
|
|
||||||
OIDCDiscoverURL {{ fid.redirect_uri }}?iss={{provider.encoded_issuer_url}}
|
OIDCDiscoverURL {{ fid.oidc_redirect_uri }}?iss={{provider.encoded_issuer_url}}
|
||||||
OIDCUnAuthAction auth true
|
OIDCUnAuthAction auth true
|
||||||
OIDCUnAutzAction auth true
|
OIDCUnAutzAction auth true
|
||||||
</Location>
|
</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 }}
|
Listen 0.0.0.0:{{ ks_config.public_port }}
|
||||||
|
|
||||||
<VirtualHost *:{{ 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
|
WSGIDaemonProcess keystone-public processes=4 threads=1 user=keystone group=keystone display-name=%{GROUP} python-path=/usr/lib/python3/site-packages
|
||||||
WSGIProcessGroup keystone-public
|
WSGIProcessGroup keystone-public
|
||||||
{% if ingress_internal and ingress_internal.ingress_path -%}
|
{% if ingress_internal and ingress_internal.ingress_path -%}
|
||||||
@@ -26,4 +29,5 @@ Listen 0.0.0.0:{{ ks_config.public_port }}
|
|||||||
</IfVersion>
|
</IfVersion>
|
||||||
</Directory>
|
</Directory>
|
||||||
{% include "apache2-oidc-params" %}
|
{% include "apache2-oidc-params" %}
|
||||||
|
{% include "apache2-saml-params" %}
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
|
@@ -29,10 +29,28 @@ from cryptography import (
|
|||||||
from cryptography.exceptions import (
|
from cryptography.exceptions import (
|
||||||
InvalidSignature,
|
InvalidSignature,
|
||||||
)
|
)
|
||||||
|
from cryptography.hazmat.backends import (
|
||||||
|
default_backend,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives import (
|
||||||
|
serialization,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def certificate_is_valid(certificate: bytes) -> bool:
|
||||||
"""Returns whether a certificate is valid.
|
"""Returns whether a certificate is valid.
|
||||||
|
|
||||||
|
@@ -45,6 +45,10 @@ _OIDC_METADATA_FOLDER = "/etc/apache2/oidc-metadata"
|
|||||||
_KEYSTONE_COMBINED_CA = (
|
_KEYSTONE_COMBINED_CA = (
|
||||||
"/usr/local/share/ca-certificates/keystone-combined.crt"
|
"/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:
|
class KeystoneManager:
|
||||||
@@ -135,13 +139,19 @@ class KeystoneManager:
|
|||||||
self._credential_setup()
|
self._credential_setup()
|
||||||
self._bootstrap()
|
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):
|
def setup_oidc_metadata_folder(self):
|
||||||
"""Create the OIDC metadata folder and set permissions."""
|
"""Create the OIDC metadata folder and set permissions."""
|
||||||
self.run_cmd(["sudo", "mkdir", "-p", _OIDC_METADATA_FOLDER])
|
self._ensure_metadata_folder(_OIDC_METADATA_FOLDER)
|
||||||
self.run_cmd(
|
|
||||||
["sudo", "chown", "keystone:www-data", _OIDC_METADATA_FOLDER]
|
def setup_saml2_metadata_folder(self):
|
||||||
)
|
"""Create the SAML2 metadata folder and set permissions."""
|
||||||
self.run_cmd(["sudo", "chmod", "550", _OIDC_METADATA_FOLDER])
|
self._ensure_metadata_folder(SAML_METADATA_FOLDER)
|
||||||
|
self._ensure_metadata_folder(SAML_PROVIDER_FOLDER)
|
||||||
|
|
||||||
def rotate_fernet_keys(self):
|
def rotate_fernet_keys(self):
|
||||||
"""Rotate the fernet keys.
|
"""Rotate the fernet keys.
|
||||||
@@ -205,7 +215,7 @@ class KeystoneManager:
|
|||||||
def write_combined_ca(self) -> None:
|
def write_combined_ca(self) -> None:
|
||||||
"""Write the combined CA to the container."""
|
"""Write the combined CA to the container."""
|
||||||
ca_contents = self.charm.get_ca_and_chain()
|
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)
|
container = self.charm.unit.get_container(self.container_name)
|
||||||
if not ca_contents and not oauth_ca_certs:
|
if not ca_contents and not oauth_ca_certs:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -231,12 +241,13 @@ class KeystoneManager:
|
|||||||
)
|
)
|
||||||
self.run_cmd(["sudo", "update-ca-certificates", "--fresh"])
|
self.run_cmd(["sudo", "update-ca-certificates", "--fresh"])
|
||||||
|
|
||||||
def write_oidc_metadata(self, metadata: Mapping[str, str]) -> None:
|
def _write_metadata_files(
|
||||||
"""Write the OIDC metadata to the container."""
|
self, metadata: Mapping[str, str], meta_folder: str
|
||||||
|
) -> None:
|
||||||
container = self.charm.unit.get_container(self.container_name)
|
container = self.charm.unit.get_container(self.container_name)
|
||||||
for filename, contents in metadata.items():
|
for filename, contents in metadata.items():
|
||||||
container.push(
|
container.push(
|
||||||
f"{_OIDC_METADATA_FOLDER}/{filename}",
|
f"{meta_folder}/{filename}",
|
||||||
contents,
|
contents,
|
||||||
user="keystone",
|
user="keystone",
|
||||||
group="www-data",
|
group="www-data",
|
||||||
@@ -244,11 +255,47 @@ class KeystoneManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# remove old metadata files
|
# remove old metadata files
|
||||||
files = container.list_files(_OIDC_METADATA_FOLDER)
|
files = container.list_files(meta_folder)
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.name not in metadata:
|
if file.name not in metadata:
|
||||||
container.remove_path(file.path)
|
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]:
|
def read_keys(self, key_repository: str) -> Mapping[str, str]:
|
||||||
"""Pull the fernet keys from the on-disk repository."""
|
"""Pull the fernet keys from the on-disk repository."""
|
||||||
container = self.charm.unit.get_container(self.container_name)
|
container = self.charm.unit.get_container(self.container_name)
|
||||||
|
@@ -92,6 +92,26 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
|
|||||||
)
|
)
|
||||||
return rel_id
|
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:
|
def add_id_relation(self) -> int:
|
||||||
"""Add amqp relation."""
|
"""Add amqp relation."""
|
||||||
rel_id = self.harness.add_relation("identity-service", "cinder")
|
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):
|
def test_sync_oidc_providers(self):
|
||||||
"""Tests that OIDC provider metadata is written to disk."""
|
"""Tests that OIDC provider metadata is written to disk."""
|
||||||
secret_mock = MagicMock()
|
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
|
credential-keys: 5M
|
||||||
resources:
|
resources:
|
||||||
keystone-image: ghcr.io/canonical/keystone:2025.1
|
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:
|
horizon:
|
||||||
{% if horizon_k8s is defined and horizon_k8s is sameas true -%}
|
{% if horizon_k8s is defined and horizon_k8s is sameas true -%}
|
||||||
charm: ../../../horizon-k8s.charm
|
charm: ../../../horizon-k8s.charm
|
||||||
@@ -75,4 +91,6 @@ relations:
|
|||||||
- horizon:ingress-internal
|
- horizon:ingress-internal
|
||||||
- - keystone:send-ca-cert
|
- - keystone:send-ca-cert
|
||||||
- horizon:receive-ca-cert
|
- horizon:receive-ca-cert
|
||||||
|
- - keystone:keystone-saml
|
||||||
|
- keystone-saml:keystone-saml
|
||||||
|
|
||||||
|
@@ -48,6 +48,9 @@ target_deploy_status:
|
|||||||
keystone:
|
keystone:
|
||||||
workload-status: active
|
workload-status: active
|
||||||
workload-status-message-regex: '^$'
|
workload-status-message-regex: '^$'
|
||||||
|
keystone-saml:
|
||||||
|
workload-status: blocked
|
||||||
|
workload-status-message-regex: '^Failed to get IDP metadata$'
|
||||||
glance:
|
glance:
|
||||||
workload-status: active
|
workload-status: active
|
||||||
workload-status-message-regex: '^$'
|
workload-status-message-regex: '^$'
|
||||||
|
@@ -310,6 +310,18 @@
|
|||||||
- rebuild
|
- rebuild
|
||||||
vars:
|
vars:
|
||||||
charm: keystone-ldap-k8s
|
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:
|
- job:
|
||||||
name: charm-build-openstack-exporter-k8s
|
name: charm-build-openstack-exporter-k8s
|
||||||
description: Build sunbeam openstack-exporter-k8s charm
|
description: Build sunbeam openstack-exporter-k8s charm
|
||||||
@@ -593,14 +605,18 @@
|
|||||||
soft: true
|
soft: true
|
||||||
- name: charm-build-horizon-k8s
|
- name: charm-build-horizon-k8s
|
||||||
soft: true
|
soft: true
|
||||||
|
- name: charm-build-keystone-saml-k8s
|
||||||
|
soft: true
|
||||||
files:
|
files:
|
||||||
- ops-sunbeam/ops_sunbeam/
|
- ops-sunbeam/ops_sunbeam/
|
||||||
- charms/horizon-k8s/
|
- charms/horizon-k8s/
|
||||||
- charms/keystone-k8s/
|
- charms/keystone-k8s/
|
||||||
|
- charms/keystone-saml-k8s/
|
||||||
- rebuild
|
- rebuild
|
||||||
- zuul.d/zuul.yaml
|
- zuul.d/zuul.yaml
|
||||||
vars:
|
vars:
|
||||||
charm_jobs:
|
charm_jobs:
|
||||||
|
- charm-build-keystone-saml-k8s
|
||||||
- charm-build-keystone-k8s
|
- charm-build-keystone-k8s
|
||||||
- charm-build-horizon-k8s
|
- charm-build-horizon-k8s
|
||||||
test_dir: tests/identity
|
test_dir: tests/identity
|
||||||
|
@@ -90,6 +90,8 @@
|
|||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-keystone-ldap-k8s:
|
- charm-build-keystone-ldap-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
|
- charm-build-keystone-saml-k8s:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-openstack-exporter-k8s:
|
- charm-build-openstack-exporter-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-openstack-hypervisor:
|
- charm-build-openstack-hypervisor:
|
||||||
@@ -159,6 +161,8 @@
|
|||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-keystone-ldap-k8s:
|
- charm-build-keystone-ldap-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
|
- charm-build-keystone-saml-k8s:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-openstack-exporter-k8s:
|
- charm-build-openstack-exporter-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-openstack-hypervisor:
|
- charm-build-openstack-hypervisor:
|
||||||
|
@@ -45,6 +45,7 @@
|
|||||||
magnum-k8s: 2025.1/edge
|
magnum-k8s: 2025.1/edge
|
||||||
masakari-k8s: 2025.1/edge
|
masakari-k8s: 2025.1/edge
|
||||||
keystone-ldap-k8s: 2025.1/edge
|
keystone-ldap-k8s: 2025.1/edge
|
||||||
|
keystone-saml-k8s: 2025.1/edge
|
||||||
openstack-exporter-k8s: 2025.1/edge
|
openstack-exporter-k8s: 2025.1/edge
|
||||||
openstack-hypervisor: 2025.1/edge
|
openstack-hypervisor: 2025.1/edge
|
||||||
openstack-images-sync-k8s: 2025.1/edge
|
openstack-images-sync-k8s: 2025.1/edge
|
||||||
|
Reference in New Issue
Block a user