diff options
Diffstat (limited to 'keystone-moon/keystone/federation')
-rw-r--r-- | keystone-moon/keystone/federation/V8_backends/__init__.py | 0 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/V8_backends/sql.py | 389 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/__init__.py | 15 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/backends/__init__.py | 0 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/backends/sql.py | 393 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/constants.py | 15 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/controllers.py | 519 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/core.py | 611 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/idp.py | 615 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/routers.py | 252 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/schema.py | 115 | ||||
-rw-r--r-- | keystone-moon/keystone/federation/utils.py | 872 |
12 files changed, 3796 insertions, 0 deletions
diff --git a/keystone-moon/keystone/federation/V8_backends/__init__.py b/keystone-moon/keystone/federation/V8_backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/federation/V8_backends/__init__.py diff --git a/keystone-moon/keystone/federation/V8_backends/sql.py b/keystone-moon/keystone/federation/V8_backends/sql.py new file mode 100644 index 00000000..d6b42aa0 --- /dev/null +++ b/keystone-moon/keystone/federation/V8_backends/sql.py @@ -0,0 +1,389 @@ +# 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_log import log +from oslo_serialization import jsonutils +import six +from sqlalchemy import orm + +from keystone.common import sql +from keystone import exception +from keystone.federation import core +from keystone.i18n import _ + + +LOG = log.getLogger(__name__) + + +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', 'enabled', 'description', 'remote_ids'] + mutable_attributes = frozenset(['description', 'enabled', 'remote_ids']) + + id = sql.Column(sql.String(64), primary_key=True) + enabled = sql.Column(sql.Boolean, nullable=False) + description = sql.Column(sql.Text(), nullable=True) + remote_ids = orm.relationship('IdPRemoteIdsModel', + order_by='IdPRemoteIdsModel.remote_id', + cascade='all, delete-orphan') + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + remote_ids_list = new_dictionary.pop('remote_ids', None) + if not remote_ids_list: + remote_ids_list = [] + identity_provider = cls(**new_dictionary) + remote_ids = [] + # NOTE(fmarco76): the remote_ids_list contains only remote ids + # associated with the IdP because of the "relationship" established in + # sqlalchemy and corresponding to the FK in the idp_remote_ids table + for remote in remote_ids_list: + remote_ids.append(IdPRemoteIdsModel(remote_id=remote)) + identity_provider.remote_ids = remote_ids + return identity_provider + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + d['remote_ids'] = [] + for remote in self.remote_ids: + d['remote_ids'].append(remote.remote_id) + return d + + +class IdPRemoteIdsModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'idp_remote_ids' + attributes = ['idp_id', 'remote_id'] + mutable_attributes = frozenset(['idp_id', 'remote_id']) + + idp_id = sql.Column(sql.String(64), + sql.ForeignKey('identity_provider.id', + ondelete='CASCADE')) + remote_id = sql.Column(sql.String(255), + primary_key=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() + new_dictionary['rules'] = jsonutils.dumps(new_dictionary['rules']) + 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) + d['rules'] = jsonutils.loads(d['rules']) + return d + + +class ServiceProviderModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'service_provider' + attributes = ['auth_url', 'id', 'enabled', 'description', + 'relay_state_prefix', 'sp_url'] + mutable_attributes = frozenset(['auth_url', 'description', 'enabled', + 'relay_state_prefix', '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) + relay_state_prefix = 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.FederationDriverV8): + + _CONFLICT_LOG_MSG = 'Conflict %(conflict_type)s: %(details)s' + + def _handle_idp_conflict(self, e): + conflict_type = 'identity_provider' + details = six.text_type(e) + LOG.debug(self._CONFLICT_LOG_MSG, {'conflict_type': conflict_type, + 'details': details}) + if 'remote_id' in details: + msg = _('Duplicate remote ID: %s') + else: + msg = _('Duplicate entry: %s') + msg = msg % e.value + raise exception.Conflict(type=conflict_type, details=msg) + + # Identity Provider CRUD + @sql.handle_conflicts(conflict_type='identity_provider') + def create_idp(self, idp_id, idp): + idp['id'] = idp_id + with sql.session_for_write() 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.session_for_write() as session: + self._delete_assigned_protocols(session, idp_id) + 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(IdPRemoteIdsModel) + 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.session_for_read() 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.session_for_read() 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.session_for_read() as session: + ref = self._get_idp_from_remote_id(session, remote_id) + return ref.to_dict() + + def update_idp(self, idp_id, idp): + try: + with sql.session_for_write() 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() + except sql.DBDuplicateEntry as e: + self._handle_idp_conflict(e) + + # 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.session_for_write() 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.session_for_write() 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.session_for_read() 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.session_for_read() 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.session_for_write() as session: + key_ref = self._get_protocol(session, idp_id, protocol_id) + session.delete(key_ref) + + def _delete_assigned_protocols(self, session, idp_id): + query = session.query(FederationProtocolModel) + query = query.filter_by(idp_id=idp_id) + query.delete() + + # 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'] = mapping.get('rules') + with sql.session_for_write() 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.session_for_write() as session: + mapping_ref = self._get_mapping(session, mapping_id) + session.delete(mapping_ref) + + def list_mappings(self): + with sql.session_for_read() as session: + mappings = session.query(MappingModel) + return [x.to_dict() for x in mappings] + + def get_mapping(self, mapping_id): + with sql.session_for_read() 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'] = mapping.get('rules') + with sql.session_for_write() 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.session_for_read() 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.session_for_write() 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.session_for_write() 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.session_for_read() 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.session_for_read() as session: + sp_ref = self._get_sp(session, sp_id) + return sp_ref.to_dict() + + def update_sp(self, sp_id, sp): + with sql.session_for_write() 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.session_for_read() as session: + service_providers = session.query(ServiceProviderModel) + service_providers = service_providers.filter_by(enabled=True) + return service_providers diff --git a/keystone-moon/keystone/federation/__init__.py b/keystone-moon/keystone/federation/__init__.py new file mode 100644 index 00000000..b62cfb6f --- /dev/null +++ b/keystone-moon/keystone/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.federation.core import * # noqa diff --git a/keystone-moon/keystone/federation/backends/__init__.py b/keystone-moon/keystone/federation/backends/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/keystone-moon/keystone/federation/backends/__init__.py diff --git a/keystone-moon/keystone/federation/backends/sql.py b/keystone-moon/keystone/federation/backends/sql.py new file mode 100644 index 00000000..add409e6 --- /dev/null +++ b/keystone-moon/keystone/federation/backends/sql.py @@ -0,0 +1,393 @@ +# 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_log import log +from oslo_serialization import jsonutils +import six +from sqlalchemy import orm + +from keystone.common import sql +from keystone import exception +from keystone.federation import core +from keystone.i18n import _ + + +LOG = log.getLogger(__name__) + + +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', 'enabled', 'description', 'remote_ids'] + mutable_attributes = frozenset(['description', 'enabled', 'remote_ids']) + + id = sql.Column(sql.String(64), primary_key=True) + enabled = sql.Column(sql.Boolean, nullable=False) + description = sql.Column(sql.Text(), nullable=True) + remote_ids = orm.relationship('IdPRemoteIdsModel', + order_by='IdPRemoteIdsModel.remote_id', + cascade='all, delete-orphan') + + @classmethod + def from_dict(cls, dictionary): + new_dictionary = dictionary.copy() + remote_ids_list = new_dictionary.pop('remote_ids', None) + if not remote_ids_list: + remote_ids_list = [] + identity_provider = cls(**new_dictionary) + remote_ids = [] + # NOTE(fmarco76): the remote_ids_list contains only remote ids + # associated with the IdP because of the "relationship" established in + # sqlalchemy and corresponding to the FK in the idp_remote_ids table + for remote in remote_ids_list: + remote_ids.append(IdPRemoteIdsModel(remote_id=remote)) + identity_provider.remote_ids = remote_ids + return identity_provider + + def to_dict(self): + """Return a dictionary with model's attributes.""" + d = dict() + for attr in self.__class__.attributes: + d[attr] = getattr(self, attr) + d['remote_ids'] = [] + for remote in self.remote_ids: + d['remote_ids'].append(remote.remote_id) + return d + + +class IdPRemoteIdsModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'idp_remote_ids' + attributes = ['idp_id', 'remote_id'] + mutable_attributes = frozenset(['idp_id', 'remote_id']) + + idp_id = sql.Column(sql.String(64), + sql.ForeignKey('identity_provider.id', + ondelete='CASCADE')) + remote_id = sql.Column(sql.String(255), + primary_key=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() + new_dictionary['rules'] = jsonutils.dumps(new_dictionary['rules']) + 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) + d['rules'] = jsonutils.loads(d['rules']) + return d + + +class ServiceProviderModel(sql.ModelBase, sql.DictBase): + __tablename__ = 'service_provider' + attributes = ['auth_url', 'id', 'enabled', 'description', + 'relay_state_prefix', 'sp_url'] + mutable_attributes = frozenset(['auth_url', 'description', 'enabled', + 'relay_state_prefix', '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) + relay_state_prefix = 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.FederationDriverV9): + + _CONFLICT_LOG_MSG = 'Conflict %(conflict_type)s: %(details)s' + + def _handle_idp_conflict(self, e): + conflict_type = 'identity_provider' + details = six.text_type(e) + LOG.debug(self._CONFLICT_LOG_MSG, {'conflict_type': conflict_type, + 'details': details}) + if 'remote_id' in details: + msg = _('Duplicate remote ID: %s') + else: + msg = _('Duplicate entry: %s') + msg = msg % e.value + raise exception.Conflict(type=conflict_type, details=msg) + + # Identity Provider CRUD + def create_idp(self, idp_id, idp): + idp['id'] = idp_id + try: + with sql.session_for_write() as session: + idp_ref = IdentityProviderModel.from_dict(idp) + session.add(idp_ref) + return idp_ref.to_dict() + except sql.DBDuplicateEntry as e: + self._handle_idp_conflict(e) + + def delete_idp(self, idp_id): + with sql.session_for_write() as session: + self._delete_assigned_protocols(session, idp_id) + 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(IdPRemoteIdsModel) + 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, hints=None): + with sql.session_for_read() as session: + query = session.query(IdentityProviderModel) + idps = sql.filter_limit_query(IdentityProviderModel, query, hints) + idps_list = [idp.to_dict() for idp in idps] + return idps_list + + def get_idp(self, idp_id): + with sql.session_for_read() 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.session_for_read() as session: + ref = self._get_idp_from_remote_id(session, remote_id) + return ref.to_dict() + + def update_idp(self, idp_id, idp): + try: + with sql.session_for_write() 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() + except sql.DBDuplicateEntry as e: + self._handle_idp_conflict(e) + + # 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.session_for_write() 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.session_for_write() 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.session_for_read() 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.session_for_read() 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.session_for_write() as session: + key_ref = self._get_protocol(session, idp_id, protocol_id) + session.delete(key_ref) + + def _delete_assigned_protocols(self, session, idp_id): + query = session.query(FederationProtocolModel) + query = query.filter_by(idp_id=idp_id) + query.delete() + + # 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'] = mapping.get('rules') + with sql.session_for_write() 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.session_for_write() as session: + mapping_ref = self._get_mapping(session, mapping_id) + session.delete(mapping_ref) + + def list_mappings(self): + with sql.session_for_read() as session: + mappings = session.query(MappingModel) + return [x.to_dict() for x in mappings] + + def get_mapping(self, mapping_id): + with sql.session_for_read() 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'] = mapping.get('rules') + with sql.session_for_write() 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.session_for_read() 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.session_for_write() 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.session_for_write() 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, hints=None): + with sql.session_for_read() as session: + query = session.query(ServiceProviderModel) + sps = sql.filter_limit_query(ServiceProviderModel, query, hints) + sps_list = [sp.to_dict() for sp in sps] + return sps_list + + def get_sp(self, sp_id): + with sql.session_for_read() as session: + sp_ref = self._get_sp(session, sp_id) + return sp_ref.to_dict() + + def update_sp(self, sp_id, sp): + with sql.session_for_write() 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.session_for_read() as session: + service_providers = session.query(ServiceProviderModel) + service_providers = service_providers.filter_by(enabled=True) + return service_providers diff --git a/keystone-moon/keystone/federation/constants.py b/keystone-moon/keystone/federation/constants.py new file mode 100644 index 00000000..afb38494 --- /dev/null +++ b/keystone-moon/keystone/federation/constants.py @@ -0,0 +1,15 @@ +# 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. + +FEDERATION = 'OS-FEDERATION' +IDENTITY_PROVIDER = 'OS-FEDERATION:identity_provider' +PROTOCOL = 'OS-FEDERATION:protocol' diff --git a/keystone-moon/keystone/federation/controllers.py b/keystone-moon/keystone/federation/controllers.py new file mode 100644 index 00000000..b9e2d883 --- /dev/null +++ b/keystone-moon/keystone/federation/controllers.py @@ -0,0 +1,519 @@ +# 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. + +"""Workflow logic for the Federation service.""" + +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 utils as k_utils +from keystone.common import validation +from keystone.common import wsgi +from keystone import exception +from keystone.federation import idp as keystone_idp +from keystone.federation import schema +from keystone.federation import utils +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' + + _public_parameters = frozenset(['id', 'enabled', 'description', + 'remote_ids', '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['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() + @validation.validated(schema.identity_provider_create, 'identity_provider') + def create_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(identity_provider) + identity_provider.setdefault('enabled', False) + 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.filterprotected('id', 'enabled') + def list_identity_providers(self, context, filters): + hints = self.build_driver_hints(context, filters) + ref = self.federation_api.list_idps(hints=hints) + ref = [self.filter_params(x) for x in ref] + return IdentityProvider.wrap_collection(context, ref, hints=hints) + + @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() + @validation.validated(schema.identity_provider_update, 'identity_provider') + def update_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(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 keystone.common.controller.V3Controller docstring for explanation + on _public_parameters class attributes. + + """ + + collection_name = 'protocols' + member_name = 'protocol' + + _public_parameters = frozenset(['id', 'mapping_id', 'links']) + + @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() + @validation.validated(schema.federation_protocol_schema, 'protocol') + def create_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + 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() + @validation.validated(schema.federation_protocol_schema, 'protocol') + def update_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + 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 _get_sso_origin_host(self, context): + """Validate and return originating dashboard URL. + + Make sure the parameter is specified in the request's URL as well its + value belongs to a list of trusted dashboards. + + :param context: request's context + :raises keystone.exception.ValidationError: ``origin`` query parameter + was not specified. The URL is deemed invalid. + :raises keystone.exception.Unauthorized: URL specified in origin query + parameter does not exist in list of websso trusted dashboards. + :returns: URL with the originating dashboard + + """ + if 'origin' in context['query_string']: + origin = context['query_string']['origin'] + host = urllib.parse.unquote_plus(origin) + else: + msg = _('Request must have an origin query parameter') + LOG.error(msg) + raise exception.ValidationError(msg) + + # change trusted_dashboard hostnames to lowercase before comparison + trusted_dashboards = [k_utils.lower_case_hostname(trusted) + for trusted in CONF.federation.trusted_dashboard] + + if host not in trusted_dashboards: + msg = _('%(host)s is not a trusted dashboard host') + msg = msg % {'host': host} + LOG.error(msg) + raise exception.Unauthorized(msg) + + return host + + def federated_authentication(self, context, idp_id, protocol_id): + """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_id], + protocol_id: { + 'identity_provider': idp_id, + 'protocol': protocol_id + } + } + } + + return self.authenticate_for_token(context, auth=auth) + + def federated_sso_auth(self, context, protocol_id): + try: + remote_id_name = utils.get_remote_id_parameter(protocol_id) + remote_id = context['environment'][remote_id_name] + except KeyError: + msg = _('Missing entity ID from environment') + LOG.error(msg) + raise exception.Unauthorized(msg) + + host = self._get_sso_origin_host(context) + + ref = self.federation_api.get_idp_from_remote_id(remote_id) + # NOTE(stevemar): the returned object is a simple dict that + # contains the idp_id and remote_id. + identity_provider = ref['idp_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) + + def federated_idp_specific_sso_auth(self, context, idp_id, protocol_id): + host = self._get_sso_origin_host(context) + + # NOTE(lbragstad): We validate that the Identity Provider actually + # exists in the Mapped authentication plugin. + res = self.federated_authentication(context, idp_id, protocol_id) + token_id = res.headers['X-Subject-Token'] + return self.render_html_response(host, token_id) + + 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) + + def _create_base_saml_assertion(self, context, auth): + 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['sp_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) + + if not token_ref.project_scoped: + action = _('Use a project scoped token when attempting to create ' + 'a SAML assertion') + raise exception.ForbiddenAction(action=action) + + subject = token_ref.user_name + roles = token_ref.role_names + project = token_ref.project_name + # NOTE(rodrigods): the domain name is necessary in order to distinguish + # between projects and users with the same name in different domains. + project_domain_name = token_ref.project_domain_name + subject_domain_name = token_ref.user_domain_name + + generator = keystone_idp.SAMLGenerator() + response = generator.samlize_token( + issuer, sp_url, subject, subject_domain_name, + roles, project, project_domain_name) + return (response, service_provider) + + def _build_response_headers(self, service_provider): + return [('Content-Type', 'text/xml'), + ('X-sp-url', six.binary_type(service_provider['sp_url'])), + ('X-auth-url', six.binary_type(service_provider['auth_url']))] + + @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 + """ + t = self._create_base_saml_assertion(context, auth) + (response, service_provider) = t + + headers = self._build_response_headers(service_provider) + return wsgi.render_response(body=response.to_string(), + status=('200', 'OK'), + headers=headers) + + @validation.validated(schema.saml_create, 'auth') + def create_ecp_assertion(self, context, auth): + """Exchange a scoped token for an ECP assertion. + + :param auth: Dictionary that contains a token and service provider ID + :returns: ECP Assertion based on properties from the token + """ + t = self._create_base_saml_assertion(context, auth) + (saml_assertion, service_provider) = t + relay_state_prefix = service_provider['relay_state_prefix'] + + generator = keystone_idp.ECPGenerator() + ecp_assertion = generator.generate_ecp(saml_assertion, + relay_state_prefix) + + headers = self._build_response_headers(service_provider) + return wsgi.render_response(body=ecp_assertion.to_string(), + status=('200', 'OK'), + headers=headers) + + +@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' + + _public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description', + 'links', 'relay_state_prefix', '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) + service_provider.setdefault('relay_state_prefix', + CONF.saml.relay_state_prefix) + 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.filterprotected('id', 'enabled') + def list_service_providers(self, context, filters): + hints = self.build_driver_hints(context, filters) + ref = self.federation_api.list_sps(hints=hints) + ref = [self.filter_params(x) for x in ref] + return ServiceProvider.wrap_collection(context, ref, hints=hints) + + @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) + 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/federation/core.py b/keystone-moon/keystone/federation/core.py new file mode 100644 index 00000000..23028dfd --- /dev/null +++ b/keystone-moon/keystone/federation/core.py @@ -0,0 +1,611 @@ +# 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. + +"""Main entry point into the Federation service.""" + +import abc + +from oslo_config import cfg +from oslo_log import versionutils +import six + +from keystone.common import dependency +from keystone.common import extension +from keystone.common import manager +from keystone import exception +from keystone.federation import utils + + +CONF = cfg.CONF +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': 'http://specs.openstack.org/openstack/keystone-specs/api/v3/' + 'identity-api-v3-os-federation-ext.html', + }]} +extension.register_admin_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) +extension.register_public_extension(EXTENSION_DATA['alias'], EXTENSION_DATA) + + +@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. + + """ + + driver_namespace = 'keystone.federation' + + def __init__(self): + super(Manager, self).__init__(CONF.federation.driver) + + # Make sure it is a driver version we support, and if it is a legacy + # driver, then wrap it. + if isinstance(self.driver, FederationDriverV8): + self.driver = V9FederationWrapperForV8Driver(self.driver) + elif not isinstance(self.driver, FederationDriverV9): + raise exception.UnsupportedDriverVersion( + driver=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 a unique, user defined identifier for service provider object + - auth_url is an 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] + + def evaluate(self, idp_id, protocol_id, assertion_data): + mapping = self.get_mapping_from_idp_and_protocol(idp_id, protocol_id) + rules = mapping['rules'] + rule_processor = utils.RuleProcessor(mapping['id'], rules) + mapped_properties = rule_processor.process(assertion_data) + return mapped_properties, mapping['id'] + + +# The FederationDriverBase class is the set of driver methods from earlier +# drivers that we still support, that have not been removed or modified. This +# class is then used to created the augmented V8 and V9 version abstract driver +# classes, without having to duplicate a lot of abstract method signatures. +# If you remove a method from V9, then move the abstract methods from this Base +# class to the V8 class. Do not modify any of the method signatures in the Base +# class - changes should only be made in the V8 and subsequent classes. + +@six.add_metaclass(abc.ABCMeta) +class FederationDriverBase(object): + + @abc.abstractmethod + def create_idp(self, idp_id, idp): + """Create an identity provider. + + :param idp_id: ID of IdP object + :type idp_id: string + :param idp: idp object + :type idp: dict + :returns: idp ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_idp(self, idp_id): + """Delete an identity provider. + + :param idp_id: ID of IdP object + :type idp_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_idp(self, idp_id): + """Get an identity provider by ID. + + :param idp_id: ID of IdP object + :type idp_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :returns: idp ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_idp_from_remote_id(self, remote_id): + """Get an identity provider by remote ID. + + :param remote_id: ID of remote IdP + :type idp_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :returns: idp ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_idp(self, idp_id, idp): + """Update an identity provider by ID. + + :param idp_id: ID of IdP object + :type idp_id: string + :param idp: idp object + :type idp: dict + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :returns: idp ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def create_protocol(self, idp_id, protocol_id, protocol): + """Add an IdP-Protocol configuration. + + :param idp_id: ID of IdP object + :type idp_id: string + :param protocol_id: ID of protocol object + :type protocol_id: string + :param protocol: protocol object + :type protocol: dict + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :returns: protocol ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_protocol(self, idp_id, protocol_id, protocol): + """Change an IdP-Protocol configuration. + + :param idp_id: ID of IdP object + :type idp_id: string + :param protocol_id: ID of protocol object + :type protocol_id: string + :param protocol: protocol object + :type protocol: dict + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :raises keystone.exception.FederatedProtocolNotFound: If the federated + protocol cannot be found. + :returns: protocol ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_protocol(self, idp_id, protocol_id): + """Get an IdP-Protocol configuration. + + :param idp_id: ID of IdP object + :type idp_id: string + :param protocol_id: ID of protocol object + :type protocol_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :raises keystone.exception.FederatedProtocolNotFound: If the federated + protocol cannot be found. + :returns: protocol ref + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_protocols(self, idp_id): + """List an IdP's supported protocols. + + :param idp_id: ID of IdP object + :type idp_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :returns: list of protocol ref + :rtype: list of dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_protocol(self, idp_id, protocol_id): + """Delete an IdP-Protocol configuration. + + :param idp_id: ID of IdP object + :type idp_id: string + :param protocol_id: ID of protocol object + :type protocol_id: string + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + :raises keystone.exception.FederatedProtocolNotFound: If the federated + protocol cannot be found. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def create_mapping(self, mapping_id, mapping): + """Create a mapping. + + :param mapping_id: ID of mapping object + :type mapping_id: string + :param mapping: mapping ref with mapping name + :type mapping: dict + :returns: mapping ref + :rtype: dict + + """ + 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 + :rtype: dict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_mappings(self): + """List all mappings. + + :returns: list of mapping refs + :rtype: list of dicts + + """ + 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 + :raises keystone.exception.MappingNotFound: If the mapping cannot + be found. + :returns: mapping ref + :rtype: dict + + """ + 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: If the IdP + doesn't exist. + :raises keystone.exception.FederatedProtocolNotFound: If the federated + protocol cannot be found. + :returns: mapping ref + :rtype: dict + + """ + 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: service provider 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: If the service + provider doesn't exist. + + """ + 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: service provider ref + :rtype: dict + + :raises keystone.exception.ServiceProviderNotFound: If the service + provider doesn't exist. + + """ + 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: service provider ref + :rtype: dict + + :raises keystone.exception.ServiceProviderNotFound: If the service + provider doesn't exist. + + """ + 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 a unique, user defined identifier for service provider object + - auth_url is an 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 + + +class FederationDriverV8(FederationDriverBase): + """Removed or redefined methods from V8. + + Move the abstract methods of any methods removed or modified in later + versions of the driver from FederationDriverBase to here. We maintain this + so that legacy drivers, which will be a subclass of FederationDriverV8, can + still reference them. + + """ + + @abc.abstractmethod + def list_idps(self): + """List all identity providers. + + :returns: list of idp refs + :rtype: list of dicts + + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_sps(self): + """List all service providers. + + :returns: List of service provider ref objects + :rtype: list of dicts + + """ + raise exception.NotImplemented() # pragma: no cover + + +class FederationDriverV9(FederationDriverBase): + """New or redefined methods from V8. + + Add any new V9 abstract methods (or those with modified signatures) to + this class. + + """ + + @abc.abstractmethod + def list_idps(self, hints): + """List all identity providers. + + :param hints: filter hints which the driver should + implement if at all possible. + :returns: list of idp refs + :rtype: list of dicts + + :raises keystone.exception.IdentityProviderNotFound: If the IdP + doesn't exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_sps(self, hints): + """List all service providers. + + :param hints: filter hints which the driver should + implement if at all possible. + :returns: List of service provider ref objects + :rtype: list of dicts + + :raises keystone.exception.ServiceProviderNotFound: If the SP + doesn't exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + +class V9FederationWrapperForV8Driver(FederationDriverV9): + """Wrapper class to supported a V8 legacy driver. + + In order to support legacy drivers without having to make the manager code + driver-version aware, we wrap legacy drivers so that they look like the + latest version. For the various changes made in a new driver, here are the + actions needed in this wrapper: + + Method removed from new driver - remove the call-through method from this + class, since the manager will no longer be + calling it. + Method signature (or meaning) changed - wrap the old method in a new + signature here, and munge the input + and output parameters accordingly. + New method added to new driver - add a method to implement the new + functionality here if possible. If that is + not possible, then return NotImplemented, + since we do not guarantee to support new + functionality with legacy drivers. + + """ + + @versionutils.deprecated( + as_of=versionutils.deprecated.MITAKA, + what='keystone.federation.FederationDriverV8', + in_favor_of='keystone.federation.FederationDriverV9', + remove_in=+2) + def __init__(self, wrapped_driver): + self.driver = wrapped_driver + + def create_idp(self, idp_id, idp): + return self.driver.create_idp(idp_id, idp) + + def delete_idp(self, idp_id): + self.driver.delete_idp(idp_id) + + # NOTE(davechen): The hints is ignored here to support legacy drivers, + # but the filters in hints will be remain unsatisfied and V3Controller + # wrapper will apply these filters at the end. So that the result get + # returned for list IdP will still be filtered with the legacy drivers. + def list_idps(self, hints): + return self.driver.list_idps() + + def get_idp(self, idp_id): + return self.driver.get_idp(idp_id) + + def get_idp_from_remote_id(self, remote_id): + return self.driver.get_idp_from_remote_id(remote_id) + + def update_idp(self, idp_id, idp): + return self.driver.update_idp(idp_id, idp) + + def create_protocol(self, idp_id, protocol_id, protocol): + return self.driver.create_protocol(idp_id, protocol_id, protocol) + + def update_protocol(self, idp_id, protocol_id, protocol): + return self.driver.update_protocol(idp_id, protocol_id, protocol) + + def get_protocol(self, idp_id, protocol_id): + return self.driver.get_protocol(idp_id, protocol_id) + + def list_protocols(self, idp_id): + return self.driver.list_protocols(idp_id) + + def delete_protocol(self, idp_id, protocol_id): + self.driver.delete_protocol(idp_id, protocol_id) + + def create_mapping(self, mapping_id, mapping): + return self.driver.create_mapping(mapping_id, mapping) + + def delete_mapping(self, mapping_id): + self.driver.delete_mapping(mapping_id) + + def update_mapping(self, mapping_id, mapping_ref): + return self.driver.update_mapping(mapping_id, mapping_ref) + + def list_mappings(self): + return self.driver.list_mappings() + + def get_mapping(self, mapping_id): + return self.driver.get_mapping(mapping_id) + + def get_mapping_from_idp_and_protocol(self, idp_id, protocol_id): + return self.driver.get_mapping_from_idp_and_protocol( + idp_id, protocol_id) + + def create_sp(self, sp_id, sp): + return self.driver.create_sp(sp_id, sp) + + def delete_sp(self, sp_id): + self.driver.delete_sp(sp_id) + + # NOTE(davechen): The hints is ignored here to support legacy drivers, + # but the filters in hints will be remain unsatisfied and V3Controller + # wrapper will apply these filters at the end. So that the result get + # returned for list SPs will still be filtered with the legacy drivers. + def list_sps(self, hints): + return self.driver.list_sps() + + def get_sp(self, sp_id): + return self.driver.get_sp(sp_id) + + def update_sp(self, sp_id, sp): + return self.driver.update_sp(sp_id, sp) + + def get_enabled_service_providers(self): + return self.driver.get_enabled_service_providers() + + +Driver = manager.create_legacy_driver(FederationDriverV8) diff --git a/keystone-moon/keystone/federation/idp.py b/keystone-moon/keystone/federation/idp.py new file mode 100644 index 00000000..494d58b9 --- /dev/null +++ b/keystone-moon/keystone/federation/idp.py @@ -0,0 +1,615 @@ +# 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 uuid + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import fileutils +from oslo_utils import importutils +from oslo_utils import timeutils +import saml2 +from saml2 import client_base +from saml2 import md +from saml2.profile import ecp +from saml2 import saml +from saml2 import samlp +from saml2.schema import soapenv +from saml2 import sigver +xmldsig = importutils.try_import("saml2.xmldsig") +if not xmldsig: + xmldsig = importutils.try_import("xmldsig") + +from keystone.common import environment +from keystone.common import utils +from keystone import exception +from keystone.i18n import _, _LE + + +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, user_domain_name, roles, + project, project_domain_name, 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 user_domain_name: User Domain name + :type user_domain_name: string + :param roles: List of role names + :type roles: list + :param project: Project name + :type project: string + :param project_domain_name: Project Domain name + :type project_domain_name: string + :param expires_in: Sets how long the assertion is valid for, in seconds + :type expires_in: int + + :returns: 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, user_domain_name, roles, project, project_domain_name) + 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 utils.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> + + :returns: 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> + + :returns: 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> + + :returns: 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, user_domain_name, roles, + project, project_domain_name): + """Create an object that represents a SAML AttributeStatement. + + <ns0:AttributeStatement> + <ns0:Attribute Name="openstack_user"> + <ns0:AttributeValue + xsi:type="xs:string">test_user</ns0:AttributeValue> + </ns0:Attribute> + <ns0:Attribute Name="openstack_user_domain"> + <ns0:AttributeValue + xsi:type="xs:string">Default</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_project"> + <ns0:AttributeValue + xsi:type="xs:string">development</ns0:AttributeValue> + </ns0:Attribute> + <ns0:Attribute Name="openstack_project_domain"> + <ns0:AttributeValue + xsi:type="xs:string">Default</ns0:AttributeValue> + </ns0:Attribute> + </ns0:AttributeStatement> + + :returns: XML <AttributeStatement> object + + """ + def _build_attribute(attribute_name, attribute_values): + attribute = saml.Attribute() + attribute.name = attribute_name + + for value in attribute_values: + attribute_value = saml.AttributeValue() + attribute_value.set_text(value) + attribute.attribute_value.append(attribute_value) + + return attribute + + user_attribute = _build_attribute('openstack_user', [user]) + roles_attribute = _build_attribute('openstack_roles', roles) + project_attribute = _build_attribute('openstack_project', [project]) + project_domain_attribute = _build_attribute( + 'openstack_project_domain', [project_domain_name]) + user_domain_attribute = _build_attribute( + 'openstack_user_domain', [user_domain_name]) + + attribute_statement = saml.AttributeStatement() + attribute_statement.attribute.append(user_attribute) + attribute_statement.attribute.append(roles_attribute) + attribute_statement.attribute.append(project_attribute) + attribute_statement.attribute.append(project_domain_attribute) + attribute_statement.attribute.append(user_domain_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> + + :returns: XML <AuthnStatement> object + + """ + authn_statement = saml.AuthnStatement() + authn_statement.authn_instant = utils.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> + + :returns: XML <Assertion> object + + """ + assertion = saml.Assertion() + assertion.id = self.assertion_id + assertion.issue_instant = utils.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> + + :returns: XML <Response> object + + """ + response = samlp.Response() + response.id = uuid.uuid4().hex + response.destination = recipient + response.issue_instant = utils.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> + + :returns: 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 + :returns: 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'] + + file_path = None + 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) + subprocess = environment.subprocess + stdout = subprocess.check_output(command_list, # nosec : The contents + # of the command list are coming from + # a trusted source because the + # executable and arguments all either + # come from the config file or are + # hardcoded. The command list is + # initialized earlier in this function + # to a list and it's still a list at + # this point in the function. There is + # no opportunity for an attacker to + # attempt command injection via string + # parsing. + stderr=subprocess.STDOUT) + except Exception as e: + msg = _LE('Error when signing assertion, reason: %(reason)s%(output)s') + LOG.error(msg, + {'reason': e, + 'output': ' ' + e.output if hasattr(e, 'output') else ''}) + raise exception.SAMLSigningError(reason=e) + finally: + try: + if file_path: + os.remove(file_path) + except OSError: # nosec + # The file is already gone, good. + 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. + + :returns: XML <EntityDescriptor> object. + :raises keystone.exception.ValidationError: 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 + + +class ECPGenerator(object): + """A class for generating an ECP assertion.""" + + @staticmethod + def generate_ecp(saml_assertion, relay_state_prefix): + ecp_generator = ECPGenerator() + header = ecp_generator._create_header(relay_state_prefix) + body = ecp_generator._create_body(saml_assertion) + envelope = soapenv.Envelope(header=header, body=body) + return envelope + + def _create_header(self, relay_state_prefix): + relay_state_text = relay_state_prefix + uuid.uuid4().hex + relay_state = ecp.RelayState(actor=client_base.ACTOR, + must_understand='1', + text=relay_state_text) + header = soapenv.Header() + header.extension_elements = ( + [saml2.element_to_extension_element(relay_state)]) + return header + + def _create_body(self, saml_assertion): + body = soapenv.Body() + body.extension_elements = ( + [saml2.element_to_extension_element(saml_assertion)]) + return body diff --git a/keystone-moon/keystone/federation/routers.py b/keystone-moon/keystone/federation/routers.py new file mode 100644 index 00000000..a463ca63 --- /dev/null +++ b/keystone-moon/keystone/federation/routers.py @@ -0,0 +1,252 @@ +# 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.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 Routers(wsgi.RoutersBase): + """API Endpoints for the Federation extension. + + The API looks like:: + + PUT /OS-FEDERATION/identity_providers/{idp_id} + GET /OS-FEDERATION/identity_providers + GET /OS-FEDERATION/identity_providers/{idp_id} + DELETE /OS-FEDERATION/identity_providers/{idp_id} + PATCH /OS-FEDERATION/identity_providers/{idp_id} + + PUT /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + GET /OS-FEDERATION/identity_providers/ + {idp_id}/protocols + GET /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + PATCH /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + DELETE /OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id} + + 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/{sp_id} + GET /OS-FEDERATION/service_providers + GET /OS-FEDERATION/service_providers/{sp_id} + DELETE /OS-FEDERATION/service_providers/{sp_id} + PATCH /OS-FEDERATION/service_providers/{sp_id} + + GET /OS-FEDERATION/identity_providers/{idp_id}/ + protocols/{protocol_id}/auth + POST /OS-FEDERATION/identity_providers/{idp_id}/ + protocols/{protocol_id}/auth + GET /auth/OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id}/websso + ?origin=https%3A//horizon.example.com + POST /auth/OS-FEDERATION/identity_providers/ + {idp_id}/protocols/{protocol_id}/websso + ?origin=https%3A//horizon.example.com + + + POST /auth/OS-FEDERATION/saml2 + POST /auth/OS-FEDERATION/saml2/ecp + 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 append_v3_routers(self, mapper, routers): + 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'), + new_path='/auth/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'), + new_path='/auth/projects', + get_action='list_projects_for_groups', + rel=build_resource_relation(resource_name='projects')) + + # Auth operations + self._add_resource( + mapper, auth_controller, + path=self._construct_url('identity_providers/{idp_id}/' + 'protocols/{protocol_id}/auth'), + get_post_action='federated_authentication', + rel=build_resource_relation( + resource_name='identity_provider_protocol_auth'), + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + 'protocol_id': PROTOCOL_ID_PARAMETER_RELATION, + }) + 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('saml2/ecp'), + post_action='create_ecp_assertion', + rel=build_resource_relation(resource_name='ecp')) + 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, + }) + self._add_resource( + mapper, auth_controller, + path='/auth' + self._construct_url( + 'identity_providers/{idp_id}/protocols/{protocol_id}/websso'), + get_post_action='federated_idp_specific_sso_auth', + rel=build_resource_relation(resource_name='identity_providers'), + path_vars={ + 'idp_id': IDP_ID_PARAMETER_RELATION, + '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/federation/schema.py b/keystone-moon/keystone/federation/schema.py new file mode 100644 index 00000000..6cdfd1f5 --- /dev/null +++ b/keystone-moon/keystone/federation/schema.py @@ -0,0 +1,115 @@ +# 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, + 'relay_state_prefix': validation.nullable(parameter_types.description) +} + +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 +} + +_identity_provider_properties = { + 'enabled': parameter_types.boolean, + 'description': validation.nullable(parameter_types.description), + 'remote_ids': { + 'type': ['array', 'null'], + 'items': { + 'type': 'string' + }, + 'uniqueItems': True + } +} + +identity_provider_create = { + 'type': 'object', + 'properties': _identity_provider_properties, + 'additionalProperties': False +} + +identity_provider_update = { + 'type': 'object', + 'properties': _identity_provider_properties, + # Make sure at least one property is being updated + 'minProperties': 1, + 'additionalProperties': False +} + +federation_protocol_schema = { + 'type': 'object', + 'properties': { + 'mapping_id': parameter_types.mapping_id_string + }, + # `mapping_id` is the property that cannot be ignored + 'minProperties': 1, + 'additionalProperties': False +} diff --git a/keystone-moon/keystone/federation/utils.py b/keystone-moon/keystone/federation/utils.py new file mode 100644 index 00000000..1d215a68 --- /dev/null +++ b/keystone-moon/keystone/federation/utils.py @@ -0,0 +1,872 @@ +# 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 import exception +from keystone.i18n import _, _LW + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class UserType(object): + """User mapping type.""" + + EPHEMERAL = 'ephemeral' + LOCAL = 'local' + + +MAPPING_SCHEMA = { + "type": "object", + "required": ['rules'], + "properties": { + "rules": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "required": ['local', 'remote'], + "additionalProperties": False, + "properties": { + "local": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "user": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "email": {"type": "string"}, + "domain": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + }, + "additionalProperties": False, + }, + "type": { + "type": "string", + "enum": [UserType.EPHEMERAL, + UserType.LOCAL] + } + }, + "additionalProperties": False + }, + "group": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "domain": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + }, + "additionalProperties": False, + }, + }, + "additionalProperties": False, + }, + "groups": { + "type": "string" + }, + "group_ids": { + "type": "string" + }, + "domain": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"} + }, + "additionalProperties": False + } + } + } + }, + "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 keystone.exception.MissingGroups: if ``group_ids`` cardinality is 0 + + """ + if not group_ids: + raise exception.MissingGroups(mapping_id=mapping_id) + + +def get_remote_id_parameter(protocol): + # NOTE(marco-fargetta): Since we support any protocol ID, we attempt to + # retrieve the remote_id_attribute of the protocol ID. If it's not + # registered in the config, then register the option and try again. + # This allows the user to register protocols other than oidc and saml2. + remote_id_parameter = None + try: + remote_id_parameter = CONF[protocol]['remote_id_attribute'] + except AttributeError: + CONF.register_opt(cfg.StrOpt('remote_id_attribute'), + group=protocol) + try: + remote_id_parameter = CONF[protocol]['remote_id_attribute'] + except AttributeError: # nosec + # No remote ID attr, will be logged and use the default instead. + pass + if not remote_id_parameter: + LOG.debug('Cannot find "remote_id_attribute" in configuration ' + 'group %s. Trying default location in ' + 'group federation.', protocol) + remote_id_parameter = CONF.federation.remote_id_attribute + + return remote_id_parameter + + +def validate_idp(idp, protocol, assertion): + """The IdP providing the assertion should be registered for the mapping.""" + remote_id_parameter = get_remote_id_parameter(protocol) + if not remote_id_parameter or not idp['remote_ids']: + LOG.debug('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') + raise exception.ValidationError(msg) + if idp_remote_identifier not in idp['remote_ids']: + 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 keystone.exception.MappedGroupNotFound: If the group returned by + mapping was not found in the backend. + + """ + 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 keystone.exception.MappedGroupNotFound: If the group returned by + mapping was not found in the backend. + :raises keystone.exception.MissingGroups: If ``group_ids`` cardinality + is 0. + + """ + 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, resource_api): + """Transform groups identified 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 resource_api: resource manager object + + :returns: generator object with group ids + + :raises keystone.exception.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 + resource_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 list(context['environment'].items()): + if not k.startswith(prefix): + continue + # These bytes may be decodable as ISO-8859-1 according to Section + # 3.2.4 of RFC 7230. Let's assume that our web server plugins are + # correctly encoding the data. + if not isinstance(v, six.text_type) and getattr(v, 'decode', False): + v = v.decode('ISO-8859-1') + yield (k, v) + + +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, mapping_id, rules): + """Initialize RuleProcessor. + + Example rules can be found at: + :class:`keystone.tests.mapping_fixtures` + + :param mapping_id: id for the mapping + :type mapping_id: string + :param rules: rules from a mapping + :type rules: dict + + """ + self.mapping_id = mapping_id + self.rules = rules + + def process(self, assertion_data): + """Transform assertion to a dictionary. + + The dictionary contains mapping 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' + } + }, + { + 'group_ids': ['abc123', 'def456', '0cd5e9'] + } + ] + + :returns: dictionary with user name, group_ids and group_names. + :rtype: dict + + """ + def extract_groups(groups_by_domain): + for groups in list(groups_by_domain.values()): + for group in list({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 + } + + # initialize the group_ids as a set to eliminate duplicates + user = {} + group_ids = set() + group_names = list() + groups_by_domain = dict() + + # if mapping yield no valid identity values, we should bail right away + # instead of continuing on with a normalized bogus user + if not identity_values: + msg = _("Could not map any federated user properties to identity " + "values. Check debug logs or the mapping used for " + "additional details.") + LOG.warning(msg) + raise exception.ValidationError(msg) + + 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) + if 'group_ids' in identity_value: + # If identity_values['group_ids'] is a string representation + # of a list, parse it to a real list. Also, if the provided + # group_ids parameter contains only one element, it will be + # parsed as a simple string, and not a list or the + # representation of a list. + try: + group_ids.update( + ast.literal_eval(identity_value['group_ids'])) + except (ValueError, SyntaxError): + group_ids.update([identity_value['group_ids']]) + + 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.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'}} + + :raises keystone.exception.DirectMappingError: when referring to a + remote match from a local section of a rule + + """ + LOG.debug('direct_maps: %s', direct_maps) + LOG.debug('local: %s', local) + new = {} + for k, v in local.items(): + if isinstance(v, dict): + new_value = self._update_local_mapping(v, direct_maps) + else: + try: + new_value = v.format(*direct_maps) + except IndexError: + raise exception.DirectMappingError( + mapping_id=self.mapping_id) + + new[k] = new_value + return new + + def _verify_all_requirements(self, requirements, assertion): + """Compare remote requirements of a rule 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.federation.utils.DirectMaps or None + + """ + direct_maps = DirectMaps() + + for requirement in requirements: + requirement_type = requirement['type'] + direct_map_values = assertion.get(requirement_type) + regex = requirement.get('regex', False) + + if not direct_map_values: + return None + + any_one_values = requirement.get(self._EvalType.ANY_ONE_OF) + if any_one_values is not None: + if self._evaluate_requirement(any_one_values, + direct_map_values, + self._EvalType.ANY_ONE_OF, + regex): + 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, + direct_map_values, + self._EvalType.NOT_ANY_OF, + regex): + 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. + 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 is not None: + direct_map_values = [v for v in direct_map_values + if v not in blacklisted_values] + elif whitelisted_values is not None: + 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, assertion_values, + eval_type, regex): + """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 assertion_values: The values from the assertion to evaluate + :type assertion_values: list/string + :param eval_type: determine how to evaluate requirements + :type eval_type: string + :param regex: perform evaluation with regex + :type regex: boolean + + :returns: boolean, whether requirement is valid or not. + + """ + 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) |