diff --git a/unit_tests/test_zaza_model.py b/unit_tests/test_zaza_model.py index 13aea99..85760fd 100644 --- a/unit_tests/test_zaza_model.py +++ b/unit_tests/test_zaza_model.py @@ -1099,6 +1099,18 @@ disk_formats = ami,ari,aki,vhd,vmdk,raw,qcow2,vdi,iso,root-tar ["juju", "set-series", "-m", self.model_name, _application, _to_series]) + def test_attach_resource(self): + self.patch_object(model, 'get_juju_model', + return_value=self.model_name) + self.patch_object(model, 'subprocess') + _application = "application" + _resource_name = "myresource" + _resource_path = "/path/to/{}.tar.gz".format(_resource_name) + model.attach_resource(_application, _resource_name, _resource_path) + self.subprocess.check_call.assert_called_once_with( + ["juju", "attach-resource", "-m", self.model_name, + _application, "{}={}".format(_resource_name, _resource_path)]) + class AsyncModelTests(aiounittest.AsyncTestCase): diff --git a/zaza/charm_tests/saml_mellon/__init__.py b/zaza/charm_tests/saml_mellon/__init__.py new file mode 100644 index 0000000..8697739 --- /dev/null +++ b/zaza/charm_tests/saml_mellon/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2019 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. +"""Collection of code for setting up and testing keystone-saml-mellon.""" diff --git a/zaza/charm_tests/saml_mellon/setup.py b/zaza/charm_tests/saml_mellon/setup.py new file mode 100644 index 0000000..e1184d5 --- /dev/null +++ b/zaza/charm_tests/saml_mellon/setup.py @@ -0,0 +1,160 @@ +# Copyright 2019 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. + +"""Code for setting up keystone federation.""" + +import json +import keystoneauth1 +import os +import tempfile + +import zaza.charm_lifecycle.utils as charm_lifecycle_utils +import zaza.model +from zaza.utilities import ( + cert as cert_utils, + cli as cli_utils, + openstack as openstack_utils, +) + + +APP_NAME = "keystone-saml-mellon" + +FEDERATED_DOMAIN = "federated_domain" +FEDERATED_GROUP = "federated_users" +MEMBER = "Member" +IDP = "samltest" +REMOTE_ID = "https://samltest.id/saml/idp" +MAP_NAME = "{}_mapping".format(IDP) +PROTOCOL_NAME = "mapped" +MAP_TEMPLATE = ''' + [{{ + "local": [ + {{ + "user": {{ + "name": "{{0}}" + }}, + "group": {{ + "name": "federated_users", + "domain": {{ + "id": "{domain_id}" + }} + }}, + "projects": [ + {{ + "name": "{{0}}_project", + "roles": [ + {{ + "name": "Member" + }} + ] + }} + ] + }} + ], + "remote": [ + {{ + "type": "MELLON_NAME_ID" + }} + ] + }}] +''' + +SP_SIGNING_KEY_INFO_XML_TEMPLATE = ''' + + + + {} + + + +''' + + +def keystone_federation_setup(): + """Configure Keystone Federation.""" + cli_utils.setup_logging() + keystone_session = openstack_utils.get_overcloud_keystone_session() + keystone_client = openstack_utils.get_keystone_session_client( + keystone_session) + + try: + domain = keystone_client.domains.find(name=FEDERATED_DOMAIN) + except keystoneauth1.exceptions.http.NotFound: + domain = keystone_client.domains.create( + FEDERATED_DOMAIN, + description="Federated Domain", + enabled=True) + + try: + group = keystone_client.groups.find( + name=FEDERATED_GROUP, domain=domain) + except keystoneauth1.exceptions.http.NotFound: + group = keystone_client.groups.create( + FEDERATED_GROUP, + domain=domain, + enabled=True) + + role = keystone_client.roles.find(name=MEMBER) + keystone_client.roles.grant(role, group=group, domain=domain) + + try: + idp = keystone_client.federation.identity_providers.find( + name=IDP, domain_id=domain.id) + except keystoneauth1.exceptions.http.NotFound: + idp = keystone_client.federation.identity_providers.create( + IDP, + remote_ids=[REMOTE_ID], + domain_id=domain.id, + enabled=True) + + JSON_RULES = json.loads(MAP_TEMPLATE.format(domain_id=domain.id)) + + try: + keystone_client.federation.mappings.find(name=MAP_NAME) + except keystoneauth1.exceptions.http.NotFound: + keystone_client.federation.mappings.create( + MAP_NAME, rules=JSON_RULES) + + try: + keystone_client.federation.protocols.get(IDP, PROTOCOL_NAME) + except keystoneauth1.exceptions.http.NotFound: + keystone_client.federation.protocols.create( + PROTOCOL_NAME, mapping=MAP_NAME, identity_provider=idp) + + +def attach_saml_resources(application="keystone-saml-mellon"): + """Attach resource to the Keystone SAML Mellon charm.""" + test_idp_metadata_xml = "samltest.xml" + idp_metadata_xml_file = os.path.join( + charm_lifecycle_utils.BUNDLE_DIR, test_idp_metadata_xml) + + idp_metadata_name = "idp-metadata" + sp_private_key_name = "sp-private-key" + sp_signing_keyinfo_name = "sp-signing-keyinfo" + + zaza.model.attach_resource( + application, idp_metadata_name, idp_metadata_xml_file) + + (key, cert) = cert_utils.generate_cert('SP Signing Key') + + with tempfile.NamedTemporaryFile(mode='w', suffix='.pem') as fp: + fp.write(key.decode()) + fp.flush() + zaza.model.attach_resource(application, sp_private_key_name, fp.name) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.xml') as fp: + fp.write(SP_SIGNING_KEY_INFO_XML_TEMPLATE.format(key.decode())) + fp.flush() + zaza.model.attach_resource( + application, sp_signing_keyinfo_name, fp.name) diff --git a/zaza/charm_tests/saml_mellon/tests.py b/zaza/charm_tests/saml_mellon/tests.py new file mode 100644 index 0000000..c1551e9 --- /dev/null +++ b/zaza/charm_tests/saml_mellon/tests.py @@ -0,0 +1,144 @@ +# Copyright 2018 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. + +"""Keystone SAML Mellon Testing.""" + +import logging +from lxml import etree +import requests + +import zaza.model +from zaza.charm_tests.keystone import BaseKeystoneTest + + +class FailedToReachIDP(Exception): + """Custom Exception for failing to reach the IDP.""" + + pass + + +class CharmKeystoneSAMLMellonTest(BaseKeystoneTest): + """Charm Keystone SAML Mellon tests.""" + + @classmethod + def setUpClass(cls): + """Run class setup for running Keystone SAML Mellon charm tests.""" + super(CharmKeystoneSAMLMellonTest, cls).setUpClass() + cls.action = "get-sp-metadata" + + def test_run_get_sp_metadata_action(self): + """Validate the get-sp-metadata action.""" + if self.vip: + ip = self.vip + else: + unit = zaza.model.get_units(self.application_name)[0] + ip = unit.public_address + + action = zaza.model.run_action(unit.entity_id, self.action) + if "failed" in action.data["status"]: + raise Exception( + "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]: + assert ip in item[1] + + for appt in root.getchildren(): + for elem in appt.getchildren(): + for item in elem.items(): + if "Location" in item[0]: + assert ip in item[1] + + logging.info("Successul get-sp-metadata action") + + def test_saml_mellon_redirects(self): + """Validate the horizon -> keystone -> IDP redirects.""" + if self.vip: + keystone_ip = self.vip + else: + unit = zaza.model.get_units(self.application_name)[0] + keystone_ip = unit.public_address + + horizon = "openstack-dashboard" + horizon_vip = (zaza.model.get_application_config(horizon) + .get("vip").get("value")) + if horizon_vip: + horizon_ip = horizon_vip + else: + unit = zaza.model.get_units("openstack-dashboard")[0] + horizon_ip = unit.public_address + + if self.tls_rid: + proto = "https" + else: + proto = "http" + + url = "{}://{}/horizon/auth/login/".format(proto, horizon_ip) + region = "{}://{}:5000/v3".format(proto, keystone_ip) + horizon_expect = ('') + + # This is the message samltest.id gives when it has not had + # SP XML uploaded. It still shows we have been directed to: + # horizon -> keystone -> samltest.id + idp_expect = ("The application you have accessed is not registered " + "for use with this service.") + + def _do_redirect_check(url, region, idp_expect, horizon_expect): + + # start session, get csrftoken + client = requests.session() + # Verify=False see note below + login_page = client.get(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": "samltest_mapped", + "csrfmiddlewaretoken": csrftoken, + "next": "/horizon/project/api_access", + "region": region, + } + + # Verify=False due to CA certificate bundles. + # If we point to the CA for keystone/horizon they work but + # samltest.id does not. + # 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( + url, data=form_data, + headers={"Referer": 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) + + # Execute the check + # 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") diff --git a/zaza/model.py b/zaza/model.py index 86ff130..c5d8f76 100644 --- a/zaza/model.py +++ b/zaza/model.py @@ -1341,3 +1341,21 @@ def set_series(application, to_series): cmd = ["juju", "set-series", "-m", juju_model, application, to_series] subprocess.check_call(cmd) + + +def attach_resource(application, resource_name, resource_path): + """Attach resource to charm. + + :param application: Application to get leader settings from. + :type application: str + :param resource_name: The name of the resource as defined in metadata.yaml + :type resource_name: str + :param resource_path: The path to the resource on disk + :type resource_path: str + :returns: None + :rtype: None + """ + juju_model = get_juju_model() + cmd = ["juju", "attach-resource", "-m", juju_model, + application, "{}={}".format(resource_name, resource_path)] + subprocess.check_call(cmd)