summaryrefslogtreecommitdiffstats
path: root/keystone-moon/keystone/federation
diff options
context:
space:
mode:
authorRuan HE <ruan.he@orange.com>2016-06-09 08:12:34 +0000
committerGerrit Code Review <gerrit@172.30.200.206>2016-06-09 08:12:34 +0000
commit4bc079a2664f9a407e332291f34d174625a9d5ea (patch)
tree7481cd5d0a9b3ce37c44c797a1e0d39881221cbe /keystone-moon/keystone/federation
parent2f179c5790fbbf6144205d3c6e5089e6eb5f048a (diff)
parent2e7b4f2027a1147ca28301e4f88adf8274b39a1f (diff)
Merge "Update Keystone core to Mitaka."
Diffstat (limited to 'keystone-moon/keystone/federation')
-rw-r--r--keystone-moon/keystone/federation/V8_backends/__init__.py0
-rw-r--r--keystone-moon/keystone/federation/V8_backends/sql.py389
-rw-r--r--keystone-moon/keystone/federation/__init__.py15
-rw-r--r--keystone-moon/keystone/federation/backends/__init__.py0
-rw-r--r--keystone-moon/keystone/federation/backends/sql.py393
-rw-r--r--keystone-moon/keystone/federation/constants.py15
-rw-r--r--keystone-moon/keystone/federation/controllers.py519
-rw-r--r--keystone-moon/keystone/federation/core.py611
-rw-r--r--keystone-moon/keystone/federation/idp.py615
-rw-r--r--keystone-moon/keystone/federation/routers.py252
-rw-r--r--keystone-moon/keystone/federation/schema.py115
-rw-r--r--keystone-moon/keystone/federation/utils.py872
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)