372 lines
13 KiB
Python
372 lines
13 KiB
Python
#
|
|
# 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(',')
|
|
|
|
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'])
|