From 9e830dbe026e0297078a4c000e63696ffc053bb7 Mon Sep 17 00:00:00 2001 From: Lance Bragstad Date: Thu, 22 Dec 2016 18:21:04 +0000 Subject: [PATCH] Implement federated auto-provisioning Provide a way to provision projects and assignments when a federated user authenticates for the first time for an unscoped token. implements bp shadow-mapping Change-Id: I6029dac8294e8cfc4bf622ac71b5e731956389db --- keystone/auth/plugins/mapped.py | 111 ++++++- keystone/exception.py | 5 + keystone/federation/utils.py | 26 ++ .../unit/contrib/federation/test_utils.py | 15 +- keystone/tests/unit/mapping_fixtures.py | 66 +++- keystone/tests/unit/test_v3_federation.py | 290 ++++++++++++++++++ ...ement-shadow-mapping-06fc7c71a401d707.yaml | 9 + 7 files changed, 513 insertions(+), 9 deletions(-) create mode 100644 releasenotes/notes/implement-shadow-mapping-06fc7c71a401d707.yaml diff --git a/keystone/auth/plugins/mapped.py b/keystone/auth/plugins/mapped.py index e0aa9ab7ab..3fd805dd32 100644 --- a/keystone/auth/plugins/mapped.py +++ b/keystone/auth/plugins/mapped.py @@ -11,7 +11,9 @@ # under the License. import functools +import uuid +from oslo_log import log from pycadf import cadftaxonomy as taxonomy from six.moves.urllib import parse @@ -21,16 +23,17 @@ from keystone.common import dependency from keystone import exception from keystone.federation import constants as federation_constants from keystone.federation import utils -from keystone.i18n import _ +from keystone.i18n import _, _LE, _LI from keystone.models import token_model from keystone import notifications +LOG = log.getLogger(__name__) METHOD_NAME = 'mapped' -@dependency.requires('federation_api', 'identity_api', - 'resource_api', 'token_provider_api') +@dependency.requires('assignment_api', 'federation_api', 'identity_api', + 'resource_api', 'token_provider_api', 'role_api') class Mapped(base.AuthMethodHandler): def _get_token_ref(self, auth_payload): @@ -66,7 +69,9 @@ class Mapped(base.AuthMethodHandler): auth_context, self.resource_api, self.federation_api, - self.identity_api) + self.identity_api, + self.assignment_api, + self.role_api) def handle_scoped_token(request, auth_context, token_ref, @@ -105,7 +110,75 @@ def handle_scoped_token(request, auth_context, token_ref, def handle_unscoped_token(request, auth_payload, auth_context, - resource_api, federation_api, identity_api): + resource_api, federation_api, identity_api, + assignment_api, role_api): + + def validate_shadow_mapping(shadow_projects, existing_roles, idp_domain_id, + idp_id): + # Validate that the roles in the shadow mapping actually exist. If + # they don't we should bail early before creating anything. + for shadow_project in shadow_projects: + for shadow_role in shadow_project['roles']: + # The role in the project mapping must exist in order for it to + # be useful. + if shadow_role['name'] not in existing_roles: + LOG.error( + _LE('Role %s was specified in the mapping but does ' + 'not exist. All roles specified in a mapping must ' + 'exist before assignment.'), + shadow_role['name'] + ) + # NOTE(lbragstad): The RoleNotFound exception usually + # expects a role_id as the parameter, but in this case we + # only have a name so we'll pass that instead. + raise exception.RoleNotFound(shadow_role['name']) + role = existing_roles[shadow_role['name']] + if (role['domain_id'] is not None and + role['domain_id'] != idp_domain_id): + LOG.error( + _LE('Role %(role)s is a domain-specific role and ' + 'cannot be assigned within %(domain)s.'), + {'role': shadow_role['name'], 'domain': idp_domain_id} + ) + raise exception.DomainSpecificRoleNotWithinIdPDomain( + role_name=shadow_role['name'], + identity_provider=idp_id + ) + + def create_projects_from_mapping(shadow_projects, idp_domain_id, + existing_roles, user, assignment_api, + resource_api): + for shadow_project in shadow_projects: + try: + # Check and see if the project already exists and if it + # does not, try to create it. + project = resource_api.get_project_by_name( + shadow_project['name'], idp_domain_id + ) + except exception.ProjectNotFound: + LOG.info( + _LI('Project %(project_name)s does not exist. It will be ' + 'automatically provisioning for user %(user_id)s.'), + {'project_name': shadow_project['name'], + 'user_id': user['id']} + ) + project_ref = { + 'id': uuid.uuid4().hex, + 'name': shadow_project['name'], + 'domain_id': idp_domain_id + } + project = resource_api.create_project( + project_ref['id'], + project_ref + ) + + shadow_roles = shadow_project['roles'] + for shadow_role in shadow_roles: + assignment_api.create_grant( + existing_roles[shadow_role['name']]['id'], + user_id=user['id'], + project_id=project['id'] + ) def is_ephemeral_user(mapped_properties): return mapped_properties['user']['type'] == utils.UserType.EPHEMERAL @@ -155,6 +228,34 @@ def handle_unscoped_token(request, auth_payload, auth_context, user = identity_api.shadow_federated_user(identity_provider, protocol, unique_id, display_name) + + if 'projects' in mapped_properties: + idp_domain_id = federation_api.get_idp( + identity_provider + )['domain_id'] + existing_roles = { + role['name']: role for role in role_api.list_roles() + } + # NOTE(lbragstad): If we are dealing with a shadow mapping, + # then we need to make sure we validate all pieces of the + # mapping and what it's saying to create. If there is something + # wrong with how the mapping is, we should bail early before we + # create anything. + validate_shadow_mapping( + mapped_properties['projects'], + existing_roles, + idp_domain_id, + identity_provider + ) + create_projects_from_mapping( + mapped_properties['projects'], + idp_domain_id, + existing_roles, + user, + assignment_api, + resource_api + ) + user_id = user['id'] group_ids = mapped_properties['group_ids'] build_ephemeral_user_context(auth_context, user, diff --git a/keystone/exception.py b/keystone/exception.py index 5718ecff3b..41808a85ae 100644 --- a/keystone/exception.py +++ b/keystone/exception.py @@ -348,6 +348,11 @@ class DomainSpecificRoleMismatch(Forbidden): " as the role: %(role_id)s being assigned.") +class DomainSpecificRoleNotWithinIdPDomain(Forbidden): + message_format = _("role: %(role_name)s must be within the same domain as " + "the identity provider: %(identity_provider)s.") + + class RoleAssignmentNotFound(NotFound): message_format = _("Could not find role assignment with role: " "%(role_id)s, user or group: %(actor_id)s, " diff --git a/keystone/federation/utils.py b/keystone/federation/utils.py index 1e8080b8a5..94f66c80c0 100644 --- a/keystone/federation/utils.py +++ b/keystone/federation/utils.py @@ -36,6 +36,20 @@ class UserType(object): EPHEMERAL = 'ephemeral' LOCAL = 'local' +ROLE_PROPERTIES = { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "additionalProperties": False + } + } +} + MAPPING_SCHEMA = { "type": "object", @@ -72,6 +86,18 @@ MAPPING_SCHEMA = { }, "additionalProperties": False }, + "projects": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "roles"], + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "roles": ROLE_PROPERTIES + } + } + }, "group": { "type": "object", "oneOf": [ diff --git a/keystone/tests/unit/contrib/federation/test_utils.py b/keystone/tests/unit/contrib/federation/test_utils.py index 975eb09c7a..ad98ccae99 100644 --- a/keystone/tests/unit/contrib/federation/test_utils.py +++ b/keystone/tests/unit/contrib/federation/test_utils.py @@ -742,9 +742,18 @@ class MappingRuleEngineTests(unit.BaseTestCase): self.assertEqual(expected_username, values['user']['name']) expected_projects = [ - {"name": "a"}, - {"name": "b"}, - {"name": "project for %s" % expected_username}, + { + "name": "Production", + "roles": [{"name": "observer"}] + }, + { + "name": "Staging", + "roles": [{"name": "member"}] + }, + { + "name": "Project for %s" % expected_username, + "roles": [{"name": "admin"}] + } ] self.assertEqual(expected_projects, values['projects']) diff --git a/keystone/tests/unit/mapping_fixtures.py b/keystone/tests/unit/mapping_fixtures.py index 87f835fdc6..7544411ce5 100644 --- a/keystone/tests/unit/mapping_fixtures.py +++ b/keystone/tests/unit/mapping_fixtures.py @@ -1590,6 +1590,44 @@ MAPPING_UNICODE = { } MAPPING_PROJECTS = { + "rules": [ + { + "local": [ + { + "user": { + "name": "{0}" + } + }, + { + "projects": [ + {"name": "Production", + "roles": [{"name": "observer"}]}, + {"name": "Staging", + "roles": [{"name": "member"}]}, + {"name": "Project for {0}", + "roles": [{"name": "admin"}]}, + ], + } + ], + "remote": [ + { + "type": "UserName" + }, + { + "type": "Email", + }, + { + "type": "orgPersonType", + "any_one_of": [ + "Employee" + ] + } + ] + } + ] +} + +MAPPING_PROJECTS_WITHOUT_ROLES = { "rules": [ { "local": [ @@ -1600,7 +1638,7 @@ MAPPING_PROJECTS = { "projects": [ {"name": "a"}, {"name": "b"}, - {"name": "project for {0}"}, + {"name": "Project for {0}"}, ], } ], @@ -1612,3 +1650,29 @@ MAPPING_PROJECTS = { }, ] } + +MAPPING_PROJECTS_WITHOUT_NAME = { + "rules": [ + { + "local": [ + { + "user": { + "name": "{0}" + }, + "projects": [ + {"roles": [{"name": "observer"}]}, + {"name": "Staging", + "roles": [{"name": "member"}]}, + {"name": "Project for {0}", + "roles": [{"name": "admin"}]}, + ] + } + ], + "remote": [ + { + "type": "UserName" + } + ] + }, + ] +} diff --git a/keystone/tests/unit/test_v3_federation.py b/keystone/tests/unit/test_v3_federation.py index ae7823a316..29d1f0a584 100644 --- a/keystone/tests/unit/test_v3_federation.py +++ b/keystone/tests/unit/test_v3_federation.py @@ -1700,6 +1700,58 @@ class MappingCRUDTests(test_v3.RestfulTestCase): self.put(url, expected_status=http_client.BAD_REQUEST, body={'mapping': bad_mapping}) + def test_create_shadow_mapping_without_roles_fails(self): + """Validate that mappings with projects contain roles when created.""" + url = self.MAPPING_URL + uuid.uuid4().hex + self.put( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_ROLES}, + expected_status=http_client.BAD_REQUEST + ) + + def test_update_shadow_mapping_without_roles_fails(self): + """Validate that mappings with projects contain roles when updated.""" + url = self.MAPPING_URL + uuid.uuid4().hex + resp = self.put( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS}, + expected_status=http_client.CREATED + ) + self.assertValidMappingResponse( + resp, mapping_fixtures.MAPPING_PROJECTS + ) + self.patch( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_ROLES}, + expected_status=http_client.BAD_REQUEST + ) + + def test_create_shadow_mapping_without_name_fails(self): + """Validate project mappings contain the project name when created.""" + url = self.MAPPING_URL + uuid.uuid4().hex + self.put( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_NAME}, + expected_status=http_client.BAD_REQUEST + ) + + def test_update_shadow_mapping_without_name_fails(self): + """Validate project mappings contain the project name when updated.""" + url = self.MAPPING_URL + uuid.uuid4().hex + resp = self.put( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS}, + expected_status=http_client.CREATED + ) + self.assertValidMappingResponse( + resp, mapping_fixtures.MAPPING_PROJECTS + ) + self.patch( + url, + body={'mapping': mapping_fixtures.MAPPING_PROJECTS_WITHOUT_NAME}, + expected_status=http_client.BAD_REQUEST + ) + class FederatedTokenTests(test_v3.RestfulTestCase, FederatedSetupMixin): @@ -3015,6 +3067,244 @@ class FederatedUserTests(test_v3.RestfulTestCase, FederatedSetupMixin): return token_resp['user']['id'], unscoped_token +class ShadowMappingTests(test_v3.RestfulTestCase, FederatedSetupMixin): + """Test class dedicated to auto-provisioning resources at login. + + A shadow mapping is a mapping that contains extra properties about that + specific federated user's situation based on attributes from the assertion. + For example, a shadow mapping can tell us that a user should have specific + role assignments on certain projects within a domain. When a federated user + authenticates, the shadow mapping will create these entities before + returning the authenticated response to the user. This test class is + dedicated to testing specific aspects of shadow mapping when performing + federated authentication. + """ + + def setUp(self): + super(ShadowMappingTests, self).setUp() + # update the mapping we have already setup to have specific projects + # and roles. + self.federation_api.update_mapping( + self.mapping['id'], + mapping_fixtures.MAPPING_PROJECTS + ) + + # The shadow mapping we're using in these tests contain a role named + # `member` and `observer` for the sake of using something other than + # `admin`. We'll need to create those before hand, otherwise the + # mapping will fail during authentication because the roles defined in + # the mapping do not exist yet. The shadow mapping mechanism currently + # doesn't support creating roles on-the-fly, but this could change in + # the future after we get some feedback from shadow mapping being used + # in real deployments. We also want to make sure we are dealing with + # global roles and not domain-scoped roles. We have specific tests + # below that test that behavior and the setup is done in the test. + member_role_ref = unit.new_role_ref(name='member') + assert member_role_ref['domain_id'] is None + self.member_role = self.role_api.create_role( + member_role_ref['id'], member_role_ref + ) + observer_role_ref = unit.new_role_ref(name='observer') + assert observer_role_ref['domain_id'] is None + self.observer_role = self.role_api.create_role( + observer_role_ref['id'], observer_role_ref + ) + + # This is a mapping of the project name to the role that is supposed to + # be assigned to the user on that project from the shadow mapping. + self.expected_results = { + 'Production': 'observer', + 'Staging': 'member', + 'Project for tbo': 'admin' + } + + def auth_plugin_config_override(self): + methods = ['saml2'] + super(ShadowMappingTests, self).auth_plugin_config_override(methods) + + def load_fixtures(self, fixtures): + super(ShadowMappingTests, self).load_fixtures(fixtures) + self.load_federation_sample_data() + + def test_shadow_mapping_creates_projects(self): + projects = self.resource_api.list_projects() + for project in projects: + self.assertNotIn(project['name'], self.expected_results) + + response = self._issue_unscoped_token() + self.assertValidMappedUser(response.json_body['token']) + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + for project in projects: + project = self.resource_api.get_project_by_name( + project['name'], + self.idp['domain_id'] + ) + self.assertIn(project['name'], self.expected_results) + + def test_shadow_mapping_create_projects_role_assignments(self): + response = self._issue_unscoped_token() + self.assertValidMappedUser(response.json_body['token']) + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + for project in projects: + # Ask for a scope token to each project in the mapping. Each token + # should contain a different role so let's check that is right, + # too. + scope = self._scope_request( + unscoped_token, 'project', project['id'] + ) + response = self.v3_create_token(scope) + project_name = response.json_body['token']['project']['name'] + roles = response.json_body['token']['roles'] + self.assertEqual( + self.expected_results[project_name], roles[0]['name'] + ) + + def test_shadow_mapping_does_not_create_roles(self): + # If a role required by the mapping does not exist, then we should fail + # the mapping since shadow mapping currently does not support creating + # mappings on-the-fly. + self.role_api.delete_role(self.observer_role['id']) + self.assertRaises(exception.RoleNotFound, self._issue_unscoped_token) + + def test_shadow_mapping_creates_project_in_identity_provider_domain(self): + response = self._issue_unscoped_token() + self.assertValidMappedUser(response.json_body['token']) + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + for project in projects: + self.assertEqual(project['domain_id'], self.idp['domain_id']) + + def test_shadow_mapping_is_idempotent(self): + """Test that projects remain idempotent for every federated auth.""" + response = self._issue_unscoped_token() + self.assertValidMappedUser(response.json_body['token']) + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + project_ids = [p['id'] for p in response.json_body['projects']] + response = self._issue_unscoped_token() + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + for project in projects: + self.assertIn(project['id'], project_ids) + + def test_roles_outside_idp_domain_fail_mapping(self): + # Create a new domain + d = unit.new_domain_ref() + new_domain = self.resource_api.create_domain(d['id'], d) + + # Delete the member role and recreate it in a different domain + self.role_api.delete_role(self.member_role['id']) + member_role_ref = unit.new_role_ref( + name='member', + domain_id=new_domain['id'] + ) + self.role_api.create_role(member_role_ref['id'], member_role_ref) + self.assertRaises( + exception.DomainSpecificRoleNotWithinIdPDomain, + self._issue_unscoped_token + ) + + def test_roles_in_idp_domain_can_be_assigned_from_mapping(self): + # Delete the member role and recreate it in the domain of the idp + self.role_api.delete_role(self.member_role['id']) + member_role_ref = unit.new_role_ref( + name='member', + domain_id=self.idp['domain_id'] + ) + self.role_api.create_role(member_role_ref['id'], member_role_ref) + response = self._issue_unscoped_token() + user_id = response.json_body['token']['user']['id'] + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + staging_project = self.resource_api.get_project_by_name( + 'Staging', self.idp['domain_id'] + ) + for project in projects: + # Even though the mapping successfully assigned the Staging project + # a member role for our user, the /auth/projects response doesn't + # include projects with only domain-specific role assignments. + self.assertNotEqual(project['name'], 'Staging') + domain_role_assignments = self.assignment_api.list_role_assignments( + user_id=user_id, + project_id=staging_project['id'], + strip_domain_roles=False + ) + self.assertEqual( + staging_project['id'], domain_role_assignments[0]['project_id'] + ) + self.assertEqual( + user_id, domain_role_assignments[0]['user_id'] + ) + + def test_mapping_with_groups_includes_projects_with_group_assignment(self): + # create a group called Observers + observer_group = unit.new_group_ref( + domain_id=self.idp['domain_id'], + name='Observers' + ) + observer_group = self.identity_api.create_group(observer_group) + # make sure the Observers group has a role on the finance project + finance_project = unit.new_project_ref( + domain_id=self.idp['domain_id'], + name='Finance' + ) + finance_project = self.resource_api.create_project( + finance_project['id'], finance_project + ) + self.assignment_api.create_grant( + self.observer_role['id'], + group_id=observer_group['id'], + project_id=finance_project['id'] + ) + # update the mapping + group_rule = { + 'group': { + 'name': 'Observers', + 'domain': { + 'id': self.idp['domain_id'] + } + } + } + updated_mapping = copy.deepcopy(mapping_fixtures.MAPPING_PROJECTS) + updated_mapping['rules'][0]['local'].append(group_rule) + self.federation_api.update_mapping(self.mapping['id'], updated_mapping) + response = self._issue_unscoped_token() + # user_id = response.json_body['token']['user']['id'] + unscoped_token = response.headers.get('X-Subject-Token') + response = self.get('/auth/projects', token=unscoped_token) + projects = response.json_body['projects'] + self.expected_results = { + # These assignments are all a result of a direct mapping from the + # shadow user to the newly created project. + 'Production': 'observer', + 'Staging': 'member', + 'Project for tbo': 'admin', + # This is a result of the mapping engine maintaining its old + # behavior. + 'Finance': 'observer' + } + for project in projects: + # Ask for a scope token to each project in the mapping. Each token + # should contain a different role so let's check that is right, + # too. + scope = self._scope_request( + unscoped_token, 'project', project['id'] + ) + response = self.v3_create_token(scope) + project_name = response.json_body['token']['project']['name'] + roles = response.json_body['token']['roles'] + self.assertEqual( + self.expected_results[project_name], roles[0]['name'] + ) + + 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/releasenotes/notes/implement-shadow-mapping-06fc7c71a401d707.yaml b/releasenotes/notes/implement-shadow-mapping-06fc7c71a401d707.yaml new file mode 100644 index 0000000000..be84a494d2 --- /dev/null +++ b/releasenotes/notes/implement-shadow-mapping-06fc7c71a401d707.yaml @@ -0,0 +1,9 @@ +--- +features: + - The federated mapping engine now supports the ability + to automatically provision federated users into projects + specified in the mapping rules at federated login time. If the + project within the mapping does not exist, it will be + automatically created in the domain of the Identity Provider. + This behavior can be done using a specific syntax within + the ``local`` rules section of a mapping.