diff options
Diffstat (limited to 'keystone-moon/keystone/contrib/federation')
18 files changed, 3022 insertions, 0 deletions
diff --git a/keystone-moon/keystone/contrib/federation/__init__.py b/keystone-moon/keystone/contrib/federation/__init__.py new file mode 100644 index 00000000..57c9e42c --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2014 OpenStack Foundation +# +# 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. + +from keystone.contrib.federation.core import * # noqa diff --git a/keystone-moon/keystone/contrib/federation/backends/__init__.py b/keystone-moon/keystone/contrib/federation/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/backends/__init__.py diff --git a/keystone-moon/keystone/contrib/federation/backends/sql.py b/keystone-moon/keystone/contrib/federation/backends/sql.py new file mode 100644 index 00000000..f2c124d0 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/backends/sql.py @@ -0,0 +1,315 @@ +# Copyright 2014 OpenStack Foundation +# +# 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. + +from oslo_serialization import jsonutils + +from keystone.common import sql +from keystone.contrib.federation import core +from keystone import exception + + +class FederationProtocolModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'federation_protocol' + attributes = ['id', 'idp_id', 'mapping_id'] + mutable_attributes = frozenset(['mapping_id']) + + id = sql.Column(sql.String(64), primary_key=True) + idp_id = sql.Column(sql.String(64), sql.ForeignKey('identity_provider.id', + ondelete='CASCADE'), primary_key=True) + mapping_id = sql.Column(sql.String(64), nullable=False) + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + return cls(**new_dictionary) + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + return d + + +class IdentityProviderModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'identity_provider' + attributes = ['id', 'remote_id', 'enabled', 'description'] + mutable_attributes = frozenset(['description', 'enabled', 'remote_id']) + + id = sql.Column(sql.String(64), primary_key=True) + remote_id = sql.Column(sql.String(256), nullable=True) + enabled = sql.Column(sql.Boolean, nullable=False) + description = sql.Column(sql.Text(), nullable=True) + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + return cls(**new_dictionary) + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + return d + + +class MappingModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'mapping' + attributes = ['id', 'rules'] + + id = sql.Column(sql.String(64), primary_key=True) + rules = sql.Column(sql.JsonBlob(), nullable=False) + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + return cls(**new_dictionary) + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + return d + + +class ServiceProviderModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'service_provider' + attributes = ['auth_url', 'id', 'enabled', 'description', 'sp_url'] + mutable_attributes = frozenset(['auth_url', 'description', 'enabled', + 'sp_url']) + + id = sql.Column(sql.String(64), primary_key=True) + enabled = sql.Column(sql.Boolean, nullable=False) + description = sql.Column(sql.Text(), nullable=True) + auth_url = sql.Column(sql.String(256), nullable=False) + sp_url = sql.Column(sql.String(256), nullable=False) + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + return cls(**new_dictionary) + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + return d + + +class Federation(core.Driver): + + # Identity Provider CRUD + @sql.handle_conflicts(conflict_type='identity_provider') + def create_idp(self, idp_id, idp): + idp['id'] = idp_id + with sql.transaction() as session: + idp_ref = IdentityProviderModel.from_dict(idp) + session.add(idp_ref) + return idp_ref.to_dict() + + def delete_idp(self, idp_id): + with sql.transaction() as session: + idp_ref = self._get_idp(session, idp_id) + session.delete(idp_ref) + + def _get_idp(self, session, idp_id): + idp_ref = session.query(IdentityProviderModel).get(idp_id) + if not idp_ref: + raise exception.IdentityProviderNotFound(idp_id=idp_id) + return idp_ref + + def _get_idp_from_remote_id(self, session, remote_id): + q = session.query(IdentityProviderModel) + q = q.filter_by(remote_id=remote_id) + try: + return q.one() + except sql.NotFound: + raise exception.IdentityProviderNotFound(idp_id=remote_id) + + def list_idps(self): + with sql.transaction() as session: + idps = session.query(IdentityProviderModel) + idps_list = [idp.to_dict() for idp in idps] + return idps_list + + def get_idp(self, idp_id): + with sql.transaction() as session: + idp_ref = self._get_idp(session, idp_id) + return idp_ref.to_dict() + + def get_idp_from_remote_id(self, remote_id): + with sql.transaction() as session: + idp_ref = self._get_idp_from_remote_id(session, remote_id) + return idp_ref.to_dict() + + def update_idp(self, idp_id, idp): + with sql.transaction() as session: + idp_ref = self._get_idp(session, idp_id) + old_idp = idp_ref.to_dict() + old_idp.update(idp) + new_idp = IdentityProviderModel.from_dict(old_idp) + for attr in IdentityProviderModel.mutable_attributes: + setattr(idp_ref, attr, getattr(new_idp, attr)) + return idp_ref.to_dict() + + # Protocol CRUD + def _get_protocol(self, session, idp_id, protocol_id): + q = session.query(FederationProtocolModel) + q = q.filter_by(id=protocol_id, idp_id=idp_id) + try: + return q.one() + except sql.NotFound: + kwargs = {'protocol_id': protocol_id, + 'idp_id': idp_id} + raise exception.FederatedProtocolNotFound(**kwargs) + + @sql.handle_conflicts(conflict_type='federation_protocol') + def create_protocol(self, idp_id, protocol_id, protocol): + protocol['id'] = protocol_id + protocol['idp_id'] = idp_id + with sql.transaction() as session: + self._get_idp(session, idp_id) + protocol_ref = FederationProtocolModel.from_dict(protocol) + session.add(protocol_ref) + return protocol_ref.to_dict() + + def update_protocol(self, idp_id, protocol_id, protocol): + with sql.transaction() as session: + proto_ref = self._get_protocol(session, idp_id, protocol_id) + old_proto = proto_ref.to_dict() + old_proto.update(protocol) + new_proto = FederationProtocolModel.from_dict(old_proto) + for attr in FederationProtocolModel.mutable_attributes: + setattr(proto_ref, attr, getattr(new_proto, attr)) + return proto_ref.to_dict() + + def get_protocol(self, idp_id, protocol_id): + with sql.transaction() as session: + protocol_ref = self._get_protocol(session, idp_id, protocol_id) + return protocol_ref.to_dict() + + def list_protocols(self, idp_id): + with sql.transaction() as session: + q = session.query(FederationProtocolModel) + q = q.filter_by(idp_id=idp_id) + protocols = [protocol.to_dict() for protocol in q] + return protocols + + def delete_protocol(self, idp_id, protocol_id): + with sql.transaction() as session: + key_ref = self._get_protocol(session, idp_id, protocol_id) + session.delete(key_ref) + + # Mapping CRUD + def _get_mapping(self, session, mapping_id): + mapping_ref = session.query(MappingModel).get(mapping_id) + if not mapping_ref: + raise exception.MappingNotFound(mapping_id=mapping_id) + return mapping_ref + + @sql.handle_conflicts(conflict_type='mapping') + def create_mapping(self, mapping_id, mapping): + ref = {} + ref['id'] = mapping_id + ref['rules'] = jsonutils.dumps(mapping.get('rules')) + with sql.transaction() as session: + mapping_ref = MappingModel.from_dict(ref) + session.add(mapping_ref) + return mapping_ref.to_dict() + + def delete_mapping(self, mapping_id): + with sql.transaction() as session: + mapping_ref = self._get_mapping(session, mapping_id) + session.delete(mapping_ref) + + def list_mappings(self): + with sql.transaction() as session: + mappings = session.query(MappingModel) + return [x.to_dict() for x in mappings] + + def get_mapping(self, mapping_id): + with sql.transaction() as session: + mapping_ref = self._get_mapping(session, mapping_id) + return mapping_ref.to_dict() + + @sql.handle_conflicts(conflict_type='mapping') + def update_mapping(self, mapping_id, mapping): + ref = {} + ref['id'] = mapping_id + ref['rules'] = jsonutils.dumps(mapping.get('rules')) + with sql.transaction() as session: + mapping_ref = self._get_mapping(session, mapping_id) + old_mapping = mapping_ref.to_dict() + old_mapping.update(ref) + new_mapping = MappingModel.from_dict(old_mapping) + for attr in MappingModel.attributes: + setattr(mapping_ref, attr, getattr(new_mapping, attr)) + return mapping_ref.to_dict() + + def get_mapping_from_idp_and_protocol(self, idp_id, protocol_id): + with sql.transaction() as session: + protocol_ref = self._get_protocol(session, idp_id, protocol_id) + mapping_id = protocol_ref.mapping_id + mapping_ref = self._get_mapping(session, mapping_id) + return mapping_ref.to_dict() + + # Service Provider CRUD + @sql.handle_conflicts(conflict_type='service_provider') + def create_sp(self, sp_id, sp): + sp['id'] = sp_id + with sql.transaction() as session: + sp_ref = ServiceProviderModel.from_dict(sp) + session.add(sp_ref) + return sp_ref.to_dict() + + def delete_sp(self, sp_id): + with sql.transaction() as session: + sp_ref = self._get_sp(session, sp_id) + session.delete(sp_ref) + + def _get_sp(self, session, sp_id): + sp_ref = session.query(ServiceProviderModel).get(sp_id) + if not sp_ref: + raise exception.ServiceProviderNotFound(sp_id=sp_id) + return sp_ref + + def list_sps(self): + with sql.transaction() as session: + sps = session.query(ServiceProviderModel) + sps_list = [sp.to_dict() for sp in sps] + return sps_list + + def get_sp(self, sp_id): + with sql.transaction() as session: + sp_ref = self._get_sp(session, sp_id) + return sp_ref.to_dict() + + def update_sp(self, sp_id, sp): + with sql.transaction() as session: + sp_ref = self._get_sp(session, sp_id) + old_sp = sp_ref.to_dict() + old_sp.update(sp) + new_sp = ServiceProviderModel.from_dict(old_sp) + for attr in ServiceProviderModel.mutable_attributes: + setattr(sp_ref, attr, getattr(new_sp, attr)) + return sp_ref.to_dict() + + def get_enabled_service_providers(self): + with sql.transaction() as session: + service_providers = session.query(ServiceProviderModel) + service_providers = service_providers.filter_by(enabled=True) + return service_providers diff --git a/keystone-moon/keystone/contrib/federation/controllers.py b/keystone-moon/keystone/contrib/federation/controllers.py new file mode 100644 index 00000000..6066a33f --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/controllers.py @@ -0,0 +1,457 @@ +# 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. + +"""Extensions supporting Federation.""" + +import string + +from oslo_config import cfg +from oslo_log import log +import six +from six.moves import urllib +import webob + +from keystone.auth import controllers as auth_controllers +from keystone.common import authorization +from keystone.common import controller +from keystone.common import dependency +from keystone.common import validation +from keystone.common import wsgi +from keystone.contrib.federation import idp as keystone_idp +from keystone.contrib.federation import schema +from keystone.contrib.federation import utils +from keystone import exception +from keystone.i18n import _ +from keystone.models import token_model + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class _ControllerBase(controller.V3Controller): + """Base behaviors for federation controllers.""" + + @classmethod + def base_url(cls, context, path=None): + """Construct a path and pass it to V3Controller.base_url method.""" + + path = '/OS-FEDERATION/' + cls.collection_name + return super(_ControllerBase, cls).base_url(context, path=path) + + +@dependency.requires('federation_api') +class IdentityProvider(_ControllerBase): + """Identity Provider representation.""" + collection_name = 'identity_providers' + member_name = 'identity_provider' + + _mutable_parameters = frozenset(['description', 'enabled', 'remote_id']) + _public_parameters = frozenset(['id', 'enabled', 'description', + 'remote_id', 'links' + ]) + + @classmethod + def _add_related_links(cls, context, ref): + """Add URLs for entities related with Identity Provider. + + Add URLs pointing to: + - protocols tied to the Identity Provider + + """ + ref.setdefault('links', {}) + base_path = ref['links'].get('self') + if base_path is None: + base_path = '/'.join([IdentityProvider.base_url(context), + ref['id']]) + for name in ['protocols']: + ref['links'][name] = '/'.join([base_path, name]) + + @classmethod + def _add_self_referential_link(cls, context, ref): + id = ref.get('id') + self_path = '/'.join([cls.base_url(context), id]) + ref.setdefault('links', {}) + ref['links']['self'] = self_path + + @classmethod + def wrap_member(cls, context, ref): + cls._add_self_referential_link(context, ref) + cls._add_related_links(context, ref) + ref = cls.filter_params(ref) + return {cls.member_name: ref} + + @controller.protected() + def create_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(identity_provider) + identity_provider.setdefault('enabled', False) + IdentityProvider.check_immutable_params(identity_provider) + idp_ref = self.federation_api.create_idp(idp_id, identity_provider) + response = IdentityProvider.wrap_member(context, idp_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + def list_identity_providers(self, context): + ref = self.federation_api.list_idps() + ref = [self.filter_params(x) for x in ref] + return IdentityProvider.wrap_collection(context, ref) + + @controller.protected() + def get_identity_provider(self, context, idp_id): + ref = self.federation_api.get_idp(idp_id) + return IdentityProvider.wrap_member(context, ref) + + @controller.protected() + def delete_identity_provider(self, context, idp_id): + self.federation_api.delete_idp(idp_id) + + @controller.protected() + def update_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(identity_provider) + IdentityProvider.check_immutable_params(identity_provider) + idp_ref = self.federation_api.update_idp(idp_id, identity_provider) + return IdentityProvider.wrap_member(context, idp_ref) + + +@dependency.requires('federation_api') +class FederationProtocol(_ControllerBase): + """A federation protocol representation. + + See IdentityProvider docstring for explanation on _mutable_parameters + and _public_parameters class attributes. + + """ + collection_name = 'protocols' + member_name = 'protocol' + + _public_parameters = frozenset(['id', 'mapping_id', 'links']) + _mutable_parameters = frozenset(['mapping_id']) + + @classmethod + def _add_self_referential_link(cls, context, ref): + """Add 'links' entry to the response dictionary. + + Calls IdentityProvider.base_url() class method, as it constructs + proper URL along with the 'identity providers' part included. + + :param ref: response dictionary + + """ + ref.setdefault('links', {}) + base_path = ref['links'].get('identity_provider') + if base_path is None: + base_path = [IdentityProvider.base_url(context), ref['idp_id']] + base_path = '/'.join(base_path) + self_path = [base_path, 'protocols', ref['id']] + self_path = '/'.join(self_path) + ref['links']['self'] = self_path + + @classmethod + def _add_related_links(cls, context, ref): + """Add new entries to the 'links' subdictionary in the response. + + Adds 'identity_provider' key with URL pointing to related identity + provider as a value. + + :param ref: response dictionary + + """ + ref.setdefault('links', {}) + base_path = '/'.join([IdentityProvider.base_url(context), + ref['idp_id']]) + ref['links']['identity_provider'] = base_path + + @classmethod + def wrap_member(cls, context, ref): + cls._add_related_links(context, ref) + cls._add_self_referential_link(context, ref) + ref = cls.filter_params(ref) + return {cls.member_name: ref} + + @controller.protected() + def create_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + FederationProtocol.check_immutable_params(ref) + ref = self.federation_api.create_protocol(idp_id, protocol_id, ref) + response = FederationProtocol.wrap_member(context, ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + def update_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + FederationProtocol.check_immutable_params(ref) + ref = self.federation_api.update_protocol(idp_id, protocol_id, + protocol) + return FederationProtocol.wrap_member(context, ref) + + @controller.protected() + def get_protocol(self, context, idp_id, protocol_id): + ref = self.federation_api.get_protocol(idp_id, protocol_id) + return FederationProtocol.wrap_member(context, ref) + + @controller.protected() + def list_protocols(self, context, idp_id): + protocols_ref = self.federation_api.list_protocols(idp_id) + protocols = list(protocols_ref) + return FederationProtocol.wrap_collection(context, protocols) + + @controller.protected() + def delete_protocol(self, context, idp_id, protocol_id): + self.federation_api.delete_protocol(idp_id, protocol_id) + + +@dependency.requires('federation_api') +class MappingController(_ControllerBase): + collection_name = 'mappings' + member_name = 'mapping' + + @controller.protected() + def create_mapping(self, context, mapping_id, mapping): + ref = self._normalize_dict(mapping) + utils.validate_mapping_structure(ref) + mapping_ref = self.federation_api.create_mapping(mapping_id, ref) + response = MappingController.wrap_member(context, mapping_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + def list_mappings(self, context): + ref = self.federation_api.list_mappings() + return MappingController.wrap_collection(context, ref) + + @controller.protected() + def get_mapping(self, context, mapping_id): + ref = self.federation_api.get_mapping(mapping_id) + return MappingController.wrap_member(context, ref) + + @controller.protected() + def delete_mapping(self, context, mapping_id): + self.federation_api.delete_mapping(mapping_id) + + @controller.protected() + def update_mapping(self, context, mapping_id, mapping): + mapping = self._normalize_dict(mapping) + utils.validate_mapping_structure(mapping) + mapping_ref = self.federation_api.update_mapping(mapping_id, mapping) + return MappingController.wrap_member(context, mapping_ref) + + +@dependency.requires('federation_api') +class Auth(auth_controllers.Auth): + + def federated_authentication(self, context, identity_provider, protocol): + """Authenticate from dedicated url endpoint. + + Build HTTP request body for federated authentication and inject + it into the ``authenticate_for_token`` function. + + """ + auth = { + 'identity': { + 'methods': [protocol], + protocol: { + 'identity_provider': identity_provider, + 'protocol': protocol + } + } + } + + return self.authenticate_for_token(context, auth=auth) + + def federated_sso_auth(self, context, protocol_id): + try: + remote_id_name = CONF.federation.remote_id_attribute + remote_id = context['environment'][remote_id_name] + except KeyError: + msg = _('Missing entity ID from environment') + LOG.error(msg) + raise exception.Unauthorized(msg) + + if 'origin' in context['query_string']: + origin = context['query_string'].get('origin') + host = urllib.parse.unquote_plus(origin) + else: + msg = _('Request must have an origin query parameter') + LOG.error(msg) + raise exception.ValidationError(msg) + + if host in CONF.federation.trusted_dashboard: + ref = self.federation_api.get_idp_from_remote_id(remote_id) + identity_provider = ref['id'] + res = self.federated_authentication(context, identity_provider, + protocol_id) + token_id = res.headers['X-Subject-Token'] + return self.render_html_response(host, token_id) + else: + msg = _('%(host)s is not a trusted dashboard host') + msg = msg % {'host': host} + LOG.error(msg) + raise exception.Unauthorized(msg) + + def render_html_response(self, host, token_id): + """Forms an HTML Form from a template with autosubmit.""" + + headers = [('Content-Type', 'text/html')] + + with open(CONF.federation.sso_callback_template) as template: + src = string.Template(template.read()) + + subs = {'host': host, 'token': token_id} + body = src.substitute(subs) + return webob.Response(body=body, status='200', + headerlist=headers) + + @validation.validated(schema.saml_create, 'auth') + def create_saml_assertion(self, context, auth): + """Exchange a scoped token for a SAML assertion. + + :param auth: Dictionary that contains a token and service provider id + :returns: SAML Assertion based on properties from the token + """ + + issuer = CONF.saml.idp_entity_id + sp_id = auth['scope']['service_provider']['id'] + service_provider = self.federation_api.get_sp(sp_id) + utils.assert_enabled_service_provider_object(service_provider) + + sp_url = service_provider.get('sp_url') + auth_url = service_provider.get('auth_url') + + token_id = auth['identity']['token']['id'] + token_data = self.token_provider_api.validate_token(token_id) + token_ref = token_model.KeystoneToken(token_id, token_data) + subject = token_ref.user_name + roles = token_ref.role_names + + if not token_ref.project_scoped: + action = _('Use a project scoped token when attempting to create ' + 'a SAML assertion') + raise exception.ForbiddenAction(action=action) + + project = token_ref.project_name + generator = keystone_idp.SAMLGenerator() + response = generator.samlize_token(issuer, sp_url, subject, roles, + project) + + return wsgi.render_response(body=response.to_string(), + status=('200', 'OK'), + headers=[('Content-Type', 'text/xml'), + ('X-sp-url', + six.binary_type(sp_url)), + ('X-auth-url', + six.binary_type(auth_url))]) + + +@dependency.requires('assignment_api', 'resource_api') +class DomainV3(controller.V3Controller): + collection_name = 'domains' + member_name = 'domain' + + def __init__(self): + super(DomainV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_domain + + @controller.protected() + def list_domains_for_groups(self, context): + """List all domains available to an authenticated user's groups. + + :param context: request context + :returns: list of accessible domains + + """ + auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV] + domains = self.assignment_api.list_domains_for_groups( + auth_context['group_ids']) + return DomainV3.wrap_collection(context, domains) + + +@dependency.requires('assignment_api', 'resource_api') +class ProjectAssignmentV3(controller.V3Controller): + collection_name = 'projects' + member_name = 'project' + + def __init__(self): + super(ProjectAssignmentV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_project + + @controller.protected() + def list_projects_for_groups(self, context): + """List all projects available to an authenticated user's groups. + + :param context: request context + :returns: list of accessible projects + + """ + auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV] + projects = self.assignment_api.list_projects_for_groups( + auth_context['group_ids']) + return ProjectAssignmentV3.wrap_collection(context, projects) + + +@dependency.requires('federation_api') +class ServiceProvider(_ControllerBase): + """Service Provider representation.""" + + collection_name = 'service_providers' + member_name = 'service_provider' + + _mutable_parameters = frozenset(['auth_url', 'description', 'enabled', + 'sp_url']) + _public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description', + 'links', 'sp_url']) + + @controller.protected() + @validation.validated(schema.service_provider_create, 'service_provider') + def create_service_provider(self, context, sp_id, service_provider): + service_provider = self._normalize_dict(service_provider) + service_provider.setdefault('enabled', False) + ServiceProvider.check_immutable_params(service_provider) + sp_ref = self.federation_api.create_sp(sp_id, service_provider) + response = ServiceProvider.wrap_member(context, sp_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + def list_service_providers(self, context): + ref = self.federation_api.list_sps() + ref = [self.filter_params(x) for x in ref] + return ServiceProvider.wrap_collection(context, ref) + + @controller.protected() + def get_service_provider(self, context, sp_id): + ref = self.federation_api.get_sp(sp_id) + return ServiceProvider.wrap_member(context, ref) + + @controller.protected() + def delete_service_provider(self, context, sp_id): + self.federation_api.delete_sp(sp_id) + + @controller.protected() + @validation.validated(schema.service_provider_update, 'service_provider') + def update_service_provider(self, context, sp_id, service_provider): + service_provider = self._normalize_dict(service_provider) + ServiceProvider.check_immutable_params(service_provider) + sp_ref = self.federation_api.update_sp(sp_id, service_provider) + return ServiceProvider.wrap_member(context, sp_ref) + + +class SAMLMetadataV3(_ControllerBase): + member_name = 'metadata' + + def get_metadata(self, context): + metadata_path = CONF.saml.idp_metadata_path + try: + with open(metadata_path, 'r') as metadata_handler: + metadata = metadata_handler.read() + except IOError as e: + # Raise HTTP 500 in case Metadata file cannot be read. + raise exception.MetadataFileError(reason=e) + return wsgi.render_response(body=metadata, status=('200', 'OK'), + headers=[('Content-Type', 'text/xml')]) diff --git a/keystone-moon/keystone/contrib/federation/core.py b/keystone-moon/keystone/contrib/federation/core.py new file mode 100644 index 00000000..b596cff7 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/core.py @@ -0,0 +1,346 @@ +# 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. + +"""Extension supporting Federation.""" + +import abc + +from oslo_config import cfg +from oslo_log import log as logging +import six + +from keystone.common import dependency +from keystone.common import extension +from keystone.common import manager +from keystone import exception + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) +EXTENSION_DATA = { + 'name': 'OpenStack Federation APIs', + 'namespace': 'http://docs.openstack.org/identity/api/ext/' + 'OS-FEDERATION/v1.0', + 'alias': 'OS-FEDERATION', + 'updated': '2013-12-17T12:00:0-00:00', + 'description': 'OpenStack Identity Providers Mechanism.', + 'links': [{ + 'rel': 'describedby', + 'type': 'text/html', + 'href': 'https://github.com/openstack/identity-api' + }]} +extension.register_admin_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) +extension.register_public_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) + +FEDERATION = 'OS-FEDERATION' +IDENTITY_PROVIDER = 'OS-FEDERATION:identity_provider' +PROTOCOL = 'OS-FEDERATION:protocol' +FEDERATED_DOMAIN_KEYWORD = 'Federated' + + +@dependency.provider('federation_api') +class Manager(manager.Manager): + """Default pivot point for the Federation backend. + + See :mod:`keystone.common.manager.Manager` for more details on how this + dynamically calls the backend. + + """ + def __init__(self): + super(Manager, self).__init__(CONF.federation.driver) + + def get_enabled_service_providers(self): + """List enabled service providers for Service Catalog + + Service Provider in a catalog contains three attributes: ``id``, + ``auth_url``, ``sp_url``, where: + + - id is an unique, user defined identifier for service provider object + - auth_url is a authentication URL of remote Keystone + - sp_url a URL accessible at the remote service provider where SAML + assertion is transmitted. + + :returns: list of dictionaries with enabled service providers + :rtype: list of dicts + + """ + def normalize(sp): + ref = { + 'auth_url': sp.auth_url, + 'id': sp.id, + 'sp_url': sp.sp_url + } + return ref + + service_providers = self.driver.get_enabled_service_providers() + return [normalize(sp) for sp in service_providers] + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + + @abc.abstractmethod + def create_idp(self, idp_id, idp): + """Create an identity provider. + + :returns: idp_ref + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_idp(self, idp_id): + """Delete an identity provider. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_idps(self): + """List all identity providers. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_idp(self, idp_id): + """Get an identity provider by ID. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_idp_from_remote_id(self, remote_id): + """Get an identity provider by remote ID. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_idp(self, idp_id, idp): + """Update an identity provider by ID. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def create_protocol(self, idp_id, protocol_id, protocol): + """Add an IdP-Protocol configuration. + + :raises: keystone.exception.IdentityProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_protocol(self, idp_id, protocol_id, protocol): + """Change an IdP-Protocol configuration. + + :raises: keystone.exception.IdentityProviderNotFound, + keystone.exception.FederatedProtocolNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_protocol(self, idp_id, protocol_id): + """Get an IdP-Protocol configuration. + + :raises: keystone.exception.IdentityProviderNotFound, + keystone.exception.FederatedProtocolNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_protocols(self, idp_id): + """List an IdP's supported protocols. + + :raises: keystone.exception.IdentityProviderNotFound, + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_protocol(self, idp_id, protocol_id): + """Delete an IdP-Protocol configuration. + + :raises: keystone.exception.IdentityProviderNotFound, + keystone.exception.FederatedProtocolNotFound, + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def create_mapping(self, mapping_ref): + """Create a mapping. + + :param mapping_ref: mapping ref with mapping name + :type mapping_ref: dict + :returns: mapping_ref + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_mapping(self, mapping_id): + """Delete a mapping. + + :param mapping_id: id of mapping to delete + :type mapping_ref: string + :returns: None + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_mapping(self, mapping_id, mapping_ref): + """Update a mapping. + + :param mapping_id: id of mapping to update + :type mapping_id: string + :param mapping_ref: new mapping ref + :type mapping_ref: dict + :returns: mapping_ref + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_mappings(self): + """List all mappings. + + returns: list of mappings + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_mapping(self, mapping_id): + """Get a mapping, returns the mapping based + on mapping_id. + + :param mapping_id: id of mapping to get + :type mapping_ref: string + :returns: mapping_ref + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_mapping_from_idp_and_protocol(self, idp_id, protocol_id): + """Get mapping based on idp_id and protocol_id. + + :param idp_id: id of the identity provider + :type idp_id: string + :param protocol_id: id of the protocol + :type protocol_id: string + :raises: keystone.exception.IdentityProviderNotFound, + keystone.exception.FederatedProtocolNotFound, + :returns: mapping_ref + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def create_sp(self, sp_id, sp): + """Create a service provider. + + :param sp_id: id of the service provider + :type sp_id: string + :param sp: service prvider object + :type sp: dict + + :returns: sp_ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_sp(self, sp_id): + """Delete a service provider. + + :param sp_id: id of the service provider + :type sp_id: string + + :raises: keystone.exception.ServiceProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_sps(self): + """List all service providers. + + :returns List of sp_ref objects + :rtype: list of dicts + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_sp(self, sp_id): + """Get a service provider. + + :param sp_id: id of the service provider + :type sp_id: string + + :returns: sp_ref + :raises: keystone.exception.ServiceProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_sp(self, sp_id, sp): + """Update a service provider. + + :param sp_id: id of the service provider + :type sp_id: string + :param sp: service prvider object + :type sp: dict + + :returns: sp_ref + :rtype: dict + + :raises: keystone.exception.ServiceProviderNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + def get_enabled_service_providers(self): + """List enabled service providers for Service Catalog + + Service Provider in a catalog contains three attributes: ``id``, + ``auth_url``, ``sp_url``, where: + + - id is an unique, user defined identifier for service provider object + - auth_url is a authentication URL of remote Keystone + - sp_url a URL accessible at the remote service provider where SAML + assertion is transmitted. + + :returns: list of dictionaries with enabled service providers + :rtype: list of dicts + + """ + raise exception.NotImplemented() # pragma: no cover diff --git a/keystone-moon/keystone/contrib/federation/idp.py b/keystone-moon/keystone/contrib/federation/idp.py new file mode 100644 index 00000000..bf400135 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/idp.py @@ -0,0 +1,558 @@ +# 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 datetime +import os +import subprocess +import uuid + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import timeutils +import saml2 +from saml2 import md +from saml2 import saml +from saml2 import samlp +from saml2 import sigver +import xmldsig + +from keystone import exception +from keystone.i18n import _, _LE +from keystone.openstack.common import fileutils + + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +class SAMLGenerator(object): + """A class to generate SAML assertions.""" + + def __init__(self): + self.assertion_id = uuid.uuid4().hex + + def samlize_token(self, issuer, recipient, user, roles, project, + expires_in=None): + """Convert Keystone attributes to a SAML assertion. + + :param issuer: URL of the issuing party + :type issuer: string + :param recipient: URL of the recipient + :type recipient: string + :param user: User name + :type user: string + :param roles: List of role names + :type roles: list + :param project: Project name + :type project: string + :param expires_in: Sets how long the assertion is valid for, in seconds + :type expires_in: int + + :return: XML <Response> object + + """ + expiration_time = self._determine_expiration_time(expires_in) + status = self._create_status() + saml_issuer = self._create_issuer(issuer) + subject = self._create_subject(user, expiration_time, recipient) + attribute_statement = self._create_attribute_statement(user, roles, + project) + authn_statement = self._create_authn_statement(issuer, expiration_time) + signature = self._create_signature() + + assertion = self._create_assertion(saml_issuer, signature, + subject, authn_statement, + attribute_statement) + + assertion = _sign_assertion(assertion) + + response = self._create_response(saml_issuer, status, assertion, + recipient) + return response + + def _determine_expiration_time(self, expires_in): + if expires_in is None: + expires_in = CONF.saml.assertion_expiration_time + now = timeutils.utcnow() + future = now + datetime.timedelta(seconds=expires_in) + return timeutils.isotime(future, subsecond=True) + + def _create_status(self): + """Create an object that represents a SAML Status. + + <ns0:Status xmlns:ns0="urn:oasis:names:tc:SAML:2.0:protocol"> + <ns0:StatusCode + Value="urn:oasis:names:tc:SAML:2.0:status:Success" /> + </ns0:Status> + + :return: XML <Status> object + + """ + status = samlp.Status() + status_code = samlp.StatusCode() + status_code.value = samlp.STATUS_SUCCESS + status_code.set_text('') + status.status_code = status_code + return status + + def _create_issuer(self, issuer_url): + """Create an object that represents a SAML Issuer. + + <ns0:Issuer + xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion" + Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity"> + https://acme.com/FIM/sps/openstack/saml20</ns0:Issuer> + + :return: XML <Issuer> object + + """ + issuer = saml.Issuer() + issuer.format = saml.NAMEID_FORMAT_ENTITY + issuer.set_text(issuer_url) + return issuer + + def _create_subject(self, user, expiration_time, recipient): + """Create an object that represents a SAML Subject. + + <ns0:Subject> + <ns0:NameID> + john@smith.com</ns0:NameID> + <ns0:SubjectConfirmation + Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> + <ns0:SubjectConfirmationData + NotOnOrAfter="2014-08-19T11:53:57.243106Z" + Recipient="http://beta.com/Shibboleth.sso/SAML2/POST" /> + </ns0:SubjectConfirmation> + </ns0:Subject> + + :return: XML <Subject> object + + """ + name_id = saml.NameID() + name_id.set_text(user) + subject_conf_data = saml.SubjectConfirmationData() + subject_conf_data.recipient = recipient + subject_conf_data.not_on_or_after = expiration_time + subject_conf = saml.SubjectConfirmation() + subject_conf.method = saml.SCM_BEARER + subject_conf.subject_confirmation_data = subject_conf_data + subject = saml.Subject() + subject.subject_confirmation = subject_conf + subject.name_id = name_id + return subject + + def _create_attribute_statement(self, user, roles, project): + """Create an object that represents a SAML AttributeStatement. + + <ns0:AttributeStatement + xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <ns0:Attribute Name="openstack_user"> + <ns0:AttributeValue + xsi:type="xs:string">test_user</ns0:AttributeValue> + </ns0:Attribute> + <ns0:Attribute Name="openstack_roles"> + <ns0:AttributeValue + xsi:type="xs:string">admin</ns0:AttributeValue> + <ns0:AttributeValue + xsi:type="xs:string">member</ns0:AttributeValue> + </ns0:Attribute> + <ns0:Attribute Name="openstack_projects"> + <ns0:AttributeValue + xsi:type="xs:string">development</ns0:AttributeValue> + </ns0:Attribute> + </ns0:AttributeStatement> + + :return: XML <AttributeStatement> object + + """ + openstack_user = 'openstack_user' + user_attribute = saml.Attribute() + user_attribute.name = openstack_user + user_value = saml.AttributeValue() + user_value.set_text(user) + user_attribute.attribute_value = user_value + + openstack_roles = 'openstack_roles' + roles_attribute = saml.Attribute() + roles_attribute.name = openstack_roles + + for role in roles: + role_value = saml.AttributeValue() + role_value.set_text(role) + roles_attribute.attribute_value.append(role_value) + + openstack_project = 'openstack_project' + project_attribute = saml.Attribute() + project_attribute.name = openstack_project + project_value = saml.AttributeValue() + project_value.set_text(project) + project_attribute.attribute_value = project_value + + attribute_statement = saml.AttributeStatement() + attribute_statement.attribute.append(user_attribute) + attribute_statement.attribute.append(roles_attribute) + attribute_statement.attribute.append(project_attribute) + return attribute_statement + + def _create_authn_statement(self, issuer, expiration_time): + """Create an object that represents a SAML AuthnStatement. + + <ns0:AuthnStatement xmlns:ns0="urn:oasis:names:tc:SAML:2.0:assertion" + AuthnInstant="2014-07-30T03:04:25Z" SessionIndex="47335964efb" + SessionNotOnOrAfter="2014-07-30T03:04:26Z"> + <ns0:AuthnContext> + <ns0:AuthnContextClassRef> + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + </ns0:AuthnContextClassRef> + <ns0:AuthenticatingAuthority> + https://acme.com/FIM/sps/openstack/saml20 + </ns0:AuthenticatingAuthority> + </ns0:AuthnContext> + </ns0:AuthnStatement> + + :return: XML <AuthnStatement> object + + """ + authn_statement = saml.AuthnStatement() + authn_statement.authn_instant = timeutils.isotime() + authn_statement.session_index = uuid.uuid4().hex + authn_statement.session_not_on_or_after = expiration_time + + authn_context = saml.AuthnContext() + authn_context_class = saml.AuthnContextClassRef() + authn_context_class.set_text(saml.AUTHN_PASSWORD) + + authn_authority = saml.AuthenticatingAuthority() + authn_authority.set_text(issuer) + authn_context.authn_context_class_ref = authn_context_class + authn_context.authenticating_authority = authn_authority + + authn_statement.authn_context = authn_context + + return authn_statement + + def _create_assertion(self, issuer, signature, subject, authn_statement, + attribute_statement): + """Create an object that represents a SAML Assertion. + + <ns0:Assertion + ID="35daed258ba647ba8962e9baff4d6a46" + IssueInstant="2014-06-11T15:45:58Z" + Version="2.0"> + <ns0:Issuer> ... </ns0:Issuer> + <ns1:Signature> ... </ns1:Signature> + <ns0:Subject> ... </ns0:Subject> + <ns0:AuthnStatement> ... </ns0:AuthnStatement> + <ns0:AttributeStatement> ... </ns0:AttributeStatement> + </ns0:Assertion> + + :return: XML <Assertion> object + + """ + assertion = saml.Assertion() + assertion.id = self.assertion_id + assertion.issue_instant = timeutils.isotime() + assertion.version = '2.0' + assertion.issuer = issuer + assertion.signature = signature + assertion.subject = subject + assertion.authn_statement = authn_statement + assertion.attribute_statement = attribute_statement + return assertion + + def _create_response(self, issuer, status, assertion, recipient): + """Create an object that represents a SAML Response. + + <ns0:Response + Destination="http://beta.com/Shibboleth.sso/SAML2/POST" + ID="c5954543230e4e778bc5b92923a0512d" + IssueInstant="2014-07-30T03:19:45Z" + Version="2.0" /> + <ns0:Issuer> ... </ns0:Issuer> + <ns0:Assertion> ... </ns0:Assertion> + <ns0:Status> ... </ns0:Status> + </ns0:Response> + + :return: XML <Response> object + + """ + response = samlp.Response() + response.id = uuid.uuid4().hex + response.destination = recipient + response.issue_instant = timeutils.isotime() + response.version = '2.0' + response.issuer = issuer + response.status = status + response.assertion = assertion + return response + + def _create_signature(self): + """Create an object that represents a SAML <Signature>. + + This must be filled with algorithms that the signing binary will apply + in order to sign the whole message. + Currently we enforce X509 signing. + Example of the template:: + + <Signature xmlns="http://www.w3.org/2000/09/xmldsig#"> + <SignedInfo> + <CanonicalizationMethod + Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/> + <SignatureMethod + Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/> + <Reference URI="#<Assertion ID>"> + <Transforms> + <Transform + Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/> + <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/> + </Transforms> + <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/> + <DigestValue /> + </Reference> + </SignedInfo> + <SignatureValue /> + <KeyInfo> + <X509Data /> + </KeyInfo> + </Signature> + + :return: XML <Signature> object + + """ + canonicalization_method = xmldsig.CanonicalizationMethod() + canonicalization_method.algorithm = xmldsig.ALG_EXC_C14N + signature_method = xmldsig.SignatureMethod( + algorithm=xmldsig.SIG_RSA_SHA1) + + transforms = xmldsig.Transforms() + envelope_transform = xmldsig.Transform( + algorithm=xmldsig.TRANSFORM_ENVELOPED) + + c14_transform = xmldsig.Transform(algorithm=xmldsig.ALG_EXC_C14N) + transforms.transform = [envelope_transform, c14_transform] + + digest_method = xmldsig.DigestMethod(algorithm=xmldsig.DIGEST_SHA1) + digest_value = xmldsig.DigestValue() + + reference = xmldsig.Reference() + reference.uri = '#' + self.assertion_id + reference.digest_method = digest_method + reference.digest_value = digest_value + reference.transforms = transforms + + signed_info = xmldsig.SignedInfo() + signed_info.canonicalization_method = canonicalization_method + signed_info.signature_method = signature_method + signed_info.reference = reference + + key_info = xmldsig.KeyInfo() + key_info.x509_data = xmldsig.X509Data() + + signature = xmldsig.Signature() + signature.signed_info = signed_info + signature.signature_value = xmldsig.SignatureValue() + signature.key_info = key_info + + return signature + + +def _sign_assertion(assertion): + """Sign a SAML assertion. + + This method utilizes ``xmlsec1`` binary and signs SAML assertions in a + separate process. ``xmlsec1`` cannot read input data from stdin so the + prepared assertion needs to be serialized and stored in a temporary + file. This file will be deleted immediately after ``xmlsec1`` returns. + The signed assertion is redirected to a standard output and read using + subprocess.PIPE redirection. A ``saml.Assertion`` class is created + from the signed string again and returned. + + Parameters that are required in the CONF:: + * xmlsec_binary + * private key file path + * public key file path + :return: XML <Assertion> object + + """ + xmlsec_binary = CONF.saml.xmlsec1_binary + idp_private_key = CONF.saml.keyfile + idp_public_key = CONF.saml.certfile + + # xmlsec1 --sign --privkey-pem privkey,cert --id-attr:ID <tag> <file> + certificates = '%(idp_private_key)s,%(idp_public_key)s' % { + 'idp_public_key': idp_public_key, + 'idp_private_key': idp_private_key + } + + command_list = [xmlsec_binary, '--sign', '--privkey-pem', certificates, + '--id-attr:ID', 'Assertion'] + + try: + # NOTE(gyee): need to make the namespace prefixes explicit so + # they won't get reassigned when we wrap the assertion into + # SAML2 response + file_path = fileutils.write_to_tempfile(assertion.to_string( + nspair={'saml': saml2.NAMESPACE, + 'xmldsig': xmldsig.NAMESPACE})) + command_list.append(file_path) + stdout = subprocess.check_output(command_list) + except Exception as e: + msg = _LE('Error when signing assertion, reason: %(reason)s') + msg = msg % {'reason': e} + LOG.error(msg) + raise exception.SAMLSigningError(reason=e) + finally: + try: + os.remove(file_path) + except OSError: + pass + + return saml2.create_class_from_xml_string(saml.Assertion, stdout) + + +class MetadataGenerator(object): + """A class for generating SAML IdP Metadata.""" + + def generate_metadata(self): + """Generate Identity Provider Metadata. + + Generate and format metadata into XML that can be exposed and + consumed by a federated Service Provider. + + :return: XML <EntityDescriptor> object. + :raises: keystone.exception.ValidationError: Raises if the required + config options aren't set. + + """ + self._ensure_required_values_present() + entity_descriptor = self._create_entity_descriptor() + entity_descriptor.idpsso_descriptor = ( + self._create_idp_sso_descriptor()) + return entity_descriptor + + def _create_entity_descriptor(self): + ed = md.EntityDescriptor() + ed.entity_id = CONF.saml.idp_entity_id + return ed + + def _create_idp_sso_descriptor(self): + + def get_cert(): + try: + return sigver.read_cert_from_file(CONF.saml.certfile, 'pem') + except (IOError, sigver.CertificateError) as e: + msg = _('Cannot open certificate %(cert_file)s. ' + 'Reason: %(reason)s') + msg = msg % {'cert_file': CONF.saml.certfile, 'reason': e} + LOG.error(msg) + raise IOError(msg) + + def key_descriptor(): + cert = get_cert() + return md.KeyDescriptor( + key_info=xmldsig.KeyInfo( + x509_data=xmldsig.X509Data( + x509_certificate=xmldsig.X509Certificate(text=cert) + ) + ), use='signing' + ) + + def single_sign_on_service(): + idp_sso_endpoint = CONF.saml.idp_sso_endpoint + return md.SingleSignOnService( + binding=saml2.BINDING_URI, + location=idp_sso_endpoint) + + def organization(): + name = md.OrganizationName(lang=CONF.saml.idp_lang, + text=CONF.saml.idp_organization_name) + display_name = md.OrganizationDisplayName( + lang=CONF.saml.idp_lang, + text=CONF.saml.idp_organization_display_name) + url = md.OrganizationURL(lang=CONF.saml.idp_lang, + text=CONF.saml.idp_organization_url) + + return md.Organization( + organization_display_name=display_name, + organization_url=url, organization_name=name) + + def contact_person(): + company = md.Company(text=CONF.saml.idp_contact_company) + given_name = md.GivenName(text=CONF.saml.idp_contact_name) + surname = md.SurName(text=CONF.saml.idp_contact_surname) + email = md.EmailAddress(text=CONF.saml.idp_contact_email) + telephone = md.TelephoneNumber( + text=CONF.saml.idp_contact_telephone) + contact_type = CONF.saml.idp_contact_type + + return md.ContactPerson( + company=company, given_name=given_name, sur_name=surname, + email_address=email, telephone_number=telephone, + contact_type=contact_type) + + def name_id_format(): + return md.NameIDFormat(text=saml.NAMEID_FORMAT_TRANSIENT) + + idpsso = md.IDPSSODescriptor() + idpsso.protocol_support_enumeration = samlp.NAMESPACE + idpsso.key_descriptor = key_descriptor() + idpsso.single_sign_on_service = single_sign_on_service() + idpsso.name_id_format = name_id_format() + if self._check_organization_values(): + idpsso.organization = organization() + if self._check_contact_person_values(): + idpsso.contact_person = contact_person() + return idpsso + + def _ensure_required_values_present(self): + """Ensure idp_sso_endpoint and idp_entity_id have values.""" + + if CONF.saml.idp_entity_id is None: + msg = _('Ensure configuration option idp_entity_id is set.') + raise exception.ValidationError(msg) + if CONF.saml.idp_sso_endpoint is None: + msg = _('Ensure configuration option idp_sso_endpoint is set.') + raise exception.ValidationError(msg) + + def _check_contact_person_values(self): + """Determine if contact information is included in metadata.""" + + # Check if we should include contact information + params = [CONF.saml.idp_contact_company, + CONF.saml.idp_contact_name, + CONF.saml.idp_contact_surname, + CONF.saml.idp_contact_email, + CONF.saml.idp_contact_telephone] + for value in params: + if value is None: + return False + + # Check if contact type is an invalid value + valid_type_values = ['technical', 'other', 'support', 'administrative', + 'billing'] + if CONF.saml.idp_contact_type not in valid_type_values: + msg = _('idp_contact_type must be one of: [technical, other, ' + 'support, administrative or billing.') + raise exception.ValidationError(msg) + return True + + def _check_organization_values(self): + """Determine if organization information is included in metadata.""" + + params = [CONF.saml.idp_organization_name, + CONF.saml.idp_organization_display_name, + CONF.saml.idp_organization_url] + for value in params: + if value is None: + return False + return True diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/__init__.py b/keystone-moon/keystone/contrib/federation/migrate_repo/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/__init__.py diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/migrate.cfg b/keystone-moon/keystone/contrib/federation/migrate_repo/migrate.cfg new file mode 100644 index 00000000..464ab62b --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/migrate.cfg @@ -0,0 +1,25 @@ +[db_settings] +# Used to identify which repository this database is versioned under. +# You can use the name of your project. +repository_id=federation + +# The name of the database table used to track the schema version. +# This name shouldn't already be used by your project. +# If this is changed once a database is under version control, you'll need to +# change the table name in each database too. +version_table=migrate_version + +# When committing a change script, Migrate will attempt to generate the +# sql for all supported databases; normally, if one of them fails - probably +# because you don't have that database installed - it is ignored and the +# commit continues, perhaps ending successfully. +# Databases in this list MUST compile successfully during a commit, or the +# entire commit will fail. List the databases your application will actually +# be using to ensure your updates to that database work properly. +# This must be a list; example: ['postgres','sqlite'] +required_dbs=[] + +# When creating new change scripts, Migrate will stamp the new script with +# a version number. By default this is latest_version + 1. You can set this +# to 'true' to tell Migrate to use the UTC timestamp instead. +use_timestamp_numbering=False diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/001_add_identity_provider_table.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/001_add_identity_provider_table.py new file mode 100644 index 00000000..cfb6f2c4 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/001_add_identity_provider_table.py @@ -0,0 +1,51 @@ +# 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 sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + idp_table = sql.Table( + 'identity_provider', + meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('enabled', sql.Boolean, nullable=False), + sql.Column('description', sql.Text(), nullable=True), + mysql_engine='InnoDB', + mysql_charset='utf8') + + idp_table.create(migrate_engine, checkfirst=True) + + federation_protocol_table = sql.Table( + 'federation_protocol', + meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('idp_id', sql.String(64), + sql.ForeignKey('identity_provider.id', ondelete='CASCADE'), + primary_key=True), + sql.Column('mapping_id', sql.String(64), nullable=True), + mysql_engine='InnoDB', + mysql_charset='utf8') + + federation_protocol_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + tables = ['federation_protocol', 'identity_provider'] + for table_name in tables: + table = sql.Table(table_name, meta, autoload=True) + table.drop() diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/002_add_mapping_tables.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/002_add_mapping_tables.py new file mode 100644 index 00000000..f827f9a9 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/002_add_mapping_tables.py @@ -0,0 +1,37 @@ +# 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 sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + mapping_table = sql.Table( + 'mapping', + meta, + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('rules', sql.Text(), nullable=False), + mysql_engine='InnoDB', + mysql_charset='utf8') + mapping_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + # Drop previously created tables + tables = ['mapping'] + for table_name in tables: + table = sql.Table(table_name, meta, autoload=True) + table.drop() diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/003_mapping_id_nullable_false.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/003_mapping_id_nullable_false.py new file mode 100644 index 00000000..eb8b2378 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/003_mapping_id_nullable_false.py @@ -0,0 +1,35 @@ +# Copyright 2014 Mirantis.inc +# All Rights Reserved. +# +# 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 sqlalchemy as sa + + +def upgrade(migrate_engine): + meta = sa.MetaData(bind=migrate_engine) + federation_protocol = sa.Table('federation_protocol', meta, autoload=True) + # NOTE(i159): The column is changed to non-nullable. To prevent + # database errors when the column will be altered, all the existing + # null-records should be filled with not null values. + stmt = (federation_protocol.update(). + where(federation_protocol.c.mapping_id.is_(None)). + values(mapping_id='')) + migrate_engine.execute(stmt) + federation_protocol.c.mapping_id.alter(nullable=False) + + +def downgrade(migrate_engine): + meta = sa.MetaData(bind=migrate_engine) + federation_protocol = sa.Table('federation_protocol', meta, autoload=True) + federation_protocol.c.mapping_id.alter(nullable=True) diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/004_add_remote_id_column.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/004_add_remote_id_column.py new file mode 100644 index 00000000..dbe5d1f1 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/004_add_remote_id_column.py @@ -0,0 +1,30 @@ +# 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. + +from oslo_db.sqlalchemy import utils +import sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + idp_table = utils.get_table(migrate_engine, 'identity_provider') + remote_id = sql.Column('remote_id', sql.String(256), nullable=True) + idp_table.create_column(remote_id) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + idp_table = utils.get_table(migrate_engine, 'identity_provider') + idp_table.drop_column('remote_id') diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/005_add_service_provider_table.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/005_add_service_provider_table.py new file mode 100644 index 00000000..bff6a252 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/005_add_service_provider_table.py @@ -0,0 +1,38 @@ +# 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 sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + sp_table = sql.Table( + 'service_provider', + meta, + sql.Column('auth_url', sql.String(256), nullable=True), + sql.Column('id', sql.String(64), primary_key=True), + sql.Column('enabled', sql.Boolean, nullable=False), + sql.Column('description', sql.Text(), nullable=True), + sql.Column('sp_url', sql.String(256), nullable=True), + mysql_engine='InnoDB', + mysql_charset='utf8') + + sp_table.create(migrate_engine, checkfirst=True) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + table = sql.Table('service_provider', meta, autoload=True) + table.drop() diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/006_fixup_service_provider_attributes.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/006_fixup_service_provider_attributes.py new file mode 100644 index 00000000..8a42ce3a --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/006_fixup_service_provider_attributes.py @@ -0,0 +1,48 @@ +# 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 sqlalchemy as sql + +_SP_TABLE_NAME = 'service_provider' + + +def _update_null_columns(migrate_engine, sp_table): + stmt = (sp_table.update(). + where(sp_table.c.auth_url.is_(None)). + values(auth_url='')) + migrate_engine.execute(stmt) + + stmt = (sp_table.update(). + where(sp_table.c.sp_url.is_(None)). + values(sp_url='')) + migrate_engine.execute(stmt) + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + sp_table = sql.Table(_SP_TABLE_NAME, meta, autoload=True) + # The columns are being changed to non-nullable. To prevent + # database errors when both are altered, all the existing + # null-records should be filled with not null values. + _update_null_columns(migrate_engine, sp_table) + + sp_table.c.auth_url.alter(nullable=False) + sp_table.c.sp_url.alter(nullable=False) + + +def downgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + sp_table = sql.Table(_SP_TABLE_NAME, meta, autoload=True) + sp_table.c.auth_url.alter(nullable=True) + sp_table.c.sp_url.alter(nullable=True) diff --git a/keystone-moon/keystone/contrib/federation/migrate_repo/versions/__init__.py b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/migrate_repo/versions/__init__.py diff --git a/keystone-moon/keystone/contrib/federation/routers.py b/keystone-moon/keystone/contrib/federation/routers.py new file mode 100644 index 00000000..9a6224b7 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/routers.py @@ -0,0 +1,226 @@ +# 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 functools + +from keystone.common import json_home +from keystone.common import wsgi +from keystone.contrib.federation import controllers + + +build_resource_relation = functools.partial( + json_home.build_v3_extension_resource_relation, + extension_name='OS-FEDERATION', extension_version='1.0') + +build_parameter_relation = functools.partial( + json_home.build_v3_extension_parameter_relation, + extension_name='OS-FEDERATION', extension_version='1.0') + +IDP_ID_PARAMETER_RELATION = build_parameter_relation(parameter_name='idp_id') +PROTOCOL_ID_PARAMETER_RELATION = build_parameter_relation( + parameter_name='protocol_id') +SP_ID_PARAMETER_RELATION = build_parameter_relation(parameter_name='sp_id') + + +class FederationExtension(wsgi.V3ExtensionRouter): + """API Endpoints for the Federation extension. + + The API looks like:: + + PUT /OS-FEDERATION/identity_providers/$identity_provider + GET /OS-FEDERATION/identity_providers + GET /OS-FEDERATION/identity_providers/$identity_provider + DELETE /OS-FEDERATION/identity_providers/$identity_provider + PATCH /OS-FEDERATION/identity_providers/$identity_provider + + PUT /OS-FEDERATION/identity_providers/ + $identity_provider/protocols/$protocol + GET /OS-FEDERATION/identity_providers/ + $identity_provider/protocols + GET /OS-FEDERATION/identity_providers/ + $identity_provider/protocols/$protocol + PATCH /OS-FEDERATION/identity_providers/ + $identity_provider/protocols/$protocol + DELETE /OS-FEDERATION/identity_providers/ + $identity_provider/protocols/$protocol + + PUT /OS-FEDERATION/mappings + GET /OS-FEDERATION/mappings + PATCH /OS-FEDERATION/mappings/$mapping_id + GET /OS-FEDERATION/mappings/$mapping_id + DELETE /OS-FEDERATION/mappings/$mapping_id + + GET /OS-FEDERATION/projects + GET /OS-FEDERATION/domains + + PUT /OS-FEDERATION/service_providers/$service_provider + GET /OS-FEDERATION/service_providers + GET /OS-FEDERATION/service_providers/$service_provider + DELETE /OS-FEDERATION/service_providers/$service_provider + PATCH /OS-FEDERATION/service_providers/$service_provider + + GET /OS-FEDERATION/identity_providers/$identity_provider/ + protocols/$protocol/auth + POST /OS-FEDERATION/identity_providers/$identity_provider/ + protocols/$protocol/auth + + POST /auth/OS-FEDERATION/saml2 + GET /OS-FEDERATION/saml2/metadata + + GET /auth/OS-FEDERATION/websso/{protocol_id} + ?origin=https%3A//horizon.example.com + + POST /auth/OS-FEDERATION/websso/{protocol_id} + ?origin=https%3A//horizon.example.com + + """ + def _construct_url(self, suffix): + return "/OS-FEDERATION/%s" % suffix + + def add_routes(self, mapper): + auth_controller = controllers.Auth() + idp_controller = controllers.IdentityProvider() + protocol_controller = controllers.FederationProtocol() + mapping_controller = controllers.MappingController() + project_controller = controllers.ProjectAssignmentV3() + domain_controller = controllers.DomainV3() + saml_metadata_controller = controllers.SAMLMetadataV3() + sp_controller = controllers.ServiceProvider() + + # Identity Provider CRUD operations + + self._add_resource( + mapper, idp_controller, + path=self._construct_url('identity_providers/{idp_id}'), + get_action='get_identity_provider', + put_action='create_identity_provider', + patch_action='update_identity_provider', + delete_action='delete_identity_provider', + rel=build_resource_relation(resource_name='identity_provider'), + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + }) + self._add_resource( + mapper, idp_controller, + path=self._construct_url('identity_providers'), + get_action='list_identity_providers', + rel=build_resource_relation(resource_name='identity_providers')) + + # Protocol CRUD operations + + self._add_resource( + mapper, protocol_controller, + path=self._construct_url('identity_providers/{idp_id}/protocols/' + '{protocol_id}'), + get_action='get_protocol', + put_action='create_protocol', + patch_action='update_protocol', + delete_action='delete_protocol', + rel=build_resource_relation( + resource_name='identity_provider_protocol'), + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, + }) + self._add_resource( + mapper, protocol_controller, + path=self._construct_url('identity_providers/{idp_id}/protocols'), + get_action='list_protocols', + rel=build_resource_relation( + resource_name='identity_provider_protocols'), + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + }) + + # Mapping CRUD operations + + self._add_resource( + mapper, mapping_controller, + path=self._construct_url('mappings/{mapping_id}'), + get_action='get_mapping', + put_action='create_mapping', + patch_action='update_mapping', + delete_action='delete_mapping', + rel=build_resource_relation(resource_name='mapping'), + path_vars={ + 'mapping_id': build_parameter_relation( + parameter_name='mapping_id'), + }) + self._add_resource( + mapper, mapping_controller, + path=self._construct_url('mappings'), + get_action='list_mappings', + rel=build_resource_relation(resource_name='mappings')) + + # Service Providers CRUD operations + + self._add_resource( + mapper, sp_controller, + path=self._construct_url('service_providers/{sp_id}'), + get_action='get_service_provider', + put_action='create_service_provider', + patch_action='update_service_provider', + delete_action='delete_service_provider', + rel=build_resource_relation(resource_name='service_provider'), + path_vars={ + 'sp_id': SP_ID_PARAMETER_RELATION, + }) + + self._add_resource( + mapper, sp_controller, + path=self._construct_url('service_providers'), + get_action='list_service_providers', + rel=build_resource_relation(resource_name='service_providers')) + + self._add_resource( + mapper, domain_controller, + path=self._construct_url('domains'), + get_action='list_domains_for_groups', + rel=build_resource_relation(resource_name='domains')) + self._add_resource( + mapper, project_controller, + path=self._construct_url('projects'), + get_action='list_projects_for_groups', + rel=build_resource_relation(resource_name='projects')) + self._add_resource( + mapper, auth_controller, + path=self._construct_url('identity_providers/{identity_provider}/' + 'protocols/{protocol}/auth'), + get_post_action='federated_authentication', + rel=build_resource_relation( + resource_name='identity_provider_protocol_auth'), + path_vars={ + 'identity_provider': IDP_ID_PARAMETER_RELATION, + 'protocol': PROTOCOL_ID_PARAMETER_RELATION, + }) + + # Auth operations + self._add_resource( + mapper, auth_controller, + path='/auth' + self._construct_url('saml2'), + post_action='create_saml_assertion', + rel=build_resource_relation(resource_name='saml2')) + self._add_resource( + mapper, auth_controller, + path='/auth' + self._construct_url('websso/{protocol_id}'), + get_post_action='federated_sso_auth', + rel=build_resource_relation(resource_name='websso'), + path_vars={ + 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, + }) + + # Keystone-Identity-Provider metadata endpoint + self._add_resource( + mapper, saml_metadata_controller, + path=self._construct_url('saml2/metadata'), + get_action='get_metadata', + rel=build_resource_relation(resource_name='metadata')) diff --git a/keystone-moon/keystone/contrib/federation/schema.py b/keystone-moon/keystone/contrib/federation/schema.py new file mode 100644 index 00000000..645e1129 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/schema.py @@ -0,0 +1,78 @@ +# 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. + +from keystone.common import validation +from keystone.common.validation import parameter_types + + +basic_property_id = { + 'type': 'object', + 'properties': { + 'id': { + 'type': 'string' + } + }, + 'required': ['id'], + 'additionalProperties': False +} + +saml_create = { + 'type': 'object', + 'properties': { + 'identity': { + 'type': 'object', + 'properties': { + 'token': basic_property_id, + 'methods': { + 'type': 'array' + } + }, + 'required': ['token'], + 'additionalProperties': False + }, + 'scope': { + 'type': 'object', + 'properties': { + 'service_provider': basic_property_id + }, + 'required': ['service_provider'], + 'additionalProperties': False + }, + }, + 'required': ['identity', 'scope'], + 'additionalProperties': False +} + +_service_provider_properties = { + # NOTE(rodrigods): The database accepts URLs with 256 as max length, + # but parameter_types.url uses 225 as max length. + 'auth_url': parameter_types.url, + 'sp_url': parameter_types.url, + 'description': validation.nullable(parameter_types.description), + 'enabled': parameter_types.boolean +} + +service_provider_create = { + 'type': 'object', + 'properties': _service_provider_properties, + # NOTE(rodrigods): 'id' is not required since it is passed in the URL + 'required': ['auth_url', 'sp_url'], + 'additionalProperties': False +} + +service_provider_update = { + 'type': 'object', + 'properties': _service_provider_properties, + # Make sure at least one property is being updated + 'minProperties': 1, + 'additionalProperties': False +} diff --git a/keystone-moon/keystone/contrib/federation/utils.py b/keystone-moon/keystone/contrib/federation/utils.py new file mode 100644 index 00000000..939fe9a0 --- /dev/null +++ b/keystone-moon/keystone/contrib/federation/utils.py @@ -0,0 +1,763 @@ +# 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. + +"""Utilities for Federation Extension.""" + +import ast +import re + +import jsonschema +from oslo_config import cfg +from oslo_log import log +from oslo_utils import timeutils +import six + +from keystone.contrib import federation +from keystone import exception +from keystone.i18n import _, _LW + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +MAPPING_SCHEMA = { + "type": "object", + "required": ['rules'], + "properties": { + "rules": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "required": ['local', 'remote'], + "additionalProperties": False, + "properties": { + "local": { + "type": "array" + }, + "remote": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "oneOf": [ + {"$ref": "#/definitions/empty"}, + {"$ref": "#/definitions/any_one_of"}, + {"$ref": "#/definitions/not_any_of"}, + {"$ref": "#/definitions/blacklist"}, + {"$ref": "#/definitions/whitelist"} + ], + } + } + } + } + } + }, + "definitions": { + "empty": { + "type": "object", + "required": ['type'], + "properties": { + "type": { + "type": "string" + }, + }, + "additionalProperties": False, + }, + "any_one_of": { + "type": "object", + "additionalProperties": False, + "required": ['type', 'any_one_of'], + "properties": { + "type": { + "type": "string" + }, + "any_one_of": { + "type": "array" + }, + "regex": { + "type": "boolean" + } + } + }, + "not_any_of": { + "type": "object", + "additionalProperties": False, + "required": ['type', 'not_any_of'], + "properties": { + "type": { + "type": "string" + }, + "not_any_of": { + "type": "array" + }, + "regex": { + "type": "boolean" + } + } + }, + "blacklist": { + "type": "object", + "additionalProperties": False, + "required": ['type', 'blacklist'], + "properties": { + "type": { + "type": "string" + }, + "blacklist": { + "type": "array" + } + } + }, + "whitelist": { + "type": "object", + "additionalProperties": False, + "required": ['type', 'whitelist'], + "properties": { + "type": { + "type": "string" + }, + "whitelist": { + "type": "array" + } + } + } + } +} + + +class DirectMaps(object): + """An abstraction around the remote matches. + + Each match is treated internally as a list. + """ + + def __init__(self): + self._matches = [] + + def add(self, values): + """Adds a matched value to the list of matches. + + :param list value: the match to save + + """ + self._matches.append(values) + + def __getitem__(self, idx): + """Used by Python when executing ``''.format(*DirectMaps())``.""" + value = self._matches[idx] + if isinstance(value, list) and len(value) == 1: + return value[0] + else: + return value + + +def validate_mapping_structure(ref): + v = jsonschema.Draft4Validator(MAPPING_SCHEMA) + + messages = '' + for error in sorted(v.iter_errors(ref), key=str): + messages = messages + error.message + "\n" + + if messages: + raise exception.ValidationError(messages) + + +def validate_expiration(token_ref): + if timeutils.utcnow() > token_ref.expires: + raise exception.Unauthorized(_('Federation token is expired')) + + +def validate_groups_cardinality(group_ids, mapping_id): + """Check if groups list is non-empty. + + :param group_ids: list of group ids + :type group_ids: list of str + + :raises exception.MissingGroups: if ``group_ids`` cardinality is 0 + + """ + if not group_ids: + raise exception.MissingGroups(mapping_id=mapping_id) + + +def validate_idp(idp, assertion): + """Check if the IdP providing the assertion is the one registered for + the mapping + """ + remote_id_parameter = CONF.federation.remote_id_attribute + if not remote_id_parameter or not idp['remote_id']: + LOG.warning(_LW('Impossible to identify the IdP %s '), + idp['id']) + # If nothing is defined, the administrator may want to + # allow the mapping of every IdP + return + try: + idp_remote_identifier = assertion[remote_id_parameter] + except KeyError: + msg = _('Could not find Identity Provider identifier in ' + 'environment, check [federation] remote_id_attribute ' + 'for details.') + raise exception.ValidationError(msg) + if idp_remote_identifier != idp['remote_id']: + msg = _('Incoming identity provider identifier not included ' + 'among the accepted identifiers.') + raise exception.Forbidden(msg) + + +def validate_groups_in_backend(group_ids, mapping_id, identity_api): + """Iterate over group ids and make sure they are present in the backend/ + + This call is not transactional. + :param group_ids: IDs of the groups to be checked + :type group_ids: list of str + + :param mapping_id: id of the mapping used for this operation + :type mapping_id: str + + :param identity_api: Identity Manager object used for communication with + backend + :type identity_api: identity.Manager + + :raises: exception.MappedGroupNotFound + + """ + for group_id in group_ids: + try: + identity_api.get_group(group_id) + except exception.GroupNotFound: + raise exception.MappedGroupNotFound( + group_id=group_id, mapping_id=mapping_id) + + +def validate_groups(group_ids, mapping_id, identity_api): + """Check group ids cardinality and check their existence in the backend. + + This call is not transactional. + :param group_ids: IDs of the groups to be checked + :type group_ids: list of str + + :param mapping_id: id of the mapping used for this operation + :type mapping_id: str + + :param identity_api: Identity Manager object used for communication with + backend + :type identity_api: identity.Manager + + :raises: exception.MappedGroupNotFound + :raises: exception.MissingGroups + + """ + validate_groups_cardinality(group_ids, mapping_id) + validate_groups_in_backend(group_ids, mapping_id, identity_api) + + +# TODO(marek-denis): Optimize this function, so the number of calls to the +# backend are minimized. +def transform_to_group_ids(group_names, mapping_id, + identity_api, assignment_api): + """Transform groups identitified by name/domain to their ids + + Function accepts list of groups identified by a name and domain giving + a list of group ids in return. + + Example of group_names parameter:: + + [ + { + "name": "group_name", + "domain": { + "id": "domain_id" + }, + }, + { + "name": "group_name_2", + "domain": { + "name": "domain_name" + } + } + ] + + :param group_names: list of group identified by name and its domain. + :type group_names: list + + :param mapping_id: id of the mapping used for mapping assertion into + local credentials + :type mapping_id: str + + :param identity_api: identity_api object + :param assignment_api: assignment_api object + + :returns: generator object with group ids + + :raises: excepton.MappedGroupNotFound: in case asked group doesn't + exist in the backend. + + """ + + def resolve_domain(domain): + """Return domain id. + + Input is a dictionary with a domain identified either by a ``id`` or a + ``name``. In the latter case system will attempt to fetch domain object + from the backend. + + :returns: domain's id + :rtype: str + + """ + domain_id = (domain.get('id') or + assignment_api.get_domain_by_name( + domain.get('name')).get('id')) + return domain_id + + for group in group_names: + try: + group_dict = identity_api.get_group_by_name( + group['name'], resolve_domain(group['domain'])) + yield group_dict['id'] + except exception.GroupNotFound: + LOG.debug('Skip mapping group %s; has no entry in the backend', + group['name']) + + +def get_assertion_params_from_env(context): + LOG.debug('Environment variables: %s', context['environment']) + prefix = CONF.federation.assertion_prefix + for k, v in context['environment'].items(): + if k.startswith(prefix): + yield (k, v) + + +class UserType(object): + """User mapping type.""" + EPHEMERAL = 'ephemeral' + LOCAL = 'local' + + +class RuleProcessor(object): + """A class to process assertions and mapping rules.""" + + class _EvalType(object): + """Mapping rule evaluation types.""" + ANY_ONE_OF = 'any_one_of' + NOT_ANY_OF = 'not_any_of' + BLACKLIST = 'blacklist' + WHITELIST = 'whitelist' + + def __init__(self, rules): + """Initialize RuleProcessor. + + Example rules can be found at: + :class:`keystone.tests.mapping_fixtures` + + :param rules: rules from a mapping + :type rules: dict + + """ + + self.rules = rules + + def process(self, assertion_data): + """Transform assertion to a dictionary of user name and group ids + based on mapping rules. + + This function will iterate through the mapping rules to find + assertions that are valid. + + :param assertion_data: an assertion containing values from an IdP + :type assertion_data: dict + + Example assertion_data:: + + { + 'Email': 'testacct@example.com', + 'UserName': 'testacct', + 'FirstName': 'Test', + 'LastName': 'Account', + 'orgPersonType': 'Tester' + } + + :returns: dictionary with user and group_ids + + The expected return structure is:: + + { + 'name': 'foobar', + 'group_ids': ['abc123', 'def456'], + 'group_names': [ + { + 'name': 'group_name_1', + 'domain': { + 'name': 'domain1' + } + }, + { + 'name': 'group_name_1_1', + 'domain': { + 'name': 'domain1' + } + }, + { + 'name': 'group_name_2', + 'domain': { + 'id': 'xyz132' + } + } + ] + } + + """ + + # Assertions will come in as string key-value pairs, and will use a + # semi-colon to indicate multiple values, i.e. groups. + # This will create a new dictionary where the values are arrays, and + # any multiple values are stored in the arrays. + LOG.debug('assertion data: %s', assertion_data) + assertion = {n: v.split(';') for n, v in assertion_data.items() + if isinstance(v, six.string_types)} + LOG.debug('assertion: %s', assertion) + identity_values = [] + + LOG.debug('rules: %s', self.rules) + for rule in self.rules: + direct_maps = self._verify_all_requirements(rule['remote'], + assertion) + + # If the compare comes back as None, then the rule did not apply + # to the assertion data, go on to the next rule + if direct_maps is None: + continue + + # If there are no direct mappings, then add the local mapping + # directly to the array of saved values. However, if there is + # a direct mapping, then perform variable replacement. + if not direct_maps: + identity_values += rule['local'] + else: + for local in rule['local']: + new_local = self._update_local_mapping(local, direct_maps) + identity_values.append(new_local) + + LOG.debug('identity_values: %s', identity_values) + mapped_properties = self._transform(identity_values) + LOG.debug('mapped_properties: %s', mapped_properties) + return mapped_properties + + def _transform(self, identity_values): + """Transform local mappings, to an easier to understand format. + + Transform the incoming array to generate the return value for + the process function. Generating content for Keystone tokens will + be easier if some pre-processing is done at this level. + + :param identity_values: local mapping from valid evaluations + :type identity_values: array of dict + + Example identity_values:: + + [ + { + 'group': {'id': '0cd5e9'}, + 'user': { + 'email': 'bob@example.com' + }, + }, + { + 'groups': ['member', 'admin', tester'], + 'domain': { + 'name': 'default_domain' + } + } + ] + + :returns: dictionary with user name, group_ids and group_names. + :rtype: dict + + """ + + def extract_groups(groups_by_domain): + for groups in groups_by_domain.values(): + for group in {g['name']: g for g in groups}.values(): + yield group + + def normalize_user(user): + """Parse and validate user mapping.""" + + user_type = user.get('type') + + if user_type and user_type not in (UserType.EPHEMERAL, + UserType.LOCAL): + msg = _("User type %s not supported") % user_type + raise exception.ValidationError(msg) + + if user_type is None: + user_type = user['type'] = UserType.EPHEMERAL + + if user_type == UserType.EPHEMERAL: + user['domain'] = { + 'id': (CONF.federation.federated_domain_name or + federation.FEDERATED_DOMAIN_KEYWORD) + } + + # initialize the group_ids as a set to eliminate duplicates + user = {} + group_ids = set() + group_names = list() + groups_by_domain = dict() + + for identity_value in identity_values: + if 'user' in identity_value: + # if a mapping outputs more than one user name, log it + if user: + LOG.warning(_LW('Ignoring user name')) + else: + user = identity_value.get('user') + if 'group' in identity_value: + group = identity_value['group'] + if 'id' in group: + group_ids.add(group['id']) + elif 'name' in group: + domain = (group['domain'].get('name') or + group['domain'].get('id')) + groups_by_domain.setdefault(domain, list()).append(group) + group_names.extend(extract_groups(groups_by_domain)) + if 'groups' in identity_value: + if 'domain' not in identity_value: + msg = _("Invalid rule: %(identity_value)s. Both 'groups' " + "and 'domain' keywords must be specified.") + msg = msg % {'identity_value': identity_value} + raise exception.ValidationError(msg) + # In this case, identity_value['groups'] is a string + # representation of a list, and we want a real list. This is + # due to the way we do direct mapping substitutions today (see + # function _update_local_mapping() ) + try: + group_names_list = ast.literal_eval( + identity_value['groups']) + except ValueError: + group_names_list = [identity_value['groups']] + domain = identity_value['domain'] + group_dicts = [{'name': name, 'domain': domain} for name in + group_names_list] + + group_names.extend(group_dicts) + + normalize_user(user) + + return {'user': user, + 'group_ids': list(group_ids), + 'group_names': group_names} + + def _update_local_mapping(self, local, direct_maps): + """Replace any {0}, {1} ... values with data from the assertion. + + :param local: local mapping reference that needs to be updated + :type local: dict + :param direct_maps: identity values used to update local + :type direct_maps: keystone.contrib.federation.utils.DirectMaps + + Example local:: + + {'user': {'name': '{0} {1}', 'email': '{2}'}} + + Example direct_maps:: + + ['Bob', 'Thompson', 'bob@example.com'] + + :returns: new local mapping reference with replaced values. + + The expected return structure is:: + + {'user': {'name': 'Bob Thompson', 'email': 'bob@example.org'}} + + """ + + LOG.debug('direct_maps: %s', direct_maps) + LOG.debug('local: %s', local) + new = {} + for k, v in six.iteritems(local): + if isinstance(v, dict): + new_value = self._update_local_mapping(v, direct_maps) + else: + new_value = v.format(*direct_maps) + new[k] = new_value + return new + + def _verify_all_requirements(self, requirements, assertion): + """Go through the remote requirements of a rule, and compare against + the assertion. + + If a value of ``None`` is returned, the rule with this assertion + doesn't apply. + If an array of zero length is returned, then there are no direct + mappings to be performed, but the rule is valid. + Otherwise, then it will first attempt to filter the values according + to blacklist or whitelist rules and finally return the values in + order, to be directly mapped. + + :param requirements: list of remote requirements from rules + :type requirements: list + + Example requirements:: + + [ + { + "type": "UserName" + }, + { + "type": "orgPersonType", + "any_one_of": [ + "Customer" + ] + }, + { + "type": "ADFS_GROUPS", + "whitelist": [ + "g1", "g2", "g3", "g4" + ] + } + ] + + :param assertion: dict of attributes from an IdP + :type assertion: dict + + Example assertion:: + + { + 'UserName': ['testacct'], + 'LastName': ['Account'], + 'orgPersonType': ['Tester'], + 'Email': ['testacct@example.com'], + 'FirstName': ['Test'], + 'ADFS_GROUPS': ['g1', 'g2'] + } + + :returns: identity values used to update local + :rtype: keystone.contrib.federation.utils.DirectMaps + + """ + + direct_maps = DirectMaps() + + for requirement in requirements: + requirement_type = requirement['type'] + regex = requirement.get('regex', False) + + any_one_values = requirement.get(self._EvalType.ANY_ONE_OF) + if any_one_values is not None: + if self._evaluate_requirement(any_one_values, + requirement_type, + self._EvalType.ANY_ONE_OF, + regex, + assertion): + continue + else: + return None + + not_any_values = requirement.get(self._EvalType.NOT_ANY_OF) + if not_any_values is not None: + if self._evaluate_requirement(not_any_values, + requirement_type, + self._EvalType.NOT_ANY_OF, + regex, + assertion): + continue + else: + return None + + # If 'any_one_of' or 'not_any_of' are not found, then values are + # within 'type'. Attempt to find that 'type' within the assertion, + # and filter these values if 'whitelist' or 'blacklist' is set. + direct_map_values = assertion.get(requirement_type) + if direct_map_values: + blacklisted_values = requirement.get(self._EvalType.BLACKLIST) + whitelisted_values = requirement.get(self._EvalType.WHITELIST) + + # If a blacklist or whitelist is used, we want to map to the + # whole list instead of just its values separately. + if blacklisted_values: + direct_map_values = [v for v in direct_map_values + if v not in blacklisted_values] + elif whitelisted_values: + direct_map_values = [v for v in direct_map_values + if v in whitelisted_values] + + direct_maps.add(direct_map_values) + + LOG.debug('updating a direct mapping: %s', direct_map_values) + + return direct_maps + + def _evaluate_values_by_regex(self, values, assertion_values): + for value in values: + for assertion_value in assertion_values: + if re.search(value, assertion_value): + return True + return False + + def _evaluate_requirement(self, values, requirement_type, + eval_type, regex, assertion): + """Evaluate the incoming requirement and assertion. + + If the requirement type does not exist in the assertion data, then + return False. If regex is specified, then compare the values and + assertion values. Otherwise, grab the intersection of the values + and use that to compare against the evaluation type. + + :param values: list of allowed values, defined in the requirement + :type values: list + :param requirement_type: key to look for in the assertion + :type requirement_type: string + :param eval_type: determine how to evaluate requirements + :type eval_type: string + :param regex: perform evaluation with regex + :type regex: boolean + :param assertion: dict of attributes from the IdP + :type assertion: dict + + :returns: boolean, whether requirement is valid or not. + + """ + + assertion_values = assertion.get(requirement_type) + if not assertion_values: + return False + + if regex: + any_match = self._evaluate_values_by_regex(values, + assertion_values) + else: + any_match = bool(set(values).intersection(set(assertion_values))) + if any_match and eval_type == self._EvalType.ANY_ONE_OF: + return True + if not any_match and eval_type == self._EvalType.NOT_ANY_OF: + return True + + return False + + +def assert_enabled_identity_provider(federation_api, idp_id): + identity_provider = federation_api.get_idp(idp_id) + if identity_provider.get('enabled') is not True: + msg = _('Identity Provider %(idp)s is disabled') % {'idp': idp_id} + LOG.debug(msg) + raise exception.Forbidden(msg) + + +def assert_enabled_service_provider_object(service_provider): + if service_provider.get('enabled') is not True: + sp_id = service_provider['id'] + msg = _('Service Provider %(sp)s is disabled') % {'sp': sp_id} + LOG.debug(msg) + raise exception.Forbidden(msg) |