diff --git a/doc/source/policy_mapping.rst b/doc/source/policy_mapping.rst index 2d3cd60a3f..71f87fd5a2 100644 --- a/doc/source/policy_mapping.rst +++ b/doc/source/policy_mapping.rst @@ -172,8 +172,8 @@ identity:get_auth_catalog GET /v3/auth/catalog identity:get_auth_projects GET /v3/auth/projects identity:get_auth_domains GET /v3/auth/domains -identity:list_projects_for_groups GET /v3/OS-FEDERATION/projects -identity:list_domains_for_groups GET /v3/OS-FEDERATION/domains +identity:list_projects_for_user GET /v3/OS-FEDERATION/projects +identity:list_domains_for_user GET /v3/OS-FEDERATION/domains identity:list_revoke_events GET /v3/OS-REVOKE/events diff --git a/etc/policy.json b/etc/policy.json index 797af24d61..c1cb2d1de0 100644 --- a/etc/policy.json +++ b/etc/policy.json @@ -173,8 +173,8 @@ "identity:get_auth_projects": "", "identity:get_auth_domains": "", - "identity:list_projects_for_groups": "", - "identity:list_domains_for_groups": "", + "identity:list_projects_for_user": "", + "identity:list_domains_for_user": "", "identity:list_revoke_events": "", diff --git a/etc/policy.v3cloudsample.json b/etc/policy.v3cloudsample.json index 2606b31e94..9469935c7b 100644 --- a/etc/policy.v3cloudsample.json +++ b/etc/policy.v3cloudsample.json @@ -198,8 +198,8 @@ "identity:get_auth_projects": "", "identity:get_auth_domains": "", - "identity:list_projects_for_groups": "", - "identity:list_domains_for_groups": "", + "identity:list_projects_for_user": "", + "identity:list_domains_for_user": "", "identity:list_revoke_events": "", diff --git a/keystone/federation/controllers.py b/keystone/federation/controllers.py index 6ad0e3c983..c7ad0b1bac 100644 --- a/keystone/federation/controllers.py +++ b/keystone/federation/controllers.py @@ -429,8 +429,8 @@ class DomainV3(controller.V3Controller): self.get_member_from_driver = self.resource_api.get_domain @controller.protected() - def list_domains_for_groups(self, request): - """List all domains available to an authenticated user's groups. + def list_domains_for_user(self, request): + """List all domains available to an authenticated user. :param context: request context :returns: list of accessible domains @@ -440,6 +440,10 @@ class DomainV3(controller.V3Controller): auth_context = env[authorization.AUTH_CONTEXT_ENV] domains = self.assignment_api.list_domains_for_groups( auth_context['group_ids']) + domains = domains + self.assignment_api.list_domains_for_user( + auth_context['user_id']) + # remove duplicates + domains = [dict(t) for t in set([tuple(d.items()) for d in domains])] return DomainV3.wrap_collection(request.context_dict, domains) @@ -453,8 +457,8 @@ class ProjectAssignmentV3(controller.V3Controller): self.get_member_from_driver = self.resource_api.get_project @controller.protected() - def list_projects_for_groups(self, request): - """List all projects available to an authenticated user's groups. + def list_projects_for_user(self, request): + """List all projects available to an authenticated user. :param context: request context :returns: list of accessible projects @@ -464,6 +468,10 @@ class ProjectAssignmentV3(controller.V3Controller): auth_context = env[authorization.AUTH_CONTEXT_ENV] projects = self.assignment_api.list_projects_for_groups( auth_context['group_ids']) + projects = projects + self.assignment_api.list_projects_for_user( + auth_context['user_id']) + # remove duplicates + projects = [dict(t) for t in set([tuple(d.items()) for d in projects])] return ProjectAssignmentV3.wrap_collection(request.context_dict, projects) diff --git a/keystone/federation/routers.py b/keystone/federation/routers.py index a463ca6367..419dd4e903 100644 --- a/keystone/federation/routers.py +++ b/keystone/federation/routers.py @@ -194,13 +194,13 @@ class Routers(wsgi.RoutersBase): mapper, domain_controller, path=self._construct_url('domains'), new_path='/auth/domains', - get_action='list_domains_for_groups', + get_action='list_domains_for_user', 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', + get_action='list_projects_for_user', rel=build_resource_relation(resource_name='projects')) # Auth operations diff --git a/keystone/federation/utils.py b/keystone/federation/utils.py index d911a8ef40..7f6a9b81b2 100644 --- a/keystone/federation/utils.py +++ b/keystone/federation/utils.py @@ -350,6 +350,8 @@ def validate_groups(group_ids, mapping_id, identity_api): is 0. """ + # TODO(rderose): remove cardinality check, as federated users can now + # receive direct role assignments validate_groups_cardinality(group_ids, mapping_id) validate_groups_in_backend(group_ids, mapping_id, identity_api) diff --git a/keystone/tests/unit/test_v3_federation.py b/keystone/tests/unit/test_v3_federation.py index c72362cfd0..ad7681ccf9 100644 --- a/keystone/tests/unit/test_v3_federation.py +++ b/keystone/tests/unit/test_v3_federation.py @@ -2637,6 +2637,258 @@ class FederatedUserTests(test_v3.RestfulTestCase, FederatedSetupMixin): user_id2 = r.json_body['token']['user']['id'] self.assertEqual(user_id, user_id2) + def test_user_role_assignment(self): + # create project and role + project_ref = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id) + self.resource_api.create_project(project_ref['id'], project_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id + user_id, unscoped_token = self._authenticate_via_saml() + + # exchange an unscoped token for a scoped token; resulting in + # unauthorized because the user doesn't have any role assignments + v3_scope_request = self._scope_request(unscoped_token, 'project', + project_ref['id']) + r = self.v3_create_token(v3_scope_request, + expected_status=http_client.UNAUTHORIZED) + + # assign project role to federated user + self.assignment_api.add_role_to_user_and_project( + user_id, project_ref['id'], role_ref['id']) + + # exchange an unscoped token for a scoped token + r = self.v3_create_token(v3_scope_request, + expected_status=http_client.CREATED) + scoped_token = r.headers['X-Subject-Token'] + + # ensure user can access resource based on role assignment + path = '/projects/%(project_id)s' % {'project_id': project_ref['id']} + r = self.v3_request(path=path, method='GET', + expected_status=http_client.OK, + token=scoped_token) + self.assertValidProjectResponse(r, project_ref) + + # create a 2nd project + project_ref2 = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id) + self.resource_api.create_project(project_ref2['id'], project_ref2) + + # ensure the user cannot access the 2nd resource (forbidden) + path = '/projects/%(project_id)s' % {'project_id': project_ref2['id']} + r = self.v3_request(path=path, method='GET', + expected_status=http_client.FORBIDDEN, + token=scoped_token) + + def test_domain_scoped_user_role_assignment(self): + # create domain and role + domain_ref = unit.new_domain_ref() + self.resource_api.create_domain(domain_ref['id'], domain_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id + user_id, unscoped_token = self._authenticate_via_saml() + + # exchange an unscoped token for a scoped token; resulting in + # unauthorized because the user doesn't have any role assignments + v3_scope_request = self._scope_request(unscoped_token, 'domain', + domain_ref['id']) + r = self.v3_create_token(v3_scope_request, + expected_status=http_client.UNAUTHORIZED) + + # assign domain role to user + self.assignment_api.create_grant(role_ref['id'], + user_id=user_id, + domain_id=domain_ref['id']) + + # exchange an unscoped token for domain scoped token and test + r = self.v3_create_token(v3_scope_request, + expected_status=http_client.CREATED) + self.assertIsNotNone(r.headers.get('X-Subject-Token')) + token_resp = r.result['token'] + self.assertIn('domain', token_resp) + + def test_auth_projects_matches_federation_projects(self): + # create project and role + project_ref = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id) + self.resource_api.create_project(project_ref['id'], project_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id + user_id, unscoped_token = self._authenticate_via_saml() + + # assign project role to federated user + self.assignment_api.add_role_to_user_and_project( + user_id, project_ref['id'], role_ref['id']) + + # get auth projects + r = self.get('/auth/projects', token=unscoped_token) + auth_projects = r.result['projects'] + + # get federation projects + r = self.get('/OS-FEDERATION/projects', token=unscoped_token) + fed_projects = r.result['projects'] + + # compare + self.assertItemsEqual(auth_projects, fed_projects) + + def test_auth_projects_matches_federation_projects_with_group_assign(self): + # create project, role, group + domain_id = CONF.identity.default_domain_id + project_ref = unit.new_project_ref(domain_id=domain_id) + self.resource_api.create_project(project_ref['id'], project_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + group_ref = unit.new_group_ref(domain_id=domain_id) + group_ref = self.identity_api.create_group(group_ref) + + # authenticate via saml get back a user id + user_id, unscoped_token = self._authenticate_via_saml() + + # assign role to group at project + self.assignment_api.create_grant(role_ref['id'], + group_id=group_ref['id'], + project_id=project_ref['id'], + domain_id=domain_id) + + # add user to group + self.identity_api.add_user_to_group(user_id=user_id, + group_id=group_ref['id']) + + # get auth projects + r = self.get('/auth/projects', token=unscoped_token) + auth_projects = r.result['projects'] + + # get federation projects + r = self.get('/OS-FEDERATION/projects', token=unscoped_token) + fed_projects = r.result['projects'] + + # compare + self.assertItemsEqual(auth_projects, fed_projects) + + def test_auth_domains_matches_federation_domains(self): + # create domain and role + domain_ref = unit.new_domain_ref() + self.resource_api.create_domain(domain_ref['id'], domain_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id and token + user_id, unscoped_token = self._authenticate_via_saml() + + # assign domain role to user + self.assignment_api.create_grant(role_ref['id'], + user_id=user_id, + domain_id=domain_ref['id']) + + # get auth domains + r = self.get('/auth/domains', token=unscoped_token) + auth_domains = r.result['domains'] + + # get federation domains + r = self.get('/OS-FEDERATION/domains', token=unscoped_token) + fed_domains = r.result['domains'] + + # compare + self.assertItemsEqual(auth_domains, fed_domains) + + def test_auth_domains_matches_federation_domains_with_group_assign(self): + # create role, group, and domain + domain_ref = unit.new_domain_ref() + self.resource_api.create_domain(domain_ref['id'], domain_ref) + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + group_ref = unit.new_group_ref(domain_id=domain_ref['id']) + group_ref = self.identity_api.create_group(group_ref) + + # authenticate via saml get back a user id and token + user_id, unscoped_token = self._authenticate_via_saml() + + # assign domain role to group + self.assignment_api.create_grant(role_ref['id'], + group_id=group_ref['id'], + domain_id=domain_ref['id']) + + # add user to group + self.identity_api.add_user_to_group(user_id=user_id, + group_id=group_ref['id']) + + # get auth domains + r = self.get('/auth/domains', token=unscoped_token) + auth_domains = r.result['domains'] + + # get federation domains + r = self.get('/OS-FEDERATION/domains', token=unscoped_token) + fed_domains = r.result['domains'] + + # compare + self.assertItemsEqual(auth_domains, fed_domains) + + def test_list_domains_for_user_duplicates(self): + # create role + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id and token + user_id, unscoped_token = self._authenticate_via_saml() + + # get federation group domains + r = self.get('/OS-FEDERATION/domains', token=unscoped_token) + group_domains = r.result['domains'] + domain_from_group = group_domains[0] + + # assign group domain and role to user, this should create a + # duplicate domain + self.assignment_api.create_grant(role_ref['id'], + user_id=user_id, + domain_id=domain_from_group['id']) + + # get user domains and test for duplicates + r = self.get('/OS-FEDERATION/domains', token=unscoped_token) + user_domains = r.result['domains'] + user_domain_ids = [] + for domain in user_domains: + self.assertNotIn(domain['id'], user_domain_ids) + user_domain_ids.append(domain['id']) + + def test_list_projects_for_user_duplicates(self): + # create role + role_ref = unit.new_role_ref() + self.role_api.create_role(role_ref['id'], role_ref) + + # authenticate via saml get back a user id and token + user_id, unscoped_token = self._authenticate_via_saml() + + # get federation group projects + r = self.get('/OS-FEDERATION/projects', token=unscoped_token) + group_projects = r.result['projects'] + project_from_group = group_projects[0] + + # assign group project and role to user, this should create a + # duplicate project + self.assignment_api.add_role_to_user_and_project( + user_id, project_from_group['id'], role_ref['id']) + + # get user projects and test for duplicates + r = self.get('/OS-FEDERATION/projects', token=unscoped_token) + user_projects = r.result['projects'] + user_project_ids = [] + for project in user_projects: + self.assertNotIn(project['id'], user_project_ids) + user_project_ids.append(project['id']) + + def _authenticate_via_saml(self): + r = self._issue_unscoped_token() + unscoped_token = r.headers['X-Subject-Token'] + token_resp = r.json_body['token'] + self.assertValidMappedUser(token_resp) + return token_resp['user']['id'], unscoped_token + class JsonHomeTests(test_v3.RestfulTestCase, test_v3.JsonHomeTestMixin): JSON_HOME_DATA = { diff --git a/keystone/token/providers/common.py b/keystone/token/providers/common.py index 86ade33a7f..fb32c8a2c5 100644 --- a/keystone/token/providers/common.py +++ b/keystone/token/providers/common.py @@ -294,12 +294,12 @@ class V3TokenDataHelper(object): user_id, project_id) return [self.role_api.get_role(role_id) for role_id in roles] - def populate_roles_for_groups(self, token_data, group_ids, - project_id=None, domain_id=None, - user_id=None): + def populate_roles_for_federated_user(self, token_data, group_ids, + project_id=None, domain_id=None, + user_id=None): """Populate roles basing on provided groups and project/domain. - Used for ephemeral users with dynamically assigned groups. + Used for federated users with dynamically assigned groups. This method does not return anything, yet it modifies token_data in place. @@ -309,8 +309,7 @@ class V3TokenDataHelper(object): :param domain_id: domain ID to scope to :param user_id: user ID - :raises keystone.exception.Unauthorized: when no roles were found for a - (group_ids, project_id) or (group_ids, domain_id) pairs. + :raises keystone.exception.Unauthorized: when no roles were found """ def check_roles(roles, user_id, project_id, domain_id): @@ -335,6 +334,12 @@ class V3TokenDataHelper(object): roles = self.assignment_api.get_roles_for_groups(group_ids, project_id, domain_id) + roles = roles + self._get_roles_for_user(user_id, domain_id, + project_id) + + # remove duplicates + roles = [dict(t) for t in set([tuple(d.items()) for d in roles])] + check_roles(roles, user_id, project_id, domain_id) token_data['roles'] = roles @@ -653,7 +658,7 @@ class BaseProvider(provider.Provider): } if project_id or domain_id: - self.v3_token_data_helper.populate_roles_for_groups( + self.v3_token_data_helper.populate_roles_for_federated_user( token_data, group_ids, project_id, domain_id, user_id) return token_data diff --git a/keystone/token/providers/fernet/core.py b/keystone/token/providers/fernet/core.py index 52fa80c806..f505f7b76c 100644 --- a/keystone/token/providers/fernet/core.py +++ b/keystone/token/providers/fernet/core.py @@ -153,7 +153,7 @@ class Provider(common.BaseProvider): """ group_ids = [x['id'] for x in federated_dict['group_ids']] - self.v3_token_data_helper.populate_roles_for_groups( + self.v3_token_data_helper.populate_roles_for_federated_user( token_dict, group_ids, project_id, domain_id, user_id) def _extract_v2_token_data(self, token_data): diff --git a/releasenotes/notes/policy_new_federated_projects_for_user-dcd7bd148efef049.yaml b/releasenotes/notes/policy_new_federated_projects_for_user-dcd7bd148efef049.yaml new file mode 100644 index 0000000000..78c8583f18 --- /dev/null +++ b/releasenotes/notes/policy_new_federated_projects_for_user-dcd7bd148efef049.yaml @@ -0,0 +1,7 @@ +--- +upgrade: + - In the policy.json file, we changed `identity:list_projects_for_groups` + to `identity:list_projects_for_user`. Likewise, we changed + `identity:list_domains_for_groups` to `identity:list_domains_for_user`. If + you have customized the policy.json file, you will need to make these + changes. This was done to better support new features around federation.