From d728458afa1dca1ca4bbf59541ce248e78f26bbc Mon Sep 17 00:00:00 2001 From: Ionut Balutoiu Date: Fri, 8 Jan 2021 15:59:28 +0000 Subject: [PATCH] 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. --- .../charm_tests/saml_mellon/setup.py | 121 +++++++++-- .../charm_tests/saml_mellon/tests.py | 199 ++++++++++++++++++ zaza/openstack/utilities/generic.py | 26 +++ 3 files changed, 329 insertions(+), 17 deletions(-) diff --git a/zaza/openstack/charm_tests/saml_mellon/setup.py b/zaza/openstack/charm_tests/saml_mellon/setup.py index 7e5f96c..20b8cbd 100644 --- a/zaza/openstack/charm_tests/saml_mellon/setup.py +++ b/zaza/openstack/charm_tests/saml_mellon/setup.py @@ -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) diff --git a/zaza/openstack/charm_tests/saml_mellon/tests.py b/zaza/openstack/charm_tests/saml_mellon/tests.py index 9b5b211..962fa92 100644 --- a/zaza/openstack/charm_tests/saml_mellon/tests.py +++ b/zaza/openstack/charm_tests/saml_mellon/tests.py @@ -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 = ''.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") diff --git a/zaza/openstack/utilities/generic.py b/zaza/openstack/utilities/generic.py index dd8b1e0..fcd7989 100644 --- a/zaza/openstack/utilities/generic.py +++ b/zaza/openstack/utilities/generic.py @@ -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)