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)