From 8e67249d5bfb07b0a236189f62b3f338532f0df0 Mon Sep 17 00:00:00 2001 From: Lance Bragstad Date: Mon, 16 Sep 2019 22:11:06 +0000 Subject: [PATCH] Add default roles and scope checking to project tags This commit makes it so that project tags adhere to system-scope and also incorporates default roles into the policy checks by default. Change-Id: Ie36df5677a08d7d95f056f3ea00eda05e1315ea5 Closes-Bug: 1844194 Closes-Bug: 1844193 Related-Bug: 1806762 --- api-ref/source/v3/project-tags.inc | 10 + etc/policy.v3cloudsample.json | 3 - keystone/api/projects.py | 30 +- keystone/common/policies/project.py | 110 +- .../tests/protection/v3/test_project_tags.py | 974 ++++++++++++++++++ keystone/tests/unit/test_policy.py | 2 + .../notes/bug-1844194-48ae60db49f91bd4.yaml | 43 + 7 files changed, 1128 insertions(+), 44 deletions(-) create mode 100644 keystone/tests/protection/v3/test_project_tags.py create mode 100644 releasenotes/notes/bug-1844194-48ae60db49f91bd4.yaml diff --git a/api-ref/source/v3/project-tags.inc b/api-ref/source/v3/project-tags.inc index 2021ec111e..a789a80915 100644 --- a/api-ref/source/v3/project-tags.inc +++ b/api-ref/source/v3/project-tags.inc @@ -19,6 +19,16 @@ Tags for projects have the following restrictions: - Each project can have a maximum of 80 tags - Each tag can be a maximum of 255 characters in length +.. warning:: + + We discourage the use of project tags for sensitive information like billing or + account codes. By default, access to project tags isn't exclusive to system + administrators or users. Domain and project administrators are allowed to + tag projects they have authorization to access. Domain and project users + (e.g., users with the ``member`` or ``reader`` roles) can view all project + tags on all projects within their domain or on projects they are + authorized to access. + List tags for a project ======================= diff --git a/etc/policy.v3cloudsample.json b/etc/policy.v3cloudsample.json index 7b21c4beae..8b82c1cf62 100644 --- a/etc/policy.v3cloudsample.json +++ b/etc/policy.v3cloudsample.json @@ -13,9 +13,6 @@ "identity:update_limit": "rule:admin_required", "identity:delete_limit": "rule:admin_required", - "identity:get_project_tag": "rule:admin_required", - "identity:list_project_tags": "rule:admin_required", - "domain_admin_matches_domain_role": "rule:admin_required and domain_id:%(role.domain_id)s", "get_domain_roles": "rule:domain_admin_matches_target_domain_role or rule:project_admin_matches_target_domain_role", "domain_admin_matches_target_domain_role": "rule:admin_required and domain_id:%(target.role.domain_id)s", diff --git a/keystone/api/projects.py b/keystone/api/projects.py index 4eb76b48f7..108971c212 100644 --- a/keystone/api/projects.py +++ b/keystone/api/projects.py @@ -236,7 +236,10 @@ class ProjectTagsResource(_ProjectTagResourceBase): GET /v3/projects/{project_id}/tags """ - ENFORCER.enforce_call(action='identity:list_project_tags') + ENFORCER.enforce_call( + action='identity:list_project_tags', + build_target=_build_project_target_enforcement + ) ref = PROVIDERS.resource_api.list_project_tags(project_id) return self.wrap_member(ref) @@ -245,7 +248,10 @@ class ProjectTagsResource(_ProjectTagResourceBase): PUT /v3/projects/{project_id}/tags """ - ENFORCER.enforce_call(action='identity:update_project_tags') + ENFORCER.enforce_call( + action='identity:update_project_tags', + build_target=_build_project_target_enforcement + ) tags = self.request_body_json.get('tags', {}) validation.lazy_validate(schema.project_tags_update, tags) ref = PROVIDERS.resource_api.update_project_tags( @@ -257,7 +263,10 @@ class ProjectTagsResource(_ProjectTagResourceBase): DELETE /v3/projects/{project_id}/tags """ - ENFORCER.enforce_call(action='identity:delete_project_tags') + ENFORCER.enforce_call( + action='identity:delete_project_tags', + build_target=_build_project_target_enforcement + ) PROVIDERS.resource_api.update_project_tags(project_id, []) return None, http_client.NO_CONTENT @@ -268,7 +277,10 @@ class ProjectTagResource(_ProjectTagResourceBase): GET /v3/projects/{project_id}/tags/{value} """ - ENFORCER.enforce_call(action='identity:get_project_tag') + ENFORCER.enforce_call( + action='identity:get_project_tag', + build_target=_build_project_target_enforcement, + ) PROVIDERS.resource_api.get_project_tag(project_id, value) return None, http_client.NO_CONTENT @@ -277,7 +289,10 @@ class ProjectTagResource(_ProjectTagResourceBase): PUT /v3/projects/{project_id}/tags/{value} """ - ENFORCER.enforce_call(action='identity:create_project_tag') + ENFORCER.enforce_call( + action='identity:create_project_tag', + build_target=_build_project_target_enforcement + ) validation.lazy_validate(schema.project_tag_create, value) # Check if we will exceed the max number of tags on this project tags = PROVIDERS.resource_api.list_project_tags(project_id) @@ -298,7 +313,10 @@ class ProjectTagResource(_ProjectTagResourceBase): /v3/projects/{project_id}/tags/{value} """ - ENFORCER.enforce_call(action='identity:delete_project_tag') + ENFORCER.enforce_call( + action='identity:delete_project_tag', + build_target=_build_project_target_enforcement + ) PROVIDERS.resource_api.delete_project_tag(project_id, value) return None, http_client.NO_CONTENT diff --git a/keystone/common/policies/project.py b/keystone/common/policies/project.py index cde64a93b9..50f8fa7826 100644 --- a/keystone/common/policies/project.py +++ b/keystone/common/policies/project.py @@ -21,6 +21,12 @@ SYSTEM_READER_OR_DOMAIN_READER_OR_PROJECT_USER = ( 'project_id:%(target.project.id)s' ) +SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN = ( + '(' + base.SYSTEM_ADMIN + ') or ' + '(role:admin and domain_id:%(target.project.domain_id)s) or ' + '(role:admin and project_id:%(target.project.id)s)' +) + # This policy is only written to be used to protect the # /v3/users/{user_id}/projects API. It should not be used to protect # /v3/project APIs because the target information contained in the last check @@ -70,6 +76,31 @@ deprecated_delete_project = policy.DeprecatedRule( name=base.IDENTITY % 'delete_project', check_str=base.RULE_ADMIN_REQUIRED ) +deprecated_list_project_tags = policy.DeprecatedRule( + name=base.IDENTITY % 'list_project_tags', + check_str=base.RULE_ADMIN_OR_TARGET_PROJECT +) +deprecated_get_project_tag = policy.DeprecatedRule( + name=base.IDENTITY % 'get_project_tag', + check_str=base.RULE_ADMIN_OR_TARGET_PROJECT +) +deprecated_update_project_tag = policy.DeprecatedRule( + name=base.IDENTITY % 'update_project_tags', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_create_project_tag = policy.DeprecatedRule( + name=base.IDENTITY % 'create_project_tag', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_delete_project_tag = policy.DeprecatedRule( + name=base.IDENTITY % 'delete_project_tag', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_delete_project_tags = policy.DeprecatedRule( + name=base.IDENTITY % 'delete_project_tags', + check_str=base.RULE_ADMIN_REQUIRED +) + DEPRECATED_REASON = """ As of the Stein release, the project API understands how to handle @@ -79,6 +110,14 @@ administrators. The new default policies for this API account for these changes automatically. """ +TAGS_DEPRECATED_REASON = """ +As of the Train release, the project tags API understands how to handle +system-scoped tokens in addition to project and domain tokens, making the API +more accessible to users without compromising security or manageability for +administrators. The new default policies for this API account for these changes +automatically. +""" + project_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_project', @@ -146,67 +185,68 @@ project_policies = [ deprecated_since=versionutils.deprecated.STEIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_project_tags', - check_str=base.RULE_ADMIN_OR_TARGET_PROJECT, - # FIXME(lbragstad): We need to make sure we check the project in the - # token scope when authorizing APIs for project tags. System - # administrators should be able to tag any project with anything. - # Domain administrators should only be able to tag projects within - # their domain. Project administrators should only be able to tag their - # project. Until we have support for these cases in code and tested, we - # should keep scope_types commented out. - # scope_types=['system', 'project'], + check_str=SYSTEM_READER_OR_DOMAIN_READER_OR_PROJECT_USER, + scope_types=['system', 'domain', 'project'], description='List tags for a project.', operations=[{'path': '/v3/projects/{project_id}/tags', 'method': 'GET'}, {'path': '/v3/projects/{project_id}/tags', - 'method': 'HEAD'}]), + 'method': 'HEAD'}], + deprecated_rule=deprecated_list_project_tags, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_project_tag', - check_str=base.RULE_ADMIN_OR_TARGET_PROJECT, - # FIXME(lbragstad): See the above comments as to why this is commented - # out. - # scope_types=['system', 'project'], + check_str=SYSTEM_READER_OR_DOMAIN_READER_OR_PROJECT_USER, + scope_types=['system', 'domain', 'project'], description='Check if project contains a tag.', operations=[{'path': '/v3/projects/{project_id}/tags/{value}', 'method': 'GET'}, {'path': '/v3/projects/{project_id}/tags/{value}', - 'method': 'HEAD'}]), + 'method': 'HEAD'}], + deprecated_rule=deprecated_get_project_tag, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'update_project_tags', - check_str=base.RULE_ADMIN_REQUIRED, - # FIXME(lbragstad): See the above comment for create_project as to why - # this is limited to only system-scope. - scope_types=['system'], + check_str=SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN, + scope_types=['system', 'domain', 'project'], description='Replace all tags on a project with the new set of tags.', operations=[{'path': '/v3/projects/{project_id}/tags', - 'method': 'PUT'}]), + 'method': 'PUT'}], + deprecated_rule=deprecated_update_project_tag, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'create_project_tag', - check_str=base.RULE_ADMIN_REQUIRED, - # FIXME(lbragstad): See the above comment for create_project as to why - # this is limited to only system-scope. - scope_types=['system'], + check_str=SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN, + scope_types=['system', 'domain', 'project'], description='Add a single tag to a project.', operations=[{'path': '/v3/projects/{project_id}/tags/{value}', - 'method': 'PUT'}]), + 'method': 'PUT'}], + deprecated_rule=deprecated_create_project_tag, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_project_tags', - check_str=base.RULE_ADMIN_REQUIRED, - # FIXME(lbragstad): See the above comment for create_project as to why - # this is limited to only system-scope. - scope_types=['system'], + check_str=SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN, + scope_types=['system', 'domain', 'project'], description='Remove all tags from a project.', operations=[{'path': '/v3/projects/{project_id}/tags', - 'method': 'DELETE'}]), + 'method': 'DELETE'}], + deprecated_rule=deprecated_delete_project_tags, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN), policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_project_tag', - check_str=base.RULE_ADMIN_REQUIRED, - # FIXME(lbragstad): See the above comment for create_project as to why - # this is limited to only system-scope. - scope_types=['system'], + check_str=SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN, + scope_types=['system', 'domain', 'project'], description='Delete a specified tag from project.', operations=[{'path': '/v3/projects/{project_id}/tags/{value}', - 'method': 'DELETE'}]) + 'method': 'DELETE'}], + deprecated_rule=deprecated_delete_project_tag, + deprecated_reason=TAGS_DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.TRAIN) ] diff --git a/keystone/tests/protection/v3/test_project_tags.py b/keystone/tests/protection/v3/test_project_tags.py new file mode 100644 index 0000000000..0807bedb6b --- /dev/null +++ b/keystone/tests/protection/v3/test_project_tags.py @@ -0,0 +1,974 @@ +# 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 oslo_serialization import jsonutils +from six.moves import http_client + +from keystone.common.policies import project as pp +from keystone.common import provider_api +import keystone.conf +from keystone.tests.common import auth as common_auth +from keystone.tests import unit +from keystone.tests.unit import base_classes +from keystone.tests.unit import ksfixtures +from keystone.tests.unit.ksfixtures import temporaryfile + +CONF = keystone.conf.CONF +PROVIDERS = provider_api.ProviderAPIs + + +def _override_policy(policy_file): + # TODO(lbragstad): Remove this once the deprecated policies in + # keystone.common.policies.project have been removed. This is only + # here to make sure we test the new policies instead of the deprecated + # ones. Oslo.policy will OR deprecated policies with new policies to + # maintain compatibility and give operators a chance to update + # permissions or update policies without breaking users. This will + # cause these specific tests to fail since we're trying to correct this + # broken behavior with better scope checking. + with open(policy_file, 'w') as f: + overridden_policies = { + 'identity:get_project_tag': ( + pp.SYSTEM_READER_OR_DOMAIN_READER_OR_PROJECT_USER + ), + 'identity:list_project_tags': ( + pp.SYSTEM_READER_OR_DOMAIN_READER_OR_PROJECT_USER + ), + 'identity:create_project_tag': ( + pp.SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN + ), + 'identity:update_project_tags': ( + pp.SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN + ), + 'identity:delete_project_tag': ( + pp.SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN + ), + 'identity:delete_project_tags': ( + pp.SYSTEM_ADMIN_OR_DOMAIN_ADMIN_OR_PROJECT_ADMIN + ) + } + f.write(jsonutils.dumps(overridden_policies)) + + +class _SystemUserTests(object): + def test_user_can_get_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.NO_CONTENT + ) + + def test_user_can_list_project_tags(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + r = c.get( + '/v3/projects/%s/tags' % project['id'], headers=self.headers + ) + self.assertTrue(len(r.json['tags']) == 1) + self.assertEqual(tag, r.json['tags'][0]) + + +class _SystemMemberAndReaderTagTests(object): + + def test_user_cannot_create_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class _DomainAndProjectUserTagTests(object): + + def test_user_cannot_create_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class SystemReaderTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _SystemUserTests, + _SystemMemberAndReaderTagTests): + + def setUp(self): + super(SystemReaderTests, self).setUp() + self.loadapp() + self.useFixture(ksfixtures.Policy(self.config_fixture)) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + system_reader = unit.new_user_ref( + domain_id=CONF.identity.default_domain_id + ) + self.user_id = PROVIDERS.identity_api.create_user( + system_reader + )['id'] + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.bootstrapper.reader_role_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=system_reader['password'], + system=True + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + +class SystemMemberTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _SystemUserTests, + _SystemMemberAndReaderTagTests): + + def setUp(self): + super(SystemMemberTests, self).setUp() + self.loadapp() + self.useFixture(ksfixtures.Policy(self.config_fixture)) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + system_member = unit.new_user_ref( + domain_id=CONF.identity.default_domain_id + ) + self.user_id = PROVIDERS.identity_api.create_user( + system_member + )['id'] + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.bootstrapper.member_role_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=system_member['password'], + system=True + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + +class SystemAdminTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _SystemUserTests): + + def setUp(self): + super(SystemAdminTests, self).setUp() + self.loadapp() + self.useFixture(ksfixtures.Policy(self.config_fixture)) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + self.user_id = self.bootstrapper.admin_user_id + auth = self.build_authentication_request( + user_id=self.user_id, + password=self.bootstrapper.admin_password, + system=True + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + def test_user_can_create_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.CREATED + ) + + def test_user_can_update_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, + expected_status_code=http_client.OK + ) + + def test_user_can_delete_project_tag(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers + ) + + +class _DomainUserTagTests(object): + + def test_user_can_get_tag_for_project_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.NO_CONTENT + ) + + def test_user_can_list_tags_for_project_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + r = c.get( + '/v3/projects/%s/tags' % project['id'], headers=self.headers + ) + self.assertTrue(len(r.json['tags']) == 1) + self.assertEqual(tag, r.json['tags'][0]) + + def test_user_cannot_create_project_tag_outside_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_project_tag_outside_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + new_tag = uuid.uuid4().hex + update = {"tags": [new_tag]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_project_tag_outside_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_get_tag_for_project_outside_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_list_tags_for_project_outside_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags' % project['id'], + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class _DomainMemberAndReaderTagTests(object): + + def test_user_cannot_create_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + new_tag = uuid.uuid4().hex + update = {"tags": [new_tag]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class DomainAdminUserTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _DomainUserTagTests): + + def setUp(self): + super(DomainAdminUserTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + domain = PROVIDERS.resource_api.create_domain( + uuid.uuid4().hex, unit.new_domain_ref() + ) + self.domain_id = domain['id'] + domain_admin = unit.new_user_ref(domain_id=self.domain_id) + self.user_id = PROVIDERS.identity_api.create_user(domain_admin)['id'] + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.admin_role_id, user_id=self.user_id, + domain_id=self.domain_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=domain_admin['password'], + domain_id=self.domain_id, + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + def test_user_can_create_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, expected_status_code=http_client.CREATED + ) + + def test_user_can_update_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + new_tag = uuid.uuid4().hex + update = {"tags": [new_tag]} + + with self.test_client() as c: + r = c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.OK + ) + self.assertTrue(len(r.json['tags']) == 1) + self.assertEqual(new_tag, r.json['tags'][0]) + + def test_user_can_delete_project_tag_in_domain(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, unit.new_project_ref(domain_id=self.domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers + ) + + +class DomainMemberUserTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _DomainUserTagTests, + _DomainMemberAndReaderTagTests): + + def setUp(self): + super(DomainMemberUserTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + domain = PROVIDERS.resource_api.create_domain( + uuid.uuid4().hex, unit.new_domain_ref() + ) + self.domain_id = domain['id'] + domain_admin = unit.new_user_ref(domain_id=self.domain_id) + self.user_id = PROVIDERS.identity_api.create_user(domain_admin)['id'] + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=self.user_id, + domain_id=self.domain_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=domain_admin['password'], + domain_id=self.domain_id, + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + +class DomainReaderUserTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _DomainUserTagTests, + _DomainMemberAndReaderTagTests): + + def setUp(self): + super(DomainReaderUserTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + domain = PROVIDERS.resource_api.create_domain( + uuid.uuid4().hex, unit.new_domain_ref() + ) + self.domain_id = domain['id'] + domain_admin = unit.new_user_ref(domain_id=self.domain_id) + self.user_id = PROVIDERS.identity_api.create_user(domain_admin)['id'] + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.reader_role_id, user_id=self.user_id, + domain_id=self.domain_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=domain_admin['password'], + domain_id=self.domain_id, + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + +class _ProjectUserTagTests(object): + + def test_user_can_get_tag_for_project(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags/%s' % (self.project_id, tag), + headers=self.headers, + expected_status_code=http_client.NO_CONTENT + ) + + def test_user_can_list_tags_for_project(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + with self.test_client() as c: + r = c.get( + '/v3/projects/%s/tags' % self.project_id, headers=self.headers + ) + self.assertTrue(len(r.json['tags']) == 1) + self.assertEqual(tag, r.json['tags'][0]) + + def test_user_cannot_create_tag_for_other_project(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_tag_for_other_project(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % project['id'], headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_tag_for_other_project(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_get_tag_for_other_project(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags/%s' % (project['id'], tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_list_tags_for_other_project(self): + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(project['id'], tag) + + with self.test_client() as c: + c.get( + '/v3/projects/%s/tags' % project['id'], + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class _ProjectMemberAndReaderTagTests(object): + + def test_user_cannot_create_project_tag(self): + tag = uuid.uuid4().hex + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (self.project_id, tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_project_tag(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % self.project_id, headers=self.headers, + json=update, expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_project_tag(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (self.project_id, tag), + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class ProjectAdminTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _ProjectUserTagTests): + + def setUp(self): + super(ProjectAdminTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + self.user_id = self.bootstrapper.admin_user_id + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.admin_role_id, user_id=self.user_id, + project_id=self.bootstrapper.project_id + ) + self.project_id = self.bootstrapper.project_id + + auth = self.build_authentication_request( + user_id=self.user_id, password=self.bootstrapper.admin_password, + project_id=self.bootstrapper.project_id + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + def test_user_can_create_project_tag(self): + tag = uuid.uuid4().hex + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags/%s' % (self.project_id, tag), + headers=self.headers, expected_status_code=http_client.CREATED + ) + + def test_user_can_update_project_tag(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + update = {"tags": [uuid.uuid4().hex]} + + with self.test_client() as c: + c.put( + '/v3/projects/%s/tags' % self.project_id, headers=self.headers, + json=update, expected_status_code=http_client.OK + ) + + def test_user_can_delete_project_tag(self): + tag = uuid.uuid4().hex + PROVIDERS.resource_api.create_project_tag(self.project_id, tag) + + with self.test_client() as c: + c.delete( + '/v3/projects/%s/tags/%s' % (self.project_id, tag), + headers=self.headers + ) + + +class ProjectMemberTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _ProjectUserTagTests, + _ProjectMemberAndReaderTagTests): + + def setUp(self): + super(ProjectMemberTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + self.project_id = project['id'] + self.user_id = self.bootstrapper.admin_user_id + + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=self.user_id, + project_id=self.project_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=self.bootstrapper.admin_password, + project_id=self.project_id + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} + + +class ProjectReaderTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _ProjectUserTagTests, + _ProjectMemberAndReaderTagTests): + + def setUp(self): + super(ProjectReaderTests, self).setUp() + self.loadapp() + + self.policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.policy_file_name = self.policy_file.file_name + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=self.policy_file_name + ) + ) + _override_policy(self.policy_file_name) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + project = PROVIDERS.resource_api.create_project( + uuid.uuid4().hex, + unit.new_project_ref(domain_id=CONF.identity.default_domain_id) + ) + self.project_id = project['id'] + self.user_id = self.bootstrapper.admin_user_id + + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.reader_role_id, user_id=self.user_id, + project_id=self.project_id + ) + + auth = self.build_authentication_request( + user_id=self.user_id, password=self.bootstrapper.admin_password, + project_id=self.project_id + ) + + # Grab a token using the persona we're testing and prepare headers + # for requests we'll be making in the tests. + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=auth) + self.token_id = r.headers['X-Subject-Token'] + self.headers = {'X-Auth-Token': self.token_id} diff --git a/keystone/tests/unit/test_policy.py b/keystone/tests/unit/test_policy.py index 53f1e77265..bc1e1805e3 100644 --- a/keystone/tests/unit/test_policy.py +++ b/keystone/tests/unit/test_policy.py @@ -280,6 +280,7 @@ class PolicyJsonTestCase(unit.TestCase): 'identity:get_mapping', 'identity:get_policy', 'identity:get_policy_for_endpoint', + 'identity:get_project_tag', 'identity:get_project', 'identity:get_protocol', 'identity:get_region', @@ -318,6 +319,7 @@ class PolicyJsonTestCase(unit.TestCase): 'identity:list_projects_associated_with_endpoint_group', 'identity:list_projects_for_endpoint', 'identity:list_projects_for_user', + 'identity:list_project_tags', 'identity:list_protocols', 'identity:list_regions', 'identity:list_registered_limits', diff --git a/releasenotes/notes/bug-1844194-48ae60db49f91bd4.yaml b/releasenotes/notes/bug-1844194-48ae60db49f91bd4.yaml new file mode 100644 index 0000000000..abdc173327 --- /dev/null +++ b/releasenotes/notes/bug-1844194-48ae60db49f91bd4.yaml @@ -0,0 +1,43 @@ +--- +features: + - | + [`bug 1844194 `_] + [`bug 1844193 `_] + The project tags API now supports the ``admin``, ``member``, and ``reader`` + default roles. +upgrade: + - | + [`bug 1844194 `_] + [`bug 1844193 `_] + The project tags API now uses new default policies that make it more + accessible to end users and administrators in a secure way. Please + consider these new defaults if your deployment overrides the project + tags policies. +deprecations: + - | + [`bug 1844194 `_] + [`bug 1844193 `_] + The project tags API policies have been deprecated. The + ``identity:get_project_tag`` and ``identity:list_project_tags`` + policies now use ``(role:reader and system_scope:all) or + (role:reader and domain_id:%(target.project.domain_id)s) or + project_id:%(target.project.id)s`` instead of + ``rule:admin_required or project_id:%(target.project.id)s``. The + ``identity:update_project_tags``, ``identity:delete_project_tags``, + ``identity:delete_project_tag``, and ``identity:create_project_tag`` + policies now use ``(role:admin and system_scope:all) or (role:admin + and domain_id:%(target.project.domain_id)s) or (role:admin and + project_id:%(target.project.id)s)`` instead of + ``rule:admin_required``. + + These new defaults automatically account for system-scope and support + a read-only role, making it easier for system administrators to + delegate subsets of responsibility with compromising security. Please + consider these new defaults if your deployment overrides the project + tag policies. +security: + - | + [`bug 1844194 `_] + [`bug 1844193 `_] + The project tags API now uses system-scope and default roles to + provide better accessibility to users in a secure way.