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:
Ionut Balutoiu
2021-01-08 15:59:28 +00:00
parent c8ccfc66c8
commit d728458afa
3 changed files with 329 additions and 17 deletions

View File

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

View File

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

View File

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