Add multi-backend testing for Keystone SAML Mellon
The new updated tests rely on new testing bundles deployed with two local IdPs via the Juju charm https://jaas.ai/u/ionutbalutoiu/test-saml-idp.
This commit is contained in:
@@ -25,6 +25,7 @@ from zaza.openstack.utilities import (
|
||||
cert as cert_utils,
|
||||
cli as cli_utils,
|
||||
openstack as openstack_utils,
|
||||
generic as generic_utils,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,8 +35,8 @@ FEDERATED_DOMAIN = "federated_domain"
|
||||
FEDERATED_GROUP = "federated_users"
|
||||
MEMBER = "Member"
|
||||
IDP = "samltest"
|
||||
LOCAL_IDP_REMOTE_ID = "http://{}/simplesaml/saml2/idp/metadata.php"
|
||||
REMOTE_ID = "https://samltest.id/saml/idp"
|
||||
MAP_NAME = "{}_mapping".format(IDP)
|
||||
PROTOCOL_NAME = "mapped"
|
||||
MAP_TEMPLATE = '''
|
||||
[{{
|
||||
@@ -45,7 +46,7 @@ MAP_TEMPLATE = '''
|
||||
"name": "{{0}}"
|
||||
}},
|
||||
"group": {{
|
||||
"name": "federated_users",
|
||||
"name": "{group_id}",
|
||||
"domain": {{
|
||||
"id": "{domain_id}"
|
||||
}}
|
||||
@@ -55,7 +56,7 @@ MAP_TEMPLATE = '''
|
||||
"name": "{{0}}_project",
|
||||
"roles": [
|
||||
{{
|
||||
"name": "Member"
|
||||
"name": "{role_name}"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
@@ -81,7 +82,10 @@ SP_SIGNING_KEY_INFO_XML_TEMPLATE = '''
|
||||
'''
|
||||
|
||||
|
||||
def keystone_federation_setup():
|
||||
def keystone_federation_setup(federated_domain=FEDERATED_DOMAIN,
|
||||
federated_group=FEDERATED_GROUP,
|
||||
idp_name=IDP,
|
||||
idp_remote_id=REMOTE_ID):
|
||||
"""Configure Keystone Federation."""
|
||||
cli_utils.setup_logging()
|
||||
keystone_session = openstack_utils.get_overcloud_keystone_session()
|
||||
@@ -89,19 +93,19 @@ def keystone_federation_setup():
|
||||
keystone_session)
|
||||
|
||||
try:
|
||||
domain = keystone_client.domains.find(name=FEDERATED_DOMAIN)
|
||||
domain = keystone_client.domains.find(name=federated_domain)
|
||||
except keystoneauth1.exceptions.http.NotFound:
|
||||
domain = keystone_client.domains.create(
|
||||
FEDERATED_DOMAIN,
|
||||
federated_domain,
|
||||
description="Federated Domain",
|
||||
enabled=True)
|
||||
|
||||
try:
|
||||
group = keystone_client.groups.find(
|
||||
name=FEDERATED_GROUP, domain=domain)
|
||||
name=federated_group, domain=domain)
|
||||
except keystoneauth1.exceptions.http.NotFound:
|
||||
group = keystone_client.groups.create(
|
||||
FEDERATED_GROUP,
|
||||
federated_group,
|
||||
domain=domain,
|
||||
enabled=True)
|
||||
|
||||
@@ -109,30 +113,33 @@ def keystone_federation_setup():
|
||||
keystone_client.roles.grant(role, group=group, domain=domain)
|
||||
|
||||
try:
|
||||
idp = keystone_client.federation.identity_providers.find(
|
||||
name=IDP, domain_id=domain.id)
|
||||
idp = keystone_client.federation.identity_providers.get(idp_name)
|
||||
except keystoneauth1.exceptions.http.NotFound:
|
||||
idp = keystone_client.federation.identity_providers.create(
|
||||
IDP,
|
||||
remote_ids=[REMOTE_ID],
|
||||
idp_name,
|
||||
remote_ids=[idp_remote_id],
|
||||
domain_id=domain.id,
|
||||
enabled=True)
|
||||
|
||||
JSON_RULES = json.loads(MAP_TEMPLATE.format(domain_id=domain.id))
|
||||
JSON_RULES = json.loads(MAP_TEMPLATE.format(
|
||||
domain_id=domain.id, group_id=group.id, role_name=MEMBER))
|
||||
|
||||
map_name = "{}_mapping".format(idp_name)
|
||||
try:
|
||||
keystone_client.federation.mappings.find(name=MAP_NAME)
|
||||
keystone_client.federation.mappings.get(map_name)
|
||||
except keystoneauth1.exceptions.http.NotFound:
|
||||
keystone_client.federation.mappings.create(
|
||||
MAP_NAME, rules=JSON_RULES)
|
||||
map_name, rules=JSON_RULES)
|
||||
|
||||
try:
|
||||
keystone_client.federation.protocols.get(IDP, PROTOCOL_NAME)
|
||||
keystone_client.federation.protocols.get(idp_name, PROTOCOL_NAME)
|
||||
except keystoneauth1.exceptions.http.NotFound:
|
||||
keystone_client.federation.protocols.create(
|
||||
PROTOCOL_NAME, mapping=MAP_NAME, identity_provider=idp)
|
||||
PROTOCOL_NAME, mapping=map_name, identity_provider=idp)
|
||||
|
||||
|
||||
# This setup method is deprecated. It will be removed once we fully drop the
|
||||
# `samltest.id` dependency.
|
||||
def attach_saml_resources(application="keystone-saml-mellon"):
|
||||
"""Attach resource to the Keystone SAML Mellon charm."""
|
||||
test_idp_metadata_xml = "samltest.xml"
|
||||
@@ -161,3 +168,83 @@ def attach_saml_resources(application="keystone-saml-mellon"):
|
||||
fp.flush()
|
||||
zaza.model.attach_resource(
|
||||
application, sp_signing_keyinfo_name, fp.name)
|
||||
|
||||
|
||||
def _attach_saml_resources_local_idp(keystone_saml_mellon_app_name=None,
|
||||
test_saml_idp_app_name=None):
|
||||
"""Attach resources to the Keystone SAML Mellon and the local IdP."""
|
||||
action_result = zaza.model.run_action_on_leader(
|
||||
test_saml_idp_app_name, 'get-idp-metadata')
|
||||
idp_metadata = action_result.data['results']['output']
|
||||
|
||||
generic_utils.attach_file_resource(
|
||||
keystone_saml_mellon_app_name,
|
||||
'idp-metadata',
|
||||
idp_metadata,
|
||||
'.xml')
|
||||
|
||||
(key, cert) = cert_utils.generate_cert('SP Signing Key')
|
||||
|
||||
cert = cert.decode().strip("-----BEGIN CERTIFICATE-----")
|
||||
cert = cert.strip("-----END CERTIFICATE-----")
|
||||
|
||||
generic_utils.attach_file_resource(
|
||||
keystone_saml_mellon_app_name,
|
||||
'sp-private-key',
|
||||
key.decode(),
|
||||
'.pem')
|
||||
generic_utils.attach_file_resource(
|
||||
keystone_saml_mellon_app_name,
|
||||
'sp-signing-keyinfo',
|
||||
SP_SIGNING_KEY_INFO_XML_TEMPLATE.format(cert),
|
||||
'.xml')
|
||||
|
||||
action_result = zaza.model.run_action_on_leader(
|
||||
keystone_saml_mellon_app_name, 'get-sp-metadata')
|
||||
sp_metadata = action_result.data['results']['output']
|
||||
|
||||
generic_utils.attach_file_resource(
|
||||
test_saml_idp_app_name,
|
||||
'sp-metadata',
|
||||
sp_metadata,
|
||||
'.xml')
|
||||
|
||||
|
||||
def attach_saml_resources_idp1():
|
||||
"""Attach the SAML resources for the local IdP #1."""
|
||||
_attach_saml_resources_local_idp(
|
||||
keystone_saml_mellon_app_name="keystone-saml-mellon1",
|
||||
test_saml_idp_app_name="test-saml-idp1")
|
||||
|
||||
|
||||
def attach_saml_resources_idp2():
|
||||
"""Attach the SAML resources for the local IdP #2."""
|
||||
_attach_saml_resources_local_idp(
|
||||
keystone_saml_mellon_app_name="keystone-saml-mellon2",
|
||||
test_saml_idp_app_name="test-saml-idp2")
|
||||
|
||||
|
||||
def keystone_federation_setup_idp1():
|
||||
"""Configure Keystone Federation for the local IdP #1."""
|
||||
test_saml_idp_unit = zaza.model.get_units("test-saml-idp1")[0]
|
||||
idp_remote_id = LOCAL_IDP_REMOTE_ID.format(
|
||||
test_saml_idp_unit.public_address)
|
||||
|
||||
keystone_federation_setup(
|
||||
federated_domain="federated_domain_idp1",
|
||||
federated_group="federated_users_idp1",
|
||||
idp_name="test-saml-idp1",
|
||||
idp_remote_id=idp_remote_id)
|
||||
|
||||
|
||||
def keystone_federation_setup_idp2():
|
||||
"""Configure Keystone Federation for the local IdP #2."""
|
||||
test_saml_idp_unit = zaza.model.get_units("test-saml-idp2")[0]
|
||||
idp_remote_id = LOCAL_IDP_REMOTE_ID.format(
|
||||
test_saml_idp_unit.public_address)
|
||||
|
||||
keystone_federation_setup(
|
||||
federated_domain="federated_domain_idp2",
|
||||
federated_group="federated_users_idp2",
|
||||
idp_name="test-saml-idp2",
|
||||
idp_remote_id=idp_remote_id)
|
||||
|
||||
@@ -30,6 +30,8 @@ class FailedToReachIDP(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# This testing class is deprecated. It will be removed once we fully drop the
|
||||
# `samltest.id` dependency.
|
||||
class CharmKeystoneSAMLMellonTest(BaseKeystoneTest):
|
||||
"""Charm Keystone SAML Mellon tests."""
|
||||
|
||||
@@ -156,3 +158,200 @@ class CharmKeystoneSAMLMellonTest(BaseKeystoneTest):
|
||||
# We may need to try/except to allow horizon to build its pages
|
||||
_do_redirect_check(url, region, idp_expect, horizon_expect)
|
||||
logging.info("SUCCESS")
|
||||
|
||||
|
||||
class BaseCharmKeystoneSAMLMellonTest(BaseKeystoneTest):
|
||||
"""Charm Keystone SAML Mellon tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls,
|
||||
application_name="keystone-saml-mellon",
|
||||
test_saml_idp_app_name="test-saml-idp",
|
||||
horizon_idp_option_name="myidp_mapped",
|
||||
horizon_idp_display_name="myidp via mapped"):
|
||||
"""Run class setup for running Keystone SAML Mellon charm tests."""
|
||||
super(BaseCharmKeystoneSAMLMellonTest, cls).setUpClass()
|
||||
# Note: The BaseKeystoneTest class sets the application_name to
|
||||
# "keystone" which breaks keystone-saml-mellon actions. Explicitly set
|
||||
# application name here.
|
||||
cls.application_name = application_name
|
||||
cls.test_saml_idp_app_name = test_saml_idp_app_name
|
||||
cls.horizon_idp_option_name = horizon_idp_option_name
|
||||
cls.horizon_idp_display_name = horizon_idp_display_name
|
||||
cls.action = "get-sp-metadata"
|
||||
cls.current_release = openstack_utils.get_os_release()
|
||||
cls.FOCAL_USSURI = openstack_utils.get_os_release("focal_ussuri")
|
||||
|
||||
@staticmethod
|
||||
def check_horizon_redirect(horizon_url, horizon_expect,
|
||||
horizon_idp_option_name, horizon_region,
|
||||
idp_url, idp_expect):
|
||||
"""Validate the Horizon -> Keystone -> IDP redirects.
|
||||
|
||||
This validation is done through `requests.session()`, and the proper
|
||||
get / post http calls.
|
||||
|
||||
:param horizon_url: The login page for the Horizon OpenStack dashboard.
|
||||
:type horizon_url: string
|
||||
:param horizon_expect: Information that needs to be displayed by
|
||||
Horizon login page, when there is a proper
|
||||
SAML IdP configuration.
|
||||
:type horizon_expect: string
|
||||
:param horizon_idp_option_name: The name of the IdP that is chosen
|
||||
in the Horizon dropdown from the login
|
||||
screen. This will go in the post body
|
||||
as 'auth_type'.
|
||||
:type horizon_idp_option_name: string
|
||||
:param horizon_region: Information needed to complete the http post
|
||||
data for the Horizon login.
|
||||
:type horizon_region: string
|
||||
:param idp_url: The url for the IdP where the user needs to be
|
||||
redirected.
|
||||
:type idp_url: string
|
||||
:param idp_expect: Information that needs to be displayed by the IdP
|
||||
after the user is redirected there.
|
||||
:type idp_expect: string
|
||||
:returns: None
|
||||
"""
|
||||
# start session, get csrftoken
|
||||
client = requests.session()
|
||||
# Verify=False see note below
|
||||
login_page = client.get(horizon_url, verify=False)
|
||||
|
||||
# Validate SAML method is available
|
||||
assert horizon_expect in login_page.text
|
||||
|
||||
# Get cookie
|
||||
if "csrftoken" in client.cookies:
|
||||
csrftoken = client.cookies["csrftoken"]
|
||||
else:
|
||||
raise Exception("Missing csrftoken")
|
||||
|
||||
# Build and send post request
|
||||
form_data = {
|
||||
"auth_type": horizon_idp_option_name,
|
||||
"csrfmiddlewaretoken": csrftoken,
|
||||
"next": "/horizon/project/api_access",
|
||||
"region": horizon_region,
|
||||
}
|
||||
|
||||
# Verify=False due to CA certificate bundles.
|
||||
# If we don't set it validation fails for keystone/horizon
|
||||
# We would have to install the keystone CA onto the system
|
||||
# to validate end to end.
|
||||
response = client.post(
|
||||
horizon_url,
|
||||
data=form_data,
|
||||
headers={"Referer": horizon_url},
|
||||
allow_redirects=True,
|
||||
verify=False)
|
||||
|
||||
if idp_expect not in response.text:
|
||||
msg = "FAILURE code={} text={}".format(response, response.text)
|
||||
# Raise a custom exception.
|
||||
raise FailedToReachIDP(msg)
|
||||
|
||||
# Validate that we were redirected to the proper IdP
|
||||
assert response.url.startswith(idp_url)
|
||||
assert idp_url in response.text
|
||||
|
||||
def test_run_get_sp_metadata_action(self):
|
||||
"""Validate the get-sp-metadata action."""
|
||||
unit = zaza.model.get_units(self.application_name)[0]
|
||||
ip = self.vip if self.vip else unit.public_address
|
||||
|
||||
action = zaza.model.run_action(unit.entity_id, self.action)
|
||||
self.assertNotIn(
|
||||
"failed",
|
||||
action.data["status"],
|
||||
msg="The action failed: {}".format(action.data["message"]))
|
||||
|
||||
output = action.data["results"]["output"]
|
||||
root = etree.fromstring(output)
|
||||
for item in root.items():
|
||||
if "entityID" in item[0]:
|
||||
self.assertIn(ip, item[1])
|
||||
|
||||
for appt in root.getchildren():
|
||||
for elem in appt.getchildren():
|
||||
for item in elem.items():
|
||||
if "Location" in item[0]:
|
||||
self.assertIn(ip, item[1])
|
||||
|
||||
logging.info("Successul get-sp-metadata action")
|
||||
|
||||
def test_saml_mellon_redirects(self):
|
||||
"""Validate the horizon -> keystone -> IDP redirects."""
|
||||
unit = zaza.model.get_units(self.application_name)[0]
|
||||
keystone_ip = self.vip if self.vip else unit.public_address
|
||||
|
||||
horizon = "openstack-dashboard"
|
||||
horizon_config = zaza.model.get_application_config(horizon)
|
||||
horizon_vip = horizon_config.get("vip").get("value")
|
||||
unit = zaza.model.get_units("openstack-dashboard")[0]
|
||||
|
||||
horizon_ip = horizon_vip if horizon_vip else unit.public_address
|
||||
proto = "https" if self.tls_rid else "http"
|
||||
|
||||
# Use Keystone URL for < Focal
|
||||
if self.current_release < self.FOCAL_USSURI:
|
||||
region = "{}://{}:5000/v3".format(proto, keystone_ip)
|
||||
else:
|
||||
region = "default"
|
||||
|
||||
idp_address = zaza.model.get_units(
|
||||
self.test_saml_idp_app_name)[0].public_address
|
||||
|
||||
horizon_url = "{}://{}/horizon/auth/login/".format(proto, horizon_ip)
|
||||
horizon_expect = '<option value="{0}">{1}</option>'.format(
|
||||
self.horizon_idp_option_name, self.horizon_idp_display_name)
|
||||
idp_url = ("http://{0}/simplesaml/"
|
||||
"module.php/core/loginuserpass.php").format(idp_address)
|
||||
# This is the message the local test-saml-idp displays after you are
|
||||
# redirected. It shows we have been directed to:
|
||||
# horizon -> keystone -> test-saml-idp
|
||||
idp_expect = (
|
||||
"A service has requested you to authenticate yourself. Please "
|
||||
"enter your username and password in the form below.")
|
||||
|
||||
# Execute the check
|
||||
BaseCharmKeystoneSAMLMellonTest.check_horizon_redirect(
|
||||
horizon_url=horizon_url,
|
||||
horizon_expect=horizon_expect,
|
||||
horizon_idp_option_name=self.horizon_idp_option_name,
|
||||
horizon_region=region,
|
||||
idp_url=idp_url,
|
||||
idp_expect=idp_expect)
|
||||
logging.info("SUCCESS")
|
||||
|
||||
|
||||
class CharmKeystoneSAMLMellonIDP1Test(BaseCharmKeystoneSAMLMellonTest):
|
||||
"""Charm Keystone SAML Mellon tests class for the local IDP #1."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Run class setup for running Keystone SAML Mellon charm tests.
|
||||
|
||||
It does the necessary setup for the local IDP #1.
|
||||
"""
|
||||
super(CharmKeystoneSAMLMellonIDP1Test, cls).setUpClass(
|
||||
application_name="keystone-saml-mellon1",
|
||||
test_saml_idp_app_name="test-saml-idp1",
|
||||
horizon_idp_option_name="test-saml-idp1_mapped",
|
||||
horizon_idp_display_name="Test SAML IDP #1")
|
||||
|
||||
|
||||
class CharmKeystoneSAMLMellonIDP2Test(BaseCharmKeystoneSAMLMellonTest):
|
||||
"""Charm Keystone SAML Mellon tests class for the local IDP #2."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Run class setup for running Keystone SAML Mellon charm tests.
|
||||
|
||||
It does the necessary setup for the local IDP #2.
|
||||
"""
|
||||
super(CharmKeystoneSAMLMellonIDP2Test, cls).setUpClass(
|
||||
application_name="keystone-saml-mellon2",
|
||||
test_saml_idp_app_name="test-saml-idp2",
|
||||
horizon_idp_option_name="test-saml-idp2_mapped",
|
||||
horizon_idp_display_name="Test SAML IDP #2")
|
||||
|
||||
@@ -20,6 +20,7 @@ import os
|
||||
import socket
|
||||
import subprocess
|
||||
import telnetlib
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
from zaza import model
|
||||
@@ -680,3 +681,28 @@ def get_mojo_cacert_path():
|
||||
return cacert
|
||||
else:
|
||||
raise zaza_exceptions.CACERTNotFound("Could not find cacert.pem")
|
||||
|
||||
|
||||
def attach_file_resource(application_name, resource_name,
|
||||
file_content, file_suffix=".txt"):
|
||||
"""Attaches a file as a Juju resource given the file content and suffix.
|
||||
|
||||
The file content will be written into a temporary file with the given
|
||||
suffix, and it will be attached to the Juju application.
|
||||
|
||||
:param application_name: Juju application name.
|
||||
:type application_name: string
|
||||
:param resource_name: Juju resource name.
|
||||
:type resource_name: string
|
||||
:param file_content: The content of the file that will be attached
|
||||
:type file_content: string
|
||||
:param file_suffix: File suffix. This should be used to set the file
|
||||
extension for applications that are sensitive to this.
|
||||
:type file_suffix: string
|
||||
:returns: None
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix=file_suffix) as fp:
|
||||
fp.write(file_content)
|
||||
fp.flush()
|
||||
model.attach_resource(
|
||||
application_name, resource_name, fp.name)
|
||||
|
||||
Reference in New Issue
Block a user