# # 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.contrib.openstack.templating as os_templating import charms_openstack.charm import charms_openstack.adapters import os import subprocess import urllib.request import urllib.error from lxml import etree from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization 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']] 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 remote_id_attribute(self): # Mellon module environment variables are prefixed with MELLON_ # and mod_auth_mellon has a default setting of: MellonIdP "IDP" return "MELLON_IDP" @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 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_paos_response_path(self): return '{}/paosResponse'.format(self.mellon_endpoint_path) @property def sp_logout_path(self): return '{}/logout'.format(self.mellon_endpoint_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(',') @property def entity_id(self): if self.entity_id_url is None: # Generate entity ID automatically return '{}{}'.format(self.keystone_fid_service_provider.base_url, self.sp_auth_path) else: return self.entity_id_url IDP_METADATA_INVALID = ('idp-metadata resource is not a well-formed' ' xml file') IDP_METADATA_URL_ERROR = ('an error has occurred during idp-metadata-url' ' config option processing') IDP_METADATA_NOT_PROVIDED = ('idp-metadata resource has not been provided') @property def idp_metadata(self): idp_metadata_content = None if self.idp_metadata_url is None: # Get metadata from resource idp_metadata_path = hookenv.resource_get('idp-metadata') if (idp_metadata_path and os.path.exists(idp_metadata_path) and not self._idp_metadata): with open(idp_metadata_path, 'r', encoding='utf-8') as f: idp_metadata_content = f.read() else: self._validation_errors['idp-metadata'] =\ self.IDP_METADATA_NOT_PROVIDED else: try: response = urllib.request.urlopen(self.idp_metadata_url) except urllib.error.URLError as e: self._validation_errors['idp-metadata'] = '{}: {}'.format( self.IDP_METADATA_URL_ERROR, e.reason ) return self._idp_metadata encoded_content = response.read() idp_metadata_content = encoded_content.decode("utf-8") # Metadata has been provided either via a resource or downloaded from # the specified URL. if idp_metadata_content is not None: try: etree.fromstring(idp_metadata_content.encode()) self._idp_metadata = idp_metadata_content self._validation_errors['idp-metadata'] = None except etree.XMLSyntaxError: self._idp_metadata = None 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') SP_SIGNING_KEYINFO_NOT_PROVIDED = ('sp-signing-keyinfo resource has not' ' been provided') @property def sp_signing_keyinfo(self): info_path = hookenv.resource_get('sp-signing-keyinfo') if not info_path: self._sp_signing_keyinfo = None self._validation_errors['sp-signing-keyinfo'] = ( self.SP_SIGNING_KEYINFO_NOT_PROVIDED) return self._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.encode()) 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 = ('sp-private-key resource is not a well-formed' ' RFC 5958 (PKCS#8) key') SP_PRIVATE_KEY_NOT_PROVIDED = ('sp-private-key resource has not' ' been provided') @property def sp_private_key(self): pk_path = hookenv.resource_get('sp-private-key') if not pk_path: self._sp_private_key = None self._validation_errors['sp-private-key'] = ( self.SP_PRIVATE_KEY_NOT_PROVIDED) return self._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' release_pkg = 'keystone-common' # Required relations required_relations = [ 'keystone-fid-service-provider', 'websso-fid-service-provider'] # 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'), } # Render idp-metadata.xml and sp-private-key with www-data group # ownership. group = 'www-data' restart_map = { IDP_METADATA: [], SP_METADATA: [], SP_PRIVATE_KEY: [], SP_LOCATION_CONFIG: [], } 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-signing-keyinfo': self.options.sp_signing_keyinfo, 'sp-private-key': self.options.sp_private_key, 'nameid-formats': self.options.nameid_formats, } return all(required_config.values()) def custom_assess_status_check(self): """Custom asses status. Check the configuration is complete. """ if not self.configuration_complete(): errors = [ '{}: {}'.format(k, v) for k, v in self.options.validation_errors.items()] status_msg = 'Configuration is incomplete. {}'.format( ','.join(errors)) return 'blocked', status_msg # Nothing to report return None, None def render_config(self, *args): """ 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()) # For now the template name does not match # basename(file_path/file_name). This is necessary to enable multiple # instantiations of keystone-saml-mellon using service_name() in the # file names. So not using self.render_with_interfaces(args) # TODO: Make a mapping mechanism between target and source templates # in charms.openstack 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_class(args, charm_instance=self), 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_class(args, charm_instance=self), owner=owner, group=group, perms=fileperms ) def remove_config(self): for f in self.restart_map.keys(): 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'])