Add openid connect zaza tests

This change adds a few zaza tests to ensure that the new identity provider
workflow works as expected. We test only against canonical identity platform
due to challenges of testing against public IDPs.

Change-Id: I9c9b364d387c1fc7ade279a230da3c70754edf83
Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira
2025-07-29 13:03:59 +03:00
parent ccd9791a91
commit 22b821bcba
6 changed files with 654 additions and 8 deletions

View File

@@ -44,11 +44,13 @@ print(f"Using context: {context}")
for test_dir in test_directories:
bundle_dir = f"tests/{test_dir}"
template_loader = Environment(loader=FileSystemLoader(bundle_dir))
bundle_template = template_loader.get_template("smoke.yaml.j2")
smoke_file = Path(f"{bundle_dir}/bundles/smoke.yaml")
smoke_file.parent.mkdir(parents=True, exist_ok=True)
with smoke_file.open("w", encoding="utf-8") as content:
templates = [pth.name for pth in Path(bundle_dir).glob('*.yaml.j2')]
for tpl in templates:
bundle_template = template_loader.get_template(tpl)
bundle_file = Path(f"{bundle_dir}/bundles/{tpl[:-3]}")
bundle_file.parent.mkdir(parents=True, exist_ok=True)
with bundle_file.open("w", encoding="utf-8") as content:
content.write(bundle_template.render(context))
print(f"Rendered smoke bundle: {smoke_file}")
with smoke_file.open("r", encoding="utf-8") as content:
print(f"Rendered bundle: {bundle_file}")
with bundle_file.open("r", encoding="utf-8") as content:
print(content.read())

View File

@@ -0,0 +1,64 @@
# This is a modified version of the 0.3/edge bundle published by
# the canonical identity team.
bundle: kubernetes
applications:
hydra:
charm: hydra
channel: latest/edge
base: ubuntu@22.04/stable
scale: 1
trust: true
kratos:
charm: kratos
channel: latest/edge
base: ubuntu@22.04/stable
scale: 1
trust: true
identity-platform-login-ui-operator:
charm: identity-platform-login-ui-operator
channel: latest/edge
base: ubuntu@22.04/stable
scale: 1
trust: true
postgresql-k8s:
charm: postgresql-k8s
base: ubuntu@22.04/stable
channel: 14/stable
scale: 1
trust: true
options:
plugin_pg_trgm_enable: true
plugin_btree_gin_enable: true
self-signed-certificates:
charm: self-signed-certificates
base: ubuntu@22.04/stable
channel: latest/stable
scale: 1
traefik-admin:
charm: traefik-k8s
base: ubuntu@20.04/stable
channel: latest/candidate
scale: 1
trust: true
traefik-public:
charm: traefik-k8s
channel: latest/candidate
base: ubuntu@20.04/stable
scale: 1
trust: true
relations:
- [hydra:pg-database, postgresql-k8s:database]
- [kratos:pg-database, postgresql-k8s:database]
- [kratos:hydra-endpoint-info, hydra:hydra-endpoint-info]
- [hydra:admin-ingress, traefik-admin:ingress]
- [hydra:public-ingress, traefik-public:ingress]
- [kratos:admin-ingress, traefik-admin:ingress]
- [kratos:public-ingress, traefik-public:ingress]
- [identity-platform-login-ui-operator:ingress, traefik-public:ingress]
- [identity-platform-login-ui-operator:hydra-endpoint-info, hydra:hydra-endpoint-info]
- [identity-platform-login-ui-operator:ui-endpoint-info, hydra:ui-endpoint-info]
- [identity-platform-login-ui-operator:ui-endpoint-info, kratos:ui-endpoint-info]
- [identity-platform-login-ui-operator:kratos-info, kratos:kratos-info]
- [traefik-admin:certificates, self-signed-certificates:certificates]
- [traefik-public:certificates, self-signed-certificates:certificates]

View File

@@ -0,0 +1,78 @@
bundle: kubernetes
applications:
traefik:
charm: ch:traefik-k8s
channel: latest/candidate
base: ubuntu@20.04
scale: 1
trust: true
options:
kubernetes-service-annotations: metallb.universe.tf/address-pool=public
mysql:
charm: ch:mysql-k8s
channel: 8.0/stable
base: ubuntu@22.04
scale: 1
trust: true
options:
profile-limit-memory: 2560
experimental-max-connections: 150
tls-operator:
charm: self-signed-certificates
channel: latest/beta
base: ubuntu@22.04
scale: 1
options:
ca-common-name: internal-ca
keystone:
{% if keystone_k8s is defined and keystone_k8s is sameas true -%}
charm: ../../../keystone-k8s.charm
{% else -%}
charm: ch:keystone-k8s
channel: 2025.1/edge
{% endif -%}
base: ubuntu@24.04
scale: 1
trust: true
storage:
fernet-keys: 5M
credential-keys: 5M
resources:
keystone-image: ghcr.io/canonical/keystone:2025.1
horizon:
{% if horizon_k8s is defined and horizon_k8s is sameas true -%}
charm: ../../../horizon-k8s.charm
{% else -%}
charm: ch:horizon-k8s
channel: 2025.1/edge
{% endif -%}
base: ubuntu@24.04
scale: 1
trust: true
resources:
horizon-image: ghcr.io/canonical/horizon:2025.1
relations:
- - mysql:database
- keystone:database
- - traefik:ingress
- keystone:ingress-internal
- - keystone:trusted-dashboard
- horizon:trusted-dashboard
- - tls-operator
- keystone
- - tls-operator
- horizon
- - traefik:certificates
- tls-operator:certificates
- - mysql:database
- horizon:database
- - keystone:identity-credentials
- horizon:identity-credentials
- - traefik:ingress
- horizon:ingress-internal
- - keystone:send-ca-cert
- horizon:receive-ca-cert

83
tests/identity/tests.yaml Normal file
View File

@@ -0,0 +1,83 @@
gate_bundles:
- iam:
- openstack: smoke
- iam: iam
smoke_bundles:
- iam:
- openstack: smoke
- iam: iam
configure:
- openstack:
- zaza.sunbeam.charm_tests.identity.identity.create_oauth_and_cert_offers
tests:
- openstack:
- zaza.sunbeam.charm_tests.identity.identity.IdentityTests
tests_options:
trust:
- smoke
- iam
ignore_hard_deploy_errors:
- smoke
- iam
target_deploy_status:
traefik:
workload-status: active
workload-status-message-regex: '^Serving at.*$'
traefik-public:
workload-status: active
workload-status-message-regex: '^Serving at.*$'
traefik-admin:
workload-status: active
workload-status-message-regex: '^Serving at.*$'
mysql:
workload-status: active
workload-status-message-regex: '^.*$'
tls-operator:
workload-status: active
workload-status-message-regex: '^$'
rabbitmq:
workload-status: active
workload-status-message-regex: '^$'
ovn-central:
workload-status: active
workload-status-message-regex: '^$'
ovn-relay:
workload-status: active
workload-status-message-regex: '^$'
keystone:
workload-status: active
workload-status-message-regex: '^$'
glance:
workload-status: active
workload-status-message-regex: '^$'
nova:
workload-status: active
workload-status-message-regex: '^$'
placement:
workload-status: active
workload-status-message-regex: '^$'
neutron:
workload-status: active
workload-status-message-regex: '^$'
openstack-images-sync:
workload-status: active
workload-status-message-regex: '^$'
hydra:
workload-status: active
workload-status-message-regex: '^$'
kratos:
workload-status: active
workload-status-message-regex: '^$'
postgresql-k8s:
workload-status: active
workload-status-message-regex: '^Primary.*$'
self-signed-certificates:
workload-status: active
workload-status-message-regex: '^$'
identity-platform-login-ui-operator:
workload-status: active
workload-status-message-regex: '^$'
horizon:
workload-status: active
workload-status-message-regex: '^$'

View File

@@ -0,0 +1,419 @@
# Copyright (c) 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.
import logging
import json
import yaml
import subprocess
import tempfile
from keystoneauth1.identity import v3
from keystoneauth1 import session
from keystoneclient.v3 import client as keystoneclient
import zaza
import zaza.model as model
from zaza.notifications import notify_around, NotifyEvents
import zaza.openstack.charm_tests.test_utils as test_utils
from zaza.openstack.utilities import openstack as openstack_utils
_FEDERATED_GROUP_NAME = "federated_users"
_FEDERATED_PROJECT_NAME = "federated_project"
_FEDERATED_DOMAIN_NAME = "canonical-iam"
_FEDERATED_IDENTITY_PROVIDER_NAME = "canonical-identity-platform"
_OPENID_PROTOCOL_NAME = "openid"
_IAM_MAPPING_RULES = """
[
{
"local": [
{
"user": {
"name": "{0}"
},
"group": {
"domain": {
"name": "%(domain_name)s"
},
"name": "%(group_name)s"
}
}
],
"remote": [
{
"type": "REMOTE_USER"
}
]
}
]
"""
def _get_unit_rel_info(unit):
command = ['juju', 'show-unit', '--format=json', unit]
output = subprocess.check_output(command).decode()
unit_info = json.loads(output)
rel_info = unit_info.get(unit, {}).get("relation-info", [])
return rel_info
def _get_issuer_url():
issuer_url = None
rel_info = _get_unit_rel_info("keystone/0")
for rel in rel_info:
if rel.get("endpoint") == "oauth" and rel.get("cross-model"):
app_data = rel.get("application-data")
if not app_data:
raise Exception(
"could not find application-data for "
"keystone oauth endpoint"
)
issuer_url = app_data.get("issuer_url")
break
if not issuer_url:
raise Exception("failed to find oauth issuer_url")
return issuer_url
class SetupOffersAndRelations(object):
application_name = "keystone"
oauth_app_name = "hydra"
oauth_cert_app_name = "self-signed-certificates"
iam_saas_name = _FEDERATED_IDENTITY_PROVIDER_NAME
iam_saas_cert_name = "iam-cert"
openstack_model_name = "openstack"
iam_model_name = "iam"
def __init__(self):
self.model_aliases = model.get_juju_model_aliases()
iam_model = self.model_aliases.get(self.iam_model_name, None)
if not iam_model:
raise ValueError("could not get 'iam' model alias")
self.model_name = self.model_aliases.get(
self.openstack_model_name, None)
if not self.model_name:
raise ValueError("could not get 'openstack' model alias")
self.iam_model = zaza.sync_wrapper(model.get_model)(
model_name=iam_model)
self.openstack_model = zaza.sync_wrapper(model.get_model)(
model_name=self.model_name)
self.openstack_model = zaza.sync_wrapper(model.get_model)(
model_name=self.model_name)
auth = openstack_utils.get_overcloud_auth()
self.keystone_client = openstack_utils.get_keystone_client(auth)
def _get_offer_url(self, application, endpoint):
offers = zaza.sync_wrapper(
self.iam_model.list_offers)()
results = offers.get("results", [])
for offer in results:
if offer.application_name == application:
for ep in offer.endpoints:
if ep.name == endpoint:
return offer.offer_url
return ""
def ensure_offers(self):
self.oauth_offer_url = self._get_offer_url(
self.oauth_app_name,
"oauth",
)
self.oauth_cert_offer_url = self._get_offer_url(
self.oauth_cert_app_name,
"send-ca-cert",
)
if not self.oauth_offer_url:
zaza.sync_wrapper(self.iam_model.create_offer)(
f"{self.oauth_app_name}:oauth",
application_name=self.oauth_app_name,
)
self.oauth_offer_url = self._get_offer_url(
self.oauth_app_name,
"oauth",
)
if not self.oauth_cert_offer_url:
zaza.sync_wrapper(self.iam_model.create_offer)(
f"{self.oauth_cert_app_name}:send-ca-cert",
application_name=self.oauth_cert_app_name,
)
self.oauth_cert_offer_url = self._get_offer_url(
self.oauth_cert_app_name,
"send-ca-cert",
)
def _wait_for_settle(self):
zaza.model.wait_for_agent_status(
model_name=self.openstack_model.name
)
logging.info("Waiting for {} to settle".format(
self.openstack_model_name))
with notify_around(NotifyEvents.WAIT_MODEL_SETTLE,
model_name=self.openstack_model.name):
zaza.model.block_until_all_units_idle(
model_name=self.openstack_model.name)
logging.info("Model {} has settled".format(
self.openstack_model_name))
def _get_role_by_name(self, domain, name):
roles = self.keystone_client.roles.list(
domain=domain,
name=name,
)
if len(roles) == 0:
raise ValueError("could not find role named %s", name)
return roles[0].id
def create_federated_domain(self):
issuer_url = _get_issuer_url()
domain = self.keystone_client.domains.create(
name=_FEDERATED_DOMAIN_NAME,
description="Domain used for federated users",
enabled=True
)
project = self.keystone_client.projects.create(
name=_FEDERATED_PROJECT_NAME,
domain=domain,
description="federated project",
enabled=True,
)
group = self.keystone_client.groups.create(
name=_FEDERATED_GROUP_NAME,
domain=domain,
description="federated users group",
)
self.keystone_client.roles.grant(
role=self._get_role_by_name(domain, "member"),
group=group,
project=project,
)
rules = _IAM_MAPPING_RULES % {
"domain_name": domain.name,
"group_name": group.name,
}
mapping = self.keystone_client.federation.mappings.create(
mapping_id="openid_mapping",
rules=json.loads(rules),
)
prov = self.keystone_client.federation.identity_providers.create(
id=_FEDERATED_IDENTITY_PROVIDER_NAME,
remote_ids=[issuer_url,],
domain_id=domain.id,
enabled=True
)
protocol = self.keystone_client.federation.protocols.create(
protocol_id=_OPENID_PROTOCOL_NAME,
identity_provider=prov,
mapping=mapping,
)
def ensure_integrations(self):
status = zaza.sync_wrapper(
self.openstack_model.get_status
)()
iam_saas = status.remote_applications.get(
self.iam_saas_name, None
)
iam_certs = status.remote_applications.get(
self.iam_saas_cert_name, None
)
oauth_relations = iam_saas.relations.get("oauth", [])
cert_relations = iam_certs.relations.get("send-ca-cert", [])
if self.application_name not in cert_relations:
zaza.sync_wrapper(self.openstack_model.integrate)(
f"{self.iam_saas_cert_name}:send-ca-cert",
f"{self.application_name}:receive-ca-cert"
)
self._wait_for_settle()
if self.application_name not in oauth_relations:
zaza.sync_wrapper(self.openstack_model.integrate)(
f"{self.iam_saas_name}:oauth",
f"{self.application_name}:oauth"
)
self._wait_for_settle()
def ensure_offers_consumed(self):
status = zaza.sync_wrapper(
self.openstack_model.get_status
)()
iam_saas = status.remote_applications.get(
self.iam_saas_name, None
)
iam_certs = status.remote_applications.get(
self.iam_saas_cert_name, None
)
if not iam_certs:
zaza.sync_wrapper(self.openstack_model.consume)(
self.oauth_cert_offer_url,
application_alias=self.iam_saas_cert_name,
)
if not iam_saas:
zaza.sync_wrapper(self.openstack_model.consume)(
self.oauth_offer_url, application_alias=self.iam_saas_name
)
def create_oauth_and_cert_offers():
setup = SetupOffersAndRelations()
setup.ensure_offers()
setup.ensure_offers_consumed()
setup.ensure_integrations()
setup.create_federated_domain()
class IdentityTests(test_utils.BaseCharmTest):
application_name = "keystone"
ca_file = None
@classmethod
def setUpClass(cls):
super(IdentityTests, cls).setUpClass(
application_name=cls.application_name)
cls.iam_model_name = cls.model_aliases.get("iam", None)
if not cls.iam_model_name:
raise ValueError("could not get 'iam' model alias")
cls.iam_model = zaza.sync_wrapper(model.get_model)(
model_name=cls.iam_model_name)
auth_data = openstack_utils.get_overcloud_auth()
cls.keystone_client = openstack_utils.get_keystone_client(auth_data)
cls.admin_account = cls._get_admin_account(cls)
@property
def _ca_cert(self):
if self.ca_file:
return self.ca_file
rel_info = _get_unit_rel_info("keystone/0")
ca_and_chain = []
for rel in rel_info:
ep = rel.get("endpoint")
if ep == "receive-ca-cert":
for unit, unit_data in rel.get("related-units", {}).items():
data = unit_data.get("data", {})
ca = data.get("ca")
if ca:
ca_and_chain.append(ca)
chain = json.loads(data.get("chain", '[]'))
if chain:
data.extend(chain)
self.ca_file = tempfile.NamedTemporaryFile(delete=False).name
with open(self.ca_file, "w") as fd:
fd.write("\n".join(ca_and_chain))
return self.ca_file
def _create_client_creds(self):
result = model.run_action_on_leader(
"hydra",
"create-oauth-client",
model_name=self.iam_model_name,
action_params={
"grant-types": ["authorization_code", "client_credentials"],
"response-types": ["id_token", "code", "token"],
"scope": ["openid", "email", "profile"],
},
raise_on_failure=True,
)
data = result.data
client_id = data.get("results", {}).get("client-id")
client_secret = data.get("results", {}).get("client-secret")
self.assertTrue(None not in [client_id, client_secret])
return {
"client_id": client_id,
"client_secret": client_secret
}
def _get_admin_account(self):
result = model.run_action_on_leader(
"keystone",
"get-admin-account",
model_name=self.model_name,
raise_on_failure=True,
)
results = result.data.get("results", {})
return results
def test_oauth_client_creds(self):
issuer_url = _get_issuer_url()
creds = self._create_client_creds()
discovery_ep = "%s/.well-known/openid-configuration" % issuer_url
auth = v3.OidcClientCredentials(
scope="openid email profile",
client_id=creds["client_id"],
client_secret=creds["client_secret"],
identity_provider=_FEDERATED_IDENTITY_PROVIDER_NAME,
protocol=_OPENID_PROTOCOL_NAME,
discovery_endpoint=discovery_ep,
auth_url=self.admin_account["public-endpoint"],
project_domain_name=_FEDERATED_DOMAIN_NAME,
project_name=_FEDERATED_PROJECT_NAME
)
ks_session = session.Session(auth=auth, verify=self._ca_cert)
ks_client = keystoneclient.Client(session=ks_session)
token = ks_session.get_token()
token_data = ks_client.tokens.get_token_data(token)
user_details = ks_client.users.get(user=token_data["token"]["user"]["id"])
self.assertEqual(user_details.id, token_data["token"]["user"]["id"])
def test_horizon_relations_created(self):
rel_info = _get_unit_rel_info("horizon/0")
trusted_dashboard_found = False
for rel in rel_info:
if rel.get("endpoint") == "trusted-dashboard":
rel_units = rel.get("related-units")
self.assertTrue(rel_units)
self.assertTrue(rel_units.get("keystone/0", False))
trusted_dashboard_found = True
app_data = rel.get("application-data", {})
fid_providers = app_data.get("federated-providers")
self.assertTrue(fid_providers)
data = json.loads(fid_providers)
self.assertTrue(len(data) > 0)
self.assertEqual(
data[0].get("name"),
_FEDERATED_IDENTITY_PROVIDER_NAME,
)
self.assertEqual(data[0].get("protocol"), _OPENID_PROTOCOL_NAME)
self.assertTrue(trusted_dashboard_found)
def test_keystone_relations_created(self):
rel_info = _get_unit_rel_info(self.lead_unit)
oauth_found = False
iam_certs_found = False
trusted_dashboard_found = False
for rel in rel_info:
if rel.get("endpoint") == "oauth" and rel.get("cross-model"):
rel_units = rel.get("related-units")
self.assertTrue(rel_units)
self.assertTrue(rel_units.get("canonical-identity-platform/0", False))
oauth_found = True
if rel.get("endpoint") == "receive-ca-cert" and rel.get("cross-model"):
rel_units = rel.get("related-units")
self.assertTrue(rel_units)
self.assertTrue(rel_units.get("iam-cert/0", False))
iam_certs_found = True
if rel.get("endpoint") == "trusted-dashboard":
rel_units = rel.get("related-units")
self.assertTrue(rel_units)
self.assertTrue(rel_units.get("horizon/0", False))
trusted_dashboard_found = True
self.assertTrue(oauth_found)
self.assertTrue(iam_certs_found)
self.assertTrue(trusted_dashboard_found)