commit acc262deeaf7f54c3f6e14aa01992f47d9ca250c Author: Dmitrii Shcherbakov Date: Fri Feb 16 01:41:55 2018 +0300 initial functional version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/rebuild b/rebuild new file mode 100644 index 0000000..f44176b --- /dev/null +++ b/rebuild @@ -0,0 +1,5 @@ +# This file is used to trigger rebuilds +# when dependencies of the charm change, +# but nothing in the charm needs to. +# simply change the uuid to something new +5572890c-916b-4ec7-a77b-a9e9f53471ae diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4215b08 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +pbr>=1.8.0,<1.9.0 +PyYAML>=3.1.0 +simplejson>=2.2.0 +netifaces>=0.10.4 +netaddr>=0.7.12,!=0.7.16 +Jinja2>=2.6 # BSD License (3 clause) +six>=1.9.0 +dnspython>=1.12.0 +psutil>=1.1.1,<2.0.0 +charm-tools diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..6047a0a --- /dev/null +++ b/src/README.md @@ -0,0 +1,194 @@ +# Overview + +This subordinate charm provides a way to integrate a SAML-based identity +provider with Keystone using Mellon Apache web server authentication +module (mod_auth_mellon) and lasso as its dependency. Mellon acts as a +Service Provider in this case and provides SAML token attributes as WSGI +environment variables to Keystone which does not itself participate in +SAML exchanges - it merely interprets results of such exchanges +and maps assertion-derived attributes to entities (such as groups, +roles, projects and domains) in a local Keystone SQL database. + +In general, any identity provider that conforms to SAML 2.0 will be +possible to integrate using this charm. + +The following documentation is useful to better understand the charm +implementation: + +* https://github.com/UNINETT/mod_auth_mellon/blob/master/doc/user_guide/mellon_user_guide.adoc +* https://github.com/UNINETT/mod_auth_mellon/blob/master/doc/user_guide/images/saml-web-sso.svg +* http://lasso.entrouvert.org/ +* https://www.oasis-open.org/standards#samlv2.0 + +# Usage + +Use this charm with the Keystone charm, running with preferred-api-version=3: + + juju deploy keystone + juju config keystone preferred-api-version=3 # other settings + juju deploy openstack-dashboard # settings + juju deploy keystone-saml-mellon + juju add-relation keystone keystone-saml-mellon + juju add-relation keystone openstack-dashboard + + +In a bundle: + +``` + applications: + # ... + keystone-saml-mellon: + charm: cs:~dmitriis/keystone-saml-mellon + num_units: 0 + options: + idp-name: 'myidp' + protocol-name: 'saml2' + user-facing-name: "myidp via saml2' + resources: + idp-metadata: "./FederationMetadata.xml" + sp-signing-keyinfo: "./sp-keyinfo.xml" + sp-private-key: "./mellon.pem" + relations: + # ... + - [ keystone, keystone-saml-mellon ] + - [ openstack-dashboard, keystone-saml-mellon ] + - [ "openstack-dashboard:websso-trusted-dashboard", "keystone:websso-trusted-dashboard" ] +``` + +# Prerequisites + +In order to use this charm, there are several prerequisites that need to be +taken into account which require certain infrastructure to be set up out of +band, namely: + +* PKI; +* DNS; +* NTP; +* idP. + +On the Keystone charm side, this means that ssl_ca, ssl_cert, ssl_key, +use-https and os-public-hostname must be set. + +Several key pairs can be used in a generic SAML exchange along with +certificates containing public keys. Besides the pairs used for message-level +signing and encryption there are also TLS certificates used for transport +layer encryption when a browser connects to a protected URL on the SP side or +when it gets redirected to an idP endpoint for authentication. In summary: + +* Service Provider (Keystone) TLS termination certificates, keys and CA; +* Service Provider signing and encryption private keys and associated + public keys (SAML-level); +* Identity Provider TLS termination certificates, keys and CA; +* Identity Provider signing and encryption private keys and associated public + keys (SAML-level). + +For a successful authentication to happen the following needs to hold: + +* A user agent (browser) needs to + * trust an issuer (CA) of TLS certificates of an SP used for HTTPS; + * trust an issuer (CA) TLS certificates of an idP used for HTTPS; + * be able to resolve domain names present in subject or subjAltName fields. +* An SP needs to: + * be able to verify signed SAML messages sent by an idP via + public keys contained in certificates provided in the idP's metadata XML + and, if SAML-level encryption is enabled, decrypt those messages; +* An idP needs to: + * be able to verify signed SAML messages sent by an SP via + public keys contained in certificates provided in the SP's metadata XML + and, if SAML-level encryption is enabled, decrypt those messages. + +Note that this does not mean that any actual checks are performed for +certificates related to SAML - only key material is used and there does +NOT have to be any PKI actually in-place, not even expiration times are +checked as per Mellon documentation. In that sense trust is very explicitly +defined by out of band mutual synchronization of SP and idP metadata files. +See SAML V2.0 Metadata Interoperability Profile (2.6.1) key processing +section for a normative reference. + +However, this does not mean that no PKI will be in place - TLS certificates +used for HTTPS connectivity have to be verifiable by the entities that use +them. With Redirect or POST binding this is mainly about user agent being +able to validate SP or idP certificates - there is no direct communication +between the two outside the metadata synchronization step which is performed +by an operator out of band. + +Additionally, for successful certificate verification clocks of all parties +need to be properly synchronized which is why it is important for NTP agents +to be able to reach proper NTP servers on SP and idP. + +# Post-deployment Configuration + +There are several post-deployment steps that have to be performed in order to +start using federated identity functionality in Keystone. They depend on the +chosen config values and also on an IDP configuration as it may put different +NameID values and attributes into SAML tokens. Token attributes are parsed by +mod_auth_mellon and are placed into WSGI environment which are used by +Keystone and they have the following format: "MELLON_" +(one attribute can have multiple values in SAML). Both NameID and attribute +values can be used in mappings to map SAML token content to existing and, in +case of projects, potentially non-existing entities in Keystone database. + +In order to take the above into account several objects need to be created: + +* a domain used for federated users; +* (optional) a project to be used by federated users; +* one or more groups to place federated users into; +* role assignments for the groups above; +* an identity provider object; +* a mapping of NameID and SAML token attributes to Keystone entities; +* a federation protocol object. + +``` + cat > rules.json < + + + + + + + + + image/svg+xml + + + + + + + SAML + + diff --git a/src/layer.yaml b/src/layer.yaml new file mode 100644 index 0000000..c7c3537 --- /dev/null +++ b/src/layer.yaml @@ -0,0 +1,7 @@ +includes: ['layer:openstack', 'layer:leadership', 'interface:keystone-fid-service-provider', 'interface:websso-fid-service-provider', 'interface:juju-info'] +options: + basic: + use_venv: True + include_system_packages: True + packages: ['python3-lxml', 'python3-cryptography'] + repo: https://github.com/dshcherb/charm-keystone-saml-mellon diff --git a/src/lib/charm/openstack/__init__.py b/src/lib/charm/openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/charm/openstack/keystone_saml_mellon.py b/src/lib/charm/openstack/keystone_saml_mellon.py new file mode 100644 index 0000000..01dd3c1 --- /dev/null +++ b/src/lib/charm/openstack/keystone_saml_mellon.py @@ -0,0 +1,344 @@ +# +# Copyright 2017 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. + +import charmhelpers.core as core +import charmhelpers.core.host as ch_host +import charmhelpers.core.hookenv as hookenv +import charmhelpers.core.unitdata as unitdata + +import charmhelpers.contrib.openstack.templating as os_templating +import charmhelpers.contrib.openstack.utils as os_utils + +import charms_openstack.charm +import charms_openstack.adapters + +import os +import subprocess + +from lxml import etree +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +# release detection is done via keystone package given that +# openstack-origin is not present in the subordinate charm +# see https://github.com/juju/charm-helpers/issues/83 +from charms_openstack.charm.core import ( + register_os_release_selector +) +OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version' + +CONFIGS = (IDP_METADATA, SP_METADATA, SP_PRIVATE_KEY, + SP_LOCATION_CONFIG,) = [ + os.path.join('/etc/apache2/mellon', + f.format(hookenv.service_name())) for f in [ + 'idp-meta.{}.xml', + 'sp-meta.{}.xml', + 'sp-pk.{}.pem', + 'sp-location.{}.conf']] + + +@register_os_release_selector +def select_release(): + """Determine the release based on the keystone package version. + + Note that this function caches the release after the first install so + that it doesn't need to keep going and getting it from the package + information. + """ + release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None) + if release_version is None: + release_version = os_utils.os_release('keystone') + unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version) + return release_version + + +class KeystoneSAMLMellonConfigurationAdapter( + charms_openstack.adapters.ConfigurationAdapter): + + def __init__(self, charm_instance=None): + super().__init__(charm_instance=charm_instance) + self._idp_metadata = None + self._sp_private_key = None + self._sp_signing_keyinfo = None + self._validation_errors = {} + + @property + def validation_errors(self): + return {k: v for k, v in + self._validation_errors.items() if v} + + @property + def idp_metadata_file(self): + return IDP_METADATA + + @property + def sp_metadata_file(self): + return SP_METADATA + + @property + def sp_private_key_file(self): + return SP_PRIVATE_KEY + + @property + def sp_location_config(self): + return SP_LOCATION_CONFIG + + @property + def keystone_host(self): + return unitdata.kv().get('hostname') + + @property + def keystone_port(self): + return unitdata.kv().get('port') + + @property + def tls_enabled(self): + return unitdata.kv().get('tls-enabled') + + @property + def keystone_base_url(self): + scheme = 'https' if self.tls_enabled else 'http' + return ('{}://{}:{}'.format(scheme, self.keystone_host, + self.keystone_port)) + + @property + def sp_idp_path(self): + return ('/v3/OS-FEDERATION/identity_providers/{}' + .format(self.idp_name)) + + @property + def sp_protocol_path(self): + return ('{}/protocols/{}' + .format(self.sp_idp_path, self.protocol_name)) + + @property + def sp_auth_path(self): + return '{}/auth'.format(self.sp_protocol_path) + + @property + def mellon_endpoint_path(self): + return '{}/mellon'.format(self.sp_auth_path) + + @property + def websso_auth_protocol_path(self): + return ('/v3/auth/OS-FEDERATION/websso/{}' + .format(self.protocol_name)) + + @property + def websso_auth_idp_protocol_path(self): + return ('/v3/auth/OS-FEDERATION/identity_providers' + '/{}/protocols/{}/websso'.format( + self.idp_name, + self.protocol_name + )) + + @property + def sp_post_response_path(self): + return '{}/postResponse'.format(self.mellon_endpoint_path) + + @property + def sp_auth_url(self): + return '{}{}'.format(self.keystone_base_url, + self.sp_auth_path) + + @property + def sp_logout_url(self): + return '{}/logout'.format(self.mellon_endpoint_path) + + @property + def sp_post_response_url(self): + return '{}{}'.format(self.keystone_base_url, + self.sp_post_response_path) + + @property + def mellon_subject_confirmation_data_address_check(self): + return ('On' if self.subject_confirmation_data_address_check + else 'Off') + + @property + def supported_nameid_formats(self): + return self.nameid_formats.split(',') + + IDP_METADATA_INVALID = ('idp-metadata resource is not a well-formed' + ' xml file') + + @property + def idp_metadata(self): + idp_metadata_path = hookenv.resource_get('idp-metadata') + if os.path.exists(idp_metadata_path) and not self._idp_metadata: + with open(idp_metadata_path) as f: + content = f.read() + try: + etree.fromstring(content) + self._idp_metadata = content + self._validation_errors['idp-metadata'] = None + except etree.XMLSyntaxError: + self._idp_metadata = '' + self._validation_errors['idp-metadata'] = ( + self.IDP_METADATA_INVALID) + return self._idp_metadata + + SP_SIGNING_KEYINFO_INVALID = ('sp-signing-keyinfo resource is not a' + ' well-formed xml file') + + @property + def sp_signing_keyinfo(self): + info_path = hookenv.resource_get('sp-signing-keyinfo') + if os.path.exists(info_path) and not self._sp_signing_keyinfo: + self._sp_signing_keyinfo = None + with open(info_path) as f: + content = f.read() + try: + etree.fromstring(content) + self._sp_signing_keyinfo = content + self._validation_errors['sp-signing-keyinfo'] = None + except etree.XMLSyntaxError: + self._sp_signing_keyinfo = '' + self._validation_errors['sp-signing-keyinfo'] = ( + self.SP_SIGNING_KEYINFO_INVALID) + return self._sp_signing_keyinfo + + SP_PRIVATE_KEY_INVALID = ('resource is not a well-formed' + ' RFC 5958 (PKCS#8) key') + + @property + def sp_private_key(self): + pk_path = hookenv.resource_get('sp-private-key') + if os.path.exists(pk_path) and not self._sp_private_key: + with open(pk_path) as f: + content = f.read() + try: + serialization.load_pem_private_key( + content.encode(), + password=None, + backend=default_backend() + ) + self._sp_private_key = content + self._validation_errors['sp-private-key'] = None + except ValueError: + self._sp_private_key = '' + self._validation_errors['sp-private-key'] = ( + self.SP_PRIVATE_KEY_INVALID) + return self._sp_private_key + + +class KeystoneSAMLMellonCharm(charms_openstack.charm.OpenStackCharm): + + # Internal name of charm + service_name = name = 'keystone-saml-mellon' + + # Package to derive application version from + version_package = 'keystone' + + # First release supported + release = 'mitaka' + + # List of packages to install for this charm + packages = ['libapache2-mod-auth-mellon'] + + configuration_class = KeystoneSAMLMellonConfigurationAdapter + + # render idP metadata provided out of band to establish + # SP -> idP trust. A domain name config parameter is evaluated at + # class definition time but this happens every event execution, + # including config-changed. Changing domain-name dynamically is not + # a real use-case anyway and it should be defined deployment time. + string_templates = { + IDP_METADATA: ('options', 'idp_metadata'), + SP_PRIVATE_KEY: ('options', 'sp_private_key'), + } + + def configuration_complete(self): + """Determine whether sufficient configuration has been provided + via charm config options and resources. + :returns: boolean indicating whether configuration is complete + """ + required_config = { + 'idp-name': self.options.idp_name, + 'protocol-name': self.options.protocol_name, + 'user-facing-name': self.options.user_facing_name, + 'idp-metadata': self.options.idp_metadata, + 'sp-private-key': self.options.sp_private_key, + 'sp-signing-keyinfo': self.options.sp_signing_keyinfo, + 'nameid-formats': self.options.nameid_formats, + } + + return all(required_config.values()) + + def assess_status(self): + """Determine the current application status for the charm""" + hookenv.application_version_set(self.application_version) + if not self.configuration_complete(): + errors = [ + '{}: {}'.format(k, v) + for k, v in self.options.validation_errors.items() if v] + status_msg = 'Configuration is incomplete. {}'.format( + ','.join(errors)) + hookenv.status_set('blocked', status_msg) + else: + hookenv.status_set('active', + 'Unit is ready') + + def render_config(self): + """ + Render Service Provider configuration file to be used by Apache + and provided to idP out of band to establish mutual trust. + """ + owner = 'root' + group = 'www-data' + # group read and exec is needed for mellon to read the rendered + # files, otherwise it will fail in a cryptic way + dperms = 0o650 + # file permissions are a bit more restrictive than defaults in + # charm-helpers but directory permissions are the main protection + # mechanism in this case + fileperms = 0o440 + # ensure that a directory we need is there + ch_host.mkdir('/etc/apache2/mellon', perms=dperms, owner=owner, + group=group) + self.render_configs(self.string_templates.keys()) + + core.templating.render( + source='mellon-sp-metadata.xml', + template_loader=os_templating.get_loader( + 'templates/', self.release), + target=self.options.sp_metadata_file, + context=self.adapters_instance, + owner=owner, + group=group, + perms=fileperms + ) + + core.templating.render( + source='apache-mellon-location.conf', + template_loader=os_templating.get_loader( + 'templates/', self.release), + target=self.options.sp_location_config, + context=self.adapters_instance, + owner=owner, + group=group, + perms=fileperms + ) + + def remove_config(self): + for f in CONFIGS: + if os.path.exists(f): + os.unlink(f) + + def enable_module(self): + subprocess.check_call(['a2enmod', 'auth_mellon']) + + def disable_module(self): + subprocess.check_call(['a2dismod', 'auth_mellon']) diff --git a/src/metadata.yaml b/src/metadata.yaml new file mode 100644 index 0000000..21c7856 --- /dev/null +++ b/src/metadata.yaml @@ -0,0 +1,74 @@ +name: keystone-saml-mellon +subordinate: true +maintainer: OpenStack Charmers +summary: Federated identity with SAML via Mellon Service Provider +description: + The main goal of this charm is to generate the necessary configuration + for use in the Keystone charm related to Service Provider config + generation, trust establishment between a remote idP and SP via + certificates and signaling Keystone service restart. + Keystone has a concept of a federated backend which serves multiple + purposes including being a backend part of a Service Provider in an + authentication scenario where SAML is used. Unless ECP is used on a + keystone client side, SAML-related exchange is performed in an Apache + authentication module (Mellon in case of this charm) and SAML + assertions are converted to WSGI environment variables passed down to + a particular mod_wsgi interpreter running Keystone code. Keystone has + an authentication plug-in called "mapped" which does the rest of the + work of resolving symbolic attributes and using them in mappings + defined by an operator or validating the existence of referenced IDs. +tags: + - openstack + - identity + - federation + - idP +series: + - xenial + - bionic + - artful + - trusty +provides: + keystone-fid-service-provider: + interface: keystone-fid-service-provider + scope: container + websso-fid-service-provider: + interface: websso-fid-service-provider + scope: global +requires: + container: + interface: juju-info + scope: container +resources: + idp-metadata: + type: file + filename: 'idp-metadata.xml' + description: | + Identity Provider metadata XML file that conforms to + saml-metadata-2.0-os specification. This file contains idP + identification information and its certificates with public keys + that can be used for signing and encryption on the idP side in + IDPSSODescriptor and other information which can be used on the + service provider side to interact with that idP. + sp-private-key: + type: file + filename: 'sp-private-key.pem' + description: | + Private key used by Service Provider (mod_auth_mellon) to sign + and/or SAML-level (not transport-level) encryption. + sp-signing-keyinfo: + type: file + filename: 'sp-signing-keyinfo.xml' + description: | + Specifies a signing KeyInfo portion of SPSSODescriptor to be used + in Service Provider metadata. This should be an XML portion + which in the simplest case is formatted as shown below: + This fragment should contain a certificate that contains a public + key of a Service Provider in case an idP requires that SAML + requests are signed. + The term “signing certificate” is a misnomer. A signing + certificate in metadata is actually used for signature + verification, not signing. The private signing key is held + securely by the signing party (SP in this case). In a SAML + exchange an SP signs SAML messages with its private key and idP + validates them via a public key embedded in a certificate present + in the SP's metadata XML and vice versa for idP. diff --git a/src/reactive/keystone_saml_mellon_handlers.py b/src/reactive/keystone_saml_mellon_handlers.py new file mode 100644 index 0000000..f589039 --- /dev/null +++ b/src/reactive/keystone_saml_mellon_handlers.py @@ -0,0 +1,137 @@ +# +# Copyright 2017 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. + +import uuid + +# import to trigger openstack charm metaclass init +import charm.openstack.keystone_saml_mellon # noqa + +import charms_openstack.charm as charm +import charms.reactive as reactive +import charms.reactive.flags as flags + +import charmhelpers.core.unitdata as unitdata + +from charms.reactive.relations import ( + endpoint_from_flag, +) + +charm.use_defaults( + 'charm.installed', + 'update-status') + +# if config has been changed we need to re-evaluate flags +# config.changed is set and cleared (atexit) in layer-basic +flags.register_trigger(when='config.changed', + clear_flag='config.rendered') +flags.register_trigger(when='upgraded', clear_flag='config.rendered') +flags.register_trigger(when='config.changed', + clear_flag='config.complete') +flags.register_trigger( + when='endpoint.keystone-fid-service-provider.changed', + clear_flag='keystone-data.complete' +) + + +@reactive.hook('upgrade-charm') +def default_upgrade_charm(): + """Default handler for the 'upgrade-charm' hook. + This calls the charm.singleton.upgrade_charm() function as a default. + """ + reactive.set_state('upgraded') + + +# clear the upgraded state once config.rendered is set again +flags.register_trigger(when='config.rendered', clear_flag='upgraded') + + +@reactive.when_not('endpoint.keystone-fid-service-provider.joined') +def keystone_departed(): + """ + Service restart should be handled on the keystone side + in this case. + """ + with charm.provide_charm_instance() as charm_instance: + charm_instance.remove_config() + + +@reactive.when('endpoint.keystone-fid-service-provider.joined') +@reactive.when_not('config.complete') +def config_changed(): + with charm.provide_charm_instance() as charm_instance: + if charm_instance.configuration_complete(): + flags.set_flag('config.complete') + + +@reactive.when('endpoint.keystone-fid-service-provider.joined') +@reactive.when_not('keystone-data.complete') +def keystone_data_changed(fid_sp): + primary_data = fid_sp.all_joined_units[0].received + if primary_data: + hostname = primary_data.get('hostname') + port = primary_data.get('port') + tls_enabled = primary_data.get('tls-enabled') + # a basic check on the fact that keystone provided us with + # hostname and port information + if hostname and port: + # save hostname and port data in local storage for future + # use - in case config is incomplete but a relation is + # we need to store this across charm hook invocations + unitdb = unitdata.kv() + unitdb.set('hostname', hostname) + unitdb.set('port', port) + unitdb.set('tls-enabled', tls_enabled) + flags.set_flag('keystone-data.complete') + + +@reactive.when('endpoint.keystone-fid-service-provider.joined') +@reactive.when('config.complete') +@reactive.when('keystone-data.complete') +@reactive.when_not('config.rendered') +def render_config(): + # don't always have a relation context - obtain from the flag + fid_sp = endpoint_from_flag( + 'endpoint.keystone-fid-service-provider.joined') + # get the first relation object as we only have one primary relation + rel = fid_sp.relations[0] + with charm.provide_charm_instance() as charm_instance: + charm_instance.render_config() + flags.set_flag('config.rendered') + # Trigger keystone restart. The relation is container-scoped + # so a per-unit db of a remote unit will only contain a nonce + # of a single subordinate + rel.to_publish['restart-nonce'] = str(uuid.uuid4()) + + +@reactive.when('endpoint.websso-fid-service-provider.joined') +@reactive.when('config.complete') +@reactive.when('keystone-data.complete') +@reactive.when('config.rendered') +def configure_websso(): + # don't always have a relation context - obtain from the flag + websso_fid_sp = endpoint_from_flag( + 'endpoint.websso-fid-service-provider.joined') + with charm.provide_charm_instance() as charm_instance: + # publish config options for all remote units of a given rel + options = charm_instance.options + websso_fid_sp.publish(options.protocol_name, + options.idp_name, + options.user_facing_name) + + +@reactive.when_not('always.run') +def assess_status(): + with charm.provide_charm_instance() as charm_instance: + charm_instance.assess_status() diff --git a/src/templates/apache-mellon-location.conf b/src/templates/apache-mellon-location.conf new file mode 100644 index 0000000..0e79d11 --- /dev/null +++ b/src/templates/apache-mellon-location.conf @@ -0,0 +1,47 @@ + + MellonEnable "info" + MellonSPPrivateKeyFile {{ options.sp_private_key_file }} + MellonSPMetadataFile {{ options.sp_metadata_file }} + MellonIdPMetadataFile {{ options.idp_metadata_file }} + MellonEndpointPath {{ options.mellon_endpoint_path }} + MellonIdP "IDP" + AuthType "Mellon" + MellonEnable "auth" + MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }} + AuthType "Mellon" + Require valid-user + MellonEnable "auth" + MellonMergeEnvVars On ";" + + + + MellonEnable "info" + MellonSPPrivateKeyFile {{ options.sp_private_key_file }} + MellonSPMetadataFile {{ options.sp_metadata_file }} + MellonIdPMetadataFile {{ options.idp_metadata_file }} + MellonEndpointPath {{ options.mellon_endpoint_path }} + MellonIdP "IDP" + AuthType "Mellon" + MellonEnable "auth" + MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }} + AuthType "Mellon" + Require valid-user + MellonEnable "auth" + MellonMergeEnvVars On ";" + + + + MellonEnable "info" + MellonSPPrivateKeyFile {{ options.sp_private_key_file }} + MellonSPMetadataFile {{ options.sp_metadata_file }} + MellonIdPMetadataFile {{ options.idp_metadata_file }} + MellonEndpointPath {{ options.mellon_endpoint_path }} + MellonIdP "IDP" + AuthType "Mellon" + MellonEnable "auth" + MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }} + AuthType "Mellon" + Require valid-user + MellonEnable "auth" + MellonMergeEnvVars On ";" + diff --git a/src/templates/mellon-sp-metadata.xml b/src/templates/mellon-sp-metadata.xml new file mode 100644 index 0000000..13b72c6 --- /dev/null +++ b/src/templates/mellon-sp-metadata.xml @@ -0,0 +1,17 @@ + + + + {{ options.sp_signing_keyinfo }} + + {% if options.saml_encryption %} + + {{ options.sp_signing_keyinfo }} + + {% endif %} + + + {% for format in options.supported_nameid_formats -%} + {{ format }} + {% endfor -%} + + diff --git a/src/test-requirements.txt b/src/test-requirements.txt new file mode 100644 index 0000000..16d0adb --- /dev/null +++ b/src/test-requirements.txt @@ -0,0 +1,33 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +os-testr>=0.4.1 +charm-tools>=2.0.0 +requests==2.6.0 +# amulet deployment helpers +git+https://github.com/juju/charm-helpers#egg=charmhelpers +# BEGIN: Amulet OpenStack Charm Helper Requirements +# Liberty client lower constraints +amulet>=1.14.3,<2.0 +bundletester>=0.6.1,<1.0 +aodhclient>=0.1.0 +python-barbicanclient>=4.0.1 +python-ceilometerclient>=1.5.0 +python-cinderclient>=1.4.0 +python-designateclient>=1.5 +python-glanceclient>=1.1.0 +python-heatclient>=0.8.0 +python-keystoneclient>=1.7.1 +python-manilaclient>=1.8.1 +python-neutronclient>=3.1.0 +python-novaclient>=2.30.1 +python-openstackclient>=1.7.0 +python-swiftclient>=2.6.0 +pika>=0.10.0,<1.0 +distro-info +# END: Amulet OpenStack Charm Helper Requirements +# NOTE: workaround for 14.04 pip/tox +pytz diff --git a/src/tox.ini b/src/tox.ini new file mode 100644 index 0000000..f201a20 --- /dev/null +++ b/src/tox.ini @@ -0,0 +1,53 @@ +# Source charm: ./src/tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. +[tox] +envlist = pep8 +skipsdist = True + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + AMULET_SETUP_TIMEOUT=2700 +whitelist_externals = juju +passenv = HOME TERM AMULET_* CS_API_* +deps = -r{toxinidir}/test-requirements.txt +install_command = + pip install --allow-unverified python-apt {opts} {packages} + +[testenv:pep8] +basepython = python2.7 +commands = charm-proof + +[testenv:func27-noop] +# DRY RUN - For Debug +basepython = python2.7 +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" -n --no-destroy + +[testenv:func27] +# Run all gate tests which are +x (expected to always pass) +basepython = python2.7 +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" --no-destroy + +[testenv:func27-smoke] +# Run a specific test as an Amulet smoke test (expected to always pass) +basepython = python2.7 +commands = + bundletester -vl DEBUG -r json -o func-results.json gate-basic-xenial-pike --no-destroy + +[testenv:func27-dfs] +# Run all deploy-from-source tests which are +x (may not always pass!) +basepython = python2.7 +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dfs-*" --no-destroy + +[testenv:func27-dev] +# Run all development test targets which are +x (may not always pass!) +basepython = python2.7 +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dev-*" --no-destroy + +[testenv:venv] +commands = {posargs} diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..368dbf2 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,7 @@ +# Lint and unit test requirements +flake8 +os-testr>=0.4.1 +charms.reactive +mock>=1.2 +coverage>=3.6 +git+https://github.com/openstack/charms.openstack.git#egg=charms-openstack diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3ba2b23 --- /dev/null +++ b/tox.ini @@ -0,0 +1,55 @@ +# Source charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. +[tox] +skipsdist = True +envlist = pep8,py34,py35 +skip_missing_interpreters = True + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + TERM=linux + LAYER_PATH={toxinidir}/layers + INTERFACE_PATH={toxinidir}/interfaces + JUJU_REPOSITORY={toxinidir}/build +passenv = http_proxy https_proxy +install_command = + pip install {opts} {packages} +deps = + -r{toxinidir}/requirements.txt + +[testenv:build] +basepython = python2.7 +commands = + charm-build --log-level DEBUG -o {toxinidir}/build src {posargs} + +[testenv:py27] +basepython = python2.7 +# Reactive source charms are Python3-only, but a py27 unit test target +# is required by OpenStack Governance. Remove this shim as soon as +# permitted. http://governance.openstack.org/reference/cti/python_cti.html +whitelist_externals = true +commands = true + +[testenv:py34] +basepython = python3.4 +deps = -r{toxinidir}/test-requirements.txt +commands = ostestr {posargs} + +[testenv:py35] +basepython = python3.5 +deps = -r{toxinidir}/test-requirements.txt +commands = ostestr {posargs} + +[testenv:pep8] +basepython = python3.5 +deps = -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} src unit_tests + +[testenv:venv] +commands = {posargs} + +[flake8] +# E402 ignore necessary for path append before sys module import in actions +ignore = E402 diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..3a5e9a3 --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2016 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. + +import sys + +sys.path.append('src') +sys.path.append('src/lib') + +# Mock out charmhelpers so that we can test without it. +import charms_openstack.test_mocks # noqa +charms_openstack.test_mocks.mock_charmhelpers()