From b764a4daa0197264e28752bed93c1afb19452526 Mon Sep 17 00:00:00 2001 From: Ronald De Rose Date: Thu, 11 Feb 2016 16:26:48 +0000 Subject: [PATCH] Shadow users - Shadow federated users "Shadow users: unified identity" implementation: Federated users have a idp_id, protocol_id, display name, and a unique ID asserted by the identity provider. These are the minimal pieces of data required to identify returning users and provide them with a consistent identity. Note: the following work items left will be completed in a separate patch: * Allow concrete role assignments for federated users * Shadowing LDAP users bp shadow-users Change-Id: Ieb582947038b4a75ef4237939ad8a90079b38aa8 --- keystone/auth/plugins/mapped.py | 16 ++-- keystone/common/config.py | 6 ++ .../versions/094_add_federated_user_table.py | 43 ++++++++++ keystone/identity/backends/sql.py | 35 +++++++- keystone/identity/core.py | 82 ++++++++++++++++++- keystone/identity/shadow_backends/__init__.py | 0 keystone/identity/shadow_backends/sql.py | 73 +++++++++++++++++ keystone/server/backends.py | 1 + .../unit/contrib/federation/test_utils.py | 47 ++++++----- keystone/tests/unit/core.py | 11 +++ keystone/tests/unit/test_backend_sql.py | 47 +++++++++++ keystone/tests/unit/test_sql_upgrade.py | 13 +++ keystone/tests/unit/test_v3_federation.py | 28 +++++++ keystone/tests/unit/test_v3_identity.py | 16 ++++ setup.cfg | 3 + 15 files changed, 388 insertions(+), 33 deletions(-) create mode 100644 keystone/common/sql/migrate_repo/versions/094_add_federated_user_table.py create mode 100644 keystone/identity/shadow_backends/__init__.py create mode 100644 keystone/identity/shadow_backends/sql.py diff --git a/keystone/auth/plugins/mapped.py b/keystone/auth/plugins/mapped.py index ad630911cf..612c73bd47 100644 --- a/keystone/auth/plugins/mapped.py +++ b/keystone/auth/plugins/mapped.py @@ -140,7 +140,12 @@ def handle_unscoped_token(context, auth_payload, auth_context, federation_api, identity_api) if is_ephemeral_user(mapped_properties): - user = setup_username(context, mapped_properties) + unique_id, display_name = ( + get_user_unique_id_and_display_name(context, mapped_properties) + ) + user = identity_api.shadow_federated_user(identity_provider, + protocol, unique_id, + display_name) user_id = user['id'] group_ids = mapped_properties['group_ids'] utils.validate_groups_cardinality(group_ids, mapping_id) @@ -201,7 +206,7 @@ def apply_mapping_filter(identity_provider, protocol, assertion, return mapped_properties, mapping_id -def setup_username(context, mapped_properties): +def get_user_unique_id_and_display_name(context, mapped_properties): """Setup federated username. Function covers all the cases for properly setting user id, a primary @@ -223,8 +228,8 @@ def setup_username(context, mapped_properties): :raises keystone.exception.Unauthorized: If neither `user_name` nor `user_id` is set. - :returns: dictionary with user identification - :rtype: dict + :returns: tuple with user identification + :rtype: tuple """ user = mapped_properties['user'] @@ -245,5 +250,4 @@ def setup_username(context, mapped_properties): user_id = user_name user['id'] = parse.quote(user_id) - - return user + return (user['id'], user['name']) diff --git a/keystone/common/config.py b/keystone/common/config.py index 468fb64bf1..8b42f4e69f 100644 --- a/keystone/common/config.py +++ b/keystone/common/config.py @@ -235,6 +235,12 @@ FILE_OPTIONS = { 'value to False is when configuring a fresh ' 'installation.'), ], + 'shadow_users': [ + cfg.StrOpt('driver', + default='sql', + help='Entrypoint for the shadow users backend driver ' + 'in the keystone.identity.shadow_users namespace.'), + ], 'trust': [ cfg.BoolOpt('enabled', default=True, help='Delegation and impersonation features can be ' diff --git a/keystone/common/sql/migrate_repo/versions/094_add_federated_user_table.py b/keystone/common/sql/migrate_repo/versions/094_add_federated_user_table.py new file mode 100644 index 0000000000..6fd3f05118 --- /dev/null +++ b/keystone/common/sql/migrate_repo/versions/094_add_federated_user_table.py @@ -0,0 +1,43 @@ +# 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 migrate +import sqlalchemy as sql + + +def upgrade(migrate_engine): + meta = sql.MetaData() + meta.bind = migrate_engine + + user_table = sql.Table('user', meta, autoload=True) + idp_table = sql.Table('identity_provider', meta, autoload=True) + protocol_table = sql.Table('federation_protocol', meta, autoload=True) + + federated_table = sql.Table( + 'federated_user', + meta, + sql.Column('id', sql.Integer, primary_key=True, nullable=False), + sql.Column('user_id', sql.String(64), + sql.ForeignKey(user_table.c.id, ondelete='CASCADE'), + nullable=False), + sql.Column('idp_id', sql.String(64), + sql.ForeignKey(idp_table.c.id, ondelete='CASCADE'), + nullable=False), + sql.Column('protocol_id', sql.String(64), nullable=False), + sql.Column('unique_id', sql.String(255), nullable=False), + sql.Column('display_name', sql.String(255), nullable=True), + sql.UniqueConstraint('idp_id', 'protocol_id', 'unique_id')) + federated_table.create(migrate_engine, checkfirst=True) + + migrate.ForeignKeyConstraint( + columns=[federated_table.c.protocol_id, federated_table.c.idp_id], + refcolumns=[protocol_table.c.id, protocol_table.c.idp_id]).create() diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index 43e69463cd..5680a8a2bb 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -12,7 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. -from sqlalchemy import and_ +import sqlalchemy from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy import orm @@ -33,14 +33,21 @@ class User(sql.ModelBase, sql.DictBase): extra = sql.Column(sql.JsonBlob()) default_project_id = sql.Column(sql.String(64)) local_user = orm.relationship('LocalUser', uselist=False, - single_parent=True, + single_parent=True, lazy='subquery', cascade='all,delete-orphan', backref='user') + federated_users = orm.relationship('FederatedUser', + single_parent=True, + lazy='subquery', + cascade='all,delete-orphan', + backref='user') # name property @hybrid_property def name(self): if self.local_user: return self.local_user.name + elif self.federated_users: + return self.federated_users[0].display_name else: return None @@ -126,6 +133,26 @@ class Password(sql.ModelBase, sql.DictBase): password = sql.Column(sql.String(128)) +class FederatedUser(sql.ModelBase, sql.ModelDictMixin): + __tablename__ = 'federated_user' + attributes = ['id', 'user_id', 'idp_id', 'protocol_id', 'unique_id', + 'display_name'] + id = sql.Column(sql.Integer, primary_key=True) + user_id = sql.Column(sql.String(64), sql.ForeignKey('user.id', + ondelete='CASCADE')) + idp_id = sql.Column(sql.String(64), sql.ForeignKey('identity_provider.id', + ondelete='CASCADE')) + protocol_id = sql.Column(sql.String(64), nullable=False) + unique_id = sql.Column(sql.String(255), nullable=False) + display_name = sql.Column(sql.String(255), nullable=True) + __table_args__ = ( + sql.UniqueConstraint('idp_id', 'protocol_id', 'unique_id'), + sqlalchemy.ForeignKeyConstraint(['protocol_id', 'idp_id'], + ['federation_protocol.id', + 'federation_protocol.idp_id']) + ) + + class Group(sql.ModelBase, sql.DictBase): __tablename__ = 'group' attributes = ['id', 'name', 'domain_id', 'description'] @@ -216,8 +243,8 @@ class Identity(identity.IdentityDriverV8): def get_user_by_name(self, user_name, domain_id): with sql.session_for_read() as session: query = session.query(User).join(LocalUser) - query = query.filter(and_(LocalUser.name == user_name, - LocalUser.domain_id == domain_id)) + query = query.filter(sqlalchemy.and_(LocalUser.name == user_name, + LocalUser.domain_id == domain_id)) try: user_ref = query.one() except sql.NotFound: diff --git a/keystone/identity/core.py b/keystone/identity/core.py index 9dddb2122c..4145bf04c1 100644 --- a/keystone/identity/core.py +++ b/keystone/identity/core.py @@ -449,7 +449,7 @@ def exception_translated(exception_type): @notifications.listener @dependency.provider('identity_api') @dependency.requires('assignment_api', 'credential_api', 'id_mapping_api', - 'resource_api', 'revoke_api') + 'resource_api', 'revoke_api', 'shadow_users_api') class Manager(manager.Manager): """Default pivot point for the Identity backend. @@ -1209,6 +1209,35 @@ class Manager(manager.Manager): update_dict = {'password': new_password} self.update_user(user_id, update_dict) + @MEMOIZE + def shadow_federated_user(self, idp_id, protocol_id, unique_id, + display_name): + """Shadows a federated user by mapping to a user. + + :param idp_id: identity provider id + :param protocol_id: protocol id + :param unique_id: unique id for the user within the IdP + :param display_name: user's display name + + :returns: dictionary of the mapped User entity + """ + user_dict = {} + try: + user_dict = self.shadow_users_api.get_federated_user( + idp_id, protocol_id, unique_id) + self.update_federated_user_display_name(idp_id, protocol_id, + unique_id, display_name) + except exception.UserNotFound: + federated_dict = { + 'idp_id': idp_id, + 'protocol_id': protocol_id, + 'unique_id': unique_id, + 'display_name': display_name + } + user_dict = self.shadow_users_api.create_federated_user( + federated_dict) + return user_dict + @six.add_metaclass(abc.ABCMeta) class IdentityDriverV8(object): @@ -1517,3 +1546,54 @@ class MappingDriverV8(object): MappingDriver = manager.create_legacy_driver(MappingDriverV8) + + +@dependency.provider('shadow_users_api') +class ShadowUsersManager(manager.Manager): + """Default pivot point for the Shadow Users backend.""" + + driver_namespace = 'keystone.identity.shadow_users' + + def __init__(self): + super(ShadowUsersManager, self).__init__(CONF.shadow_users.driver) + + +@six.add_metaclass(abc.ABCMeta) +class ShadowUsersDriverV9(object): + """Interface description for an Shadow Users driver.""" + + @abc.abstractmethod + def create_federated_user(self, federated_dict): + """Create a new user with the federated identity + + :param dict federated_dict: Reference to the federated user + :param user_id: user ID for linking to the federated identity + :returns dict: Containing the user reference + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def get_federated_user(self, idp_id, protocol_id, unique_id): + """Returns the found user for the federated identity + + :param idp_id: The identity provider ID + :param protocol_id: The federation protocol ID + :param unique_id: The unique ID for the user + :returns dict: Containing the user reference + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def update_federated_user_display_name(self, idp_id, protocol_id, + unique_id, display_name): + """Updates federated user's display name if changed + + :param idp_id: The identity provider ID + :param protocol_id: The federation protocol ID + :param unique_id: The unique ID for the user + :param display_name: The user's display name + + """ + raise exception.NotImplemented() diff --git a/keystone/identity/shadow_backends/__init__.py b/keystone/identity/shadow_backends/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/identity/shadow_backends/sql.py b/keystone/identity/shadow_backends/sql.py new file mode 100644 index 0000000000..af5a995bd2 --- /dev/null +++ b/keystone/identity/shadow_backends/sql.py @@ -0,0 +1,73 @@ +# 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 uuid + +from keystone.common import sql +from keystone import exception +from keystone import identity +from keystone.identity.backends import sql as model + + +class ShadowUsers(identity.ShadowUsersDriverV9): + @sql.handle_conflicts(conflict_type='federated_user') + def create_federated_user(self, federated_dict): + user = { + 'id': uuid.uuid4().hex, + 'enabled': True + } + with sql.session_for_write() as session: + federated_ref = model.FederatedUser.from_dict(federated_dict) + user_ref = model.User.from_dict(user) + user_ref.federated_users.append(federated_ref) + session.add(user_ref) + return identity.filter_user(user_ref.to_dict()) + + def get_federated_user(self, idp_id, protocol_id, unique_id): + user_ref = self._get_federated_user(idp_id, protocol_id, unique_id) + return identity.filter_user(user_ref.to_dict()) + + def _get_federated_user(self, idp_id, protocol_id, unique_id): + """Returns the found user for the federated identity + + :param idp_id: The identity provider ID + :param protocol_id: The federation protocol ID + :param unique_id: The user's unique ID (unique within the IdP) + :returns User: Returns a reference to the User + + """ + with sql.session_for_read() as session: + query = session.query(model.User).outerjoin(model.LocalUser) + query = query.join(model.FederatedUser) + query = query.filter(model.FederatedUser.idp_id == idp_id) + query = query.filter(model.FederatedUser.protocol_id == + protocol_id) + query = query.filter(model.FederatedUser.unique_id == unique_id) + try: + user_ref = query.one() + except sql.NotFound: + raise exception.UserNotFound(user_id=unique_id) + return user_ref + + @sql.handle_conflicts(conflict_type='federated_user') + def update_federated_user_display_name(self, idp_id, protocol_id, + unique_id, display_name): + with sql.session_for_write() as session: + query = session.query(model.FederatedUser) + query = query.filter(model.FederatedUser.idp_id == idp_id) + query = query.filter(model.FederatedUser.protocol_id == + protocol_id) + query = query.filter(model.FederatedUser.unique_id == unique_id) + query = query.filter(model.FederatedUser.display_name != + display_name) + query.update({'display_name': display_name}) + return diff --git a/keystone/server/backends.py b/keystone/server/backends.py index af888a699d..a518e7771c 100644 --- a/keystone/server/backends.py +++ b/keystone/server/backends.py @@ -59,6 +59,7 @@ def load_backends(): id_generator_api=identity.generator.Manager(), id_mapping_api=identity.MappingManager(), identity_api=_IDENTITY_API, + shadow_users_api=identity.ShadowUsersManager(), oauth_api=oauth1.Manager(), policy_api=policy.Manager(), resource_api=resource.Manager(), diff --git a/keystone/tests/unit/contrib/federation/test_utils.py b/keystone/tests/unit/contrib/federation/test_utils.py index 2c291bdcf0..9a45b7681d 100644 --- a/keystone/tests/unit/contrib/federation/test_utils.py +++ b/keystone/tests/unit/contrib/federation/test_utils.py @@ -523,8 +523,8 @@ class MappingRuleEngineTests(unit.BaseTestCase): - Check if the user has proper domain ('federated') set - Check if the user has property type set ('ephemeral') - Check if user's name is properly mapped from the assertion - - Check if user's id is properly set and equal to name, as it was not - explicitly specified in the mapping. + - Check if unique_id is properly set and equal to display_name, + as it was not explicitly specified in the mapping. """ mapping = mapping_fixtures.MAPPING_USER_IDS @@ -533,9 +533,11 @@ class MappingRuleEngineTests(unit.BaseTestCase): mapped_properties = rp.process(assertion) self.assertIsNotNone(mapped_properties) self.assertValidMappedUserObject(mapped_properties) - mapped.setup_username({}, mapped_properties) - self.assertEqual('jsmith', mapped_properties['user']['id']) self.assertEqual('jsmith', mapped_properties['user']['name']) + unique_id, display_name = mapped.get_user_unique_id_and_display_name( + {}, mapped_properties) + self.assertEqual('jsmith', unique_id) + self.assertEqual('jsmith', display_name) def test_user_identifications_name_and_federated_domain(self): """Test varius mapping options and how users are identified. @@ -546,8 +548,7 @@ class MappingRuleEngineTests(unit.BaseTestCase): - Check if the user has proper domain ('federated') set - Check if the user has propert type set ('ephemeral') - Check if user's name is properly mapped from the assertion - - Check if user's id is properly set and equal to name, as it was not - explicitly specified in the mapping. + - Check if the unique_id and display_name are properly set """ mapping = mapping_fixtures.MAPPING_USER_IDS @@ -556,10 +557,10 @@ class MappingRuleEngineTests(unit.BaseTestCase): mapped_properties = rp.process(assertion) self.assertIsNotNone(mapped_properties) self.assertValidMappedUserObject(mapped_properties) - mapped.setup_username({}, mapped_properties) - self.assertEqual('tbo', mapped_properties['user']['name']) - self.assertEqual('abc123%40example.com', - mapped_properties['user']['id']) + unique_id, display_name = mapped.get_user_unique_id_and_display_name( + {}, mapped_properties) + self.assertEqual('tbo', display_name) + self.assertEqual('abc123%40example.com', unique_id) def test_user_identification_id(self): """Test varius mapping options and how users are identified. @@ -569,9 +570,8 @@ class MappingRuleEngineTests(unit.BaseTestCase): Test plan: - Check if the user has proper domain ('federated') set - Check if the user has propert type set ('ephemeral') - - Check if user's id is properly mapped from the assertion - - Check if user's name is properly set and equal to id, as it was not - explicitly specified in the mapping. + - Check if user's display_name is properly set and equal to unique_id, + as it was not explicitly specified in the mapping. """ mapping = mapping_fixtures.MAPPING_USER_IDS @@ -581,9 +581,10 @@ class MappingRuleEngineTests(unit.BaseTestCase): context = {'environment': {}} self.assertIsNotNone(mapped_properties) self.assertValidMappedUserObject(mapped_properties) - mapped.setup_username(context, mapped_properties) - self.assertEqual('bob', mapped_properties['user']['name']) - self.assertEqual('bob', mapped_properties['user']['id']) + unique_id, display_name = mapped.get_user_unique_id_and_display_name( + context, mapped_properties) + self.assertEqual('bob', unique_id) + self.assertEqual('bob', display_name) def test_user_identification_id_and_name(self): """Test varius mapping options and how users are identified. @@ -593,8 +594,8 @@ class MappingRuleEngineTests(unit.BaseTestCase): Test plan: - Check if the user has proper domain ('federated') set - Check if the user has proper type set ('ephemeral') - - Check if user's name is properly mapped from the assertion - - Check if user's id is properly set and and equal to value hardcoded + - Check if display_name is properly set from the assertion + - Check if unique_id is properly set and and equal to value hardcoded in the mapping This test does two iterations with different assertions used as input @@ -615,10 +616,12 @@ class MappingRuleEngineTests(unit.BaseTestCase): context = {'environment': {}} self.assertIsNotNone(mapped_properties) self.assertValidMappedUserObject(mapped_properties) - mapped.setup_username(context, mapped_properties) - self.assertEqual(exp_user_name, mapped_properties['user']['name']) - self.assertEqual('abc123%40example.com', - mapped_properties['user']['id']) + unique_id, display_name = ( + mapped.get_user_unique_id_and_display_name(context, + mapped_properties) + ) + self.assertEqual(exp_user_name, display_name) + self.assertEqual('abc123%40example.com', unique_id) def test_whitelist_pass_through(self): mapping = mapping_fixtures.MAPPING_GROUPS_WHITELIST_PASS_THROUGH diff --git a/keystone/tests/unit/core.py b/keystone/tests/unit/core.py index 2db5925643..4b16f38f2b 100644 --- a/keystone/tests/unit/core.py +++ b/keystone/tests/unit/core.py @@ -342,6 +342,17 @@ def new_user_ref(domain_id, project_id=None, **kwargs): return ref +def new_federated_user_ref(idp_id=None, protocol_id=None, **kwargs): + ref = { + 'idp_id': idp_id or 'ORG_IDP', + 'protocol_id': protocol_id or 'saml2', + 'unique_id': uuid.uuid4().hex, + 'display_name': uuid.uuid4().hex, + } + ref.update(kwargs) + return ref + + def new_group_ref(domain_id, **kwargs): ref = { 'id': uuid.uuid4().hex, diff --git a/keystone/tests/unit/test_backend_sql.py b/keystone/tests/unit/test_backend_sql.py index 6aed35ac53..5805493af6 100644 --- a/keystone/tests/unit/test_backend_sql.py +++ b/keystone/tests/unit/test_backend_sql.py @@ -142,6 +142,15 @@ class SqlModels(SqlTests): ('password', sql.String, 128)) self.assertExpectedSchema('password', cols) + def test_federated_user_model(self): + cols = (('id', sql.Integer, None), + ('user_id', sql.String, 64), + ('idp_id', sql.String, 64), + ('protocol_id', sql.String, 64), + ('unique_id', sql.String, 255), + ('display_name', sql.String, 255)) + self.assertExpectedSchema('federated_user', cols) + def test_group_model(self): cols = (('id', sql.String, 64), ('name', sql.String, 64), @@ -248,6 +257,44 @@ class SqlIdentity(SqlTests, test_backend.IdentityTests): ref['name'] = ref['name'].upper() self.identity_api.create_user(ref) + def test_create_federated_user_unique_constraint(self): + federated_dict = unit.new_federated_user_ref() + user_dict = self.shadow_users_api.create_federated_user(federated_dict) + user_dict = self.identity_api.get_user(user_dict["id"]) + self.assertIsNotNone(user_dict["id"]) + self.assertRaises(exception.Conflict, + self.shadow_users_api.create_federated_user, + federated_dict) + + def test_get_federated_user(self): + federated_dict = unit.new_federated_user_ref() + user_dict_create = self.shadow_users_api.create_federated_user( + federated_dict) + user_dict_get = self.shadow_users_api.get_federated_user( + federated_dict["idp_id"], + federated_dict["protocol_id"], + federated_dict["unique_id"]) + self.assertItemsEqual(user_dict_create, user_dict_get) + self.assertEqual(user_dict_create["id"], user_dict_get["id"]) + + def test_update_federated_user_display_name(self): + federated_dict = unit.new_federated_user_ref() + user_dict_create = self.shadow_users_api.create_federated_user( + federated_dict) + new_display_name = uuid.uuid4().hex + self.shadow_users_api.update_federated_user_display_name( + federated_dict["idp_id"], + federated_dict["protocol_id"], + federated_dict["unique_id"], + new_display_name) + user_ref = self.shadow_users_api._get_federated_user( + federated_dict["idp_id"], + federated_dict["protocol_id"], + federated_dict["unique_id"]) + self.assertEqual(user_ref.federated_users[0].display_name, + new_display_name) + self.assertEqual(user_dict_create["id"], user_ref.id) + def test_create_project_case_sensitivity(self): # project name case sensitivity is down to the fact that it is marked # as an SQL UNIQUE column, which may not be valid for other backends, diff --git a/keystone/tests/unit/test_sql_upgrade.py b/keystone/tests/unit/test_sql_upgrade.py index 5a0ea338ba..9171e7af9b 100644 --- a/keystone/tests/unit/test_sql_upgrade.py +++ b/keystone/tests/unit/test_sql_upgrade.py @@ -925,6 +925,19 @@ class SqlUpgradeTests(SqlMigrateBase): projects = session.query(proj_table) _check_projects(projects) + def test_add_federated_user_table(self): + federated_user_table = 'federated_user' + self.upgrade(93) + self.assertTableDoesNotExist(federated_user_table) + self.upgrade(94) + self.assertTableColumns(federated_user_table, + ['id', + 'user_id', + 'idp_id', + 'protocol_id', + 'unique_id', + 'display_name']) + class VersionTests(SqlMigrateBase): diff --git a/keystone/tests/unit/test_v3_federation.py b/keystone/tests/unit/test_v3_federation.py index 965c99da5f..5a133b2d14 100644 --- a/keystone/tests/unit/test_v3_federation.py +++ b/keystone/tests/unit/test_v3_federation.py @@ -2548,6 +2548,34 @@ class FederatedTokenTestsMethodToken(FederatedTokenTests): self._check_project_scoped_token_attributes(token_resp, project['id']) +class FederatedUserTests(test_v3.RestfulTestCase, FederatedSetupMixin): + """Tests for federated users + + Tests new shadow users functionality + + """ + + def auth_plugin_config_override(self): + methods = ['saml2'] + super(FederatedUserTests, self).auth_plugin_config_override(methods) + + def setUp(self): + super(FederatedUserTests, self).setUp() + + def load_fixtures(self, fixtures): + super(FederatedUserTests, self).load_fixtures(fixtures) + self.load_federation_sample_data() + + def test_user_id_persistense(self): + """Ensure user_id is persistend for multiple federated authn calls.""" + r = self._issue_unscoped_token() + user_id = r.json_body['token']['user']['id'] + + r = self._issue_unscoped_token() + user_id2 = r.json_body['token']['user']['id'] + self.assertEqual(user_id, user_id2) + + class JsonHomeTests(test_v3.RestfulTestCase, test_v3.JsonHomeTestMixin): JSON_HOME_DATA = { 'http://docs.openstack.org/api/openstack-identity/3/ext/OS-FEDERATION/' diff --git a/keystone/tests/unit/test_v3_identity.py b/keystone/tests/unit/test_v3_identity.py index 97342b3047..d853afc337 100644 --- a/keystone/tests/unit/test_v3_identity.py +++ b/keystone/tests/unit/test_v3_identity.py @@ -483,6 +483,22 @@ class IdentityTestCase(test_v3.RestfulTestCase): r = self.credential_api.get_credential(credential2['id']) self.assertDictEqual(credential2, r) + # shadow user tests + def test_shadow_federated_user(self): + fed_user = unit.new_federated_user_ref() + user = ( + self.identity_api.shadow_federated_user(fed_user["idp_id"], + fed_user["protocol_id"], + fed_user["unique_id"], + fed_user["display_name"]) + ) + self.assertIsNotNone(user["id"]) + self.assertEqual(len(user.keys()), 4) + self.assertIsNotNone(user['id']) + self.assertIsNotNone(user['name']) + self.assertIsNone(user['domain_id']) + self.assertEqual(user['enabled'], True) + # group crud tests def test_create_group(self): diff --git a/setup.cfg b/setup.cfg index 4d4512d628..fa00b31a56 100644 --- a/setup.cfg +++ b/setup.cfg @@ -125,6 +125,9 @@ keystone.identity.id_generator = keystone.identity.id_mapping = sql = keystone.identity.mapping_backends.sql:Mapping +keystone.identity.shadow_users = + sql = keystone.identity.shadow_backends.sql:ShadowUsers + keystone.policy = rules = keystone.policy.backends.rules:Policy sql = keystone.policy.backends.sql:Policy