diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index 8f6fda38ca..f258e19de4 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -21,16 +21,31 @@ from six.moves import http_client from keystone.common import provider_api from keystone.common import rbac_enforcer from keystone.common import validation +import keystone.conf from keystone.credential import schema from keystone import exception from keystone.i18n import _ from keystone.server import flask as ks_flask - +CONF = keystone.conf.CONF PROVIDERS = provider_api.ProviderAPIs ENFORCER = rbac_enforcer.RBACEnforcer +def _build_target_enforcement(): + target = {} + try: + target['credential'] = PROVIDERS.credential_api.get_credential( + flask.request.view_args.get('credential_id') + ) + except exception.NotFound: # nosec + # Defer existance in the event the credential doesn't exist, we'll + # check this later anyway. + pass + + return target + + class CredentialResource(ks_flask.ResourceBase): collection_key = 'credentials' member_key = 'credential' @@ -75,17 +90,34 @@ class CredentialResource(ks_flask.ResourceBase): def _list_credentials(self): filters = ['user_id', 'type'] + if not self.oslo_context.system_scope: + target = {'credential': {'user_id': self.oslo_context.user_id}} + else: + target = None ENFORCER.enforce_call(action='identity:list_credentials', - filters=filters) + filters=filters, target_attr=target) hints = self.build_driver_hints(filters) refs = PROVIDERS.credential_api.list_credentials(hints) + # If the request was filtered, make sure to return only the + # credentials specific to that user. This makes it so that users with + # roles on projects can't see credentials that aren't theirs. + if (not self.oslo_context.system_scope and + CONF.oslo_policy.enforce_scope): + filtered_refs = [] + for ref in refs: + if ref['user_id'] == target['credential']['user_id']: + filtered_refs.append(ref) + refs = filtered_refs refs = [self._blob_to_json(r) for r in refs] return self.wrap_collection(refs, hints=hints) def _get_credential(self, credential_id): - ENFORCER.enforce_call(action='identity:get_credential') - ref = PROVIDERS.credential_api.get_credential(credential_id) - return self.wrap_member(self._blob_to_json(ref)) + ENFORCER.enforce_call( + action='identity:get_credential', + build_target=_build_target_enforcement + ) + credential = PROVIDERS.credential_api.get_credential(credential_id) + return self.wrap_member(self._blob_to_json(credential)) def get(self, credential_id=None): # Get Credential or List of credentials. @@ -97,8 +129,12 @@ class CredentialResource(ks_flask.ResourceBase): def post(self): # Create a new credential - ENFORCER.enforce_call(action='identity:create_credential') credential = flask.request.json.get('credential', {}) + target = {} + target['credential'] = credential + ENFORCER.enforce_call( + action='identity:create_credential', target_attr=target + ) validation.lazy_validate(schema.credential_create, credential) trust_id = getattr(self.oslo_context, 'trust_id', None) ref = self._assign_unique_id( @@ -108,7 +144,12 @@ class CredentialResource(ks_flask.ResourceBase): def patch(self, credential_id): # Update Credential - ENFORCER.enforce_call(action='identity:update_credential') + ENFORCER.enforce_call( + action='identity:update_credential', + build_target=_build_target_enforcement + ) + PROVIDERS.credential_api.get_credential(credential_id) + credential = flask.request.json.get('credential', {}) validation.lazy_validate(schema.credential_update, credential) self._require_matching_id(credential) @@ -118,7 +159,11 @@ class CredentialResource(ks_flask.ResourceBase): def delete(self, credential_id): # Delete credentials - ENFORCER.enforce_call(action='identity:delete_credential') + ENFORCER.enforce_call( + action='identity:delete_credential', + build_target=_build_target_enforcement + ) + return (PROVIDERS.credential_api.delete_credential(credential_id), http_client.NO_CONTENT) diff --git a/keystone/common/policies/credential.py b/keystone/common/policies/credential.py index 147f31c901..f04ba5438d 100644 --- a/keystone/common/policies/credential.py +++ b/keystone/common/policies/credential.py @@ -10,56 +10,109 @@ # License for the specific language governing permissions and limitations # under the License. +from oslo_log import versionutils from oslo_policy import policy from keystone.common.policies import base +SYSTEM_READER_OR_CRED_OWNER = ( + '(role:reader and system_scope:all) ' + 'or user_id:%(target.credential.user_id)s' +) +SYSTEM_MEMBER_OR_CRED_OWNER = ( + '(role:member and system_scope:all) ' + 'or user_id:%(target.credential.user_id)s' +) +SYSTEM_ADMIN_OR_CRED_OWNER = ( + '(role:admin and system_scope:all) ' + 'or user_id:%(target.credential.user_id)s' +) + +DEPRECATED_REASON = ( + 'As of the Stein release, the credential API now understands how to ' + 'handle system-scoped tokens in addition to project-scoped 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.' +) +deprecated_get_credential = policy.DeprecatedRule( + name=base.IDENTITY % 'get_credential', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_list_credentials = policy.DeprecatedRule( + name=base.IDENTITY % 'list_credentials', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_create_credential = policy.DeprecatedRule( + name=base.IDENTITY % 'create_credential', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_update_credential = policy.DeprecatedRule( + name=base.IDENTITY % 'update_credential', + check_str=base.RULE_ADMIN_REQUIRED +) +deprecated_delete_credential = policy.DeprecatedRule( + name=base.IDENTITY % 'delete_credential', + check_str=base.RULE_ADMIN_REQUIRED +) + + credential_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_credential', - check_str=base.RULE_ADMIN_REQUIRED, - # FIXME(lbragstad): Credentials aren't really project-scoped or - # system-scoped. Instead, they are tied to a user. If this API is - # called with a system-scoped token, it's a system-administrator and - # they should be able to get any credential for management reasons. If - # this API is called with a project-scoped token, then extra - # enforcement needs to happen based on who created the credential, what - # projects they are members of, and the project the token is scoped to. - # When we fully support the second case, we can add `project` to the - # list of scope_types. This comment applies to the rest of the policies - # in this module. - # scope_types=['system', 'project'], + check_str=SYSTEM_READER_OR_CRED_OWNER, + scope_types=['system', 'project'], description='Show credentials details.', operations=[{'path': '/v3/credentials/{credential_id}', - 'method': 'GET'}]), + 'method': 'GET'}], + deprecated_rule=deprecated_get_credential, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.STEIN + ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_credentials', - check_str=base.RULE_ADMIN_REQUIRED, - # scope_types=['system', 'project'], + check_str=SYSTEM_READER_OR_CRED_OWNER, + scope_types=['system', 'project'], description='List credentials.', operations=[{'path': '/v3/credentials', - 'method': 'GET'}]), + 'method': 'GET'}], + deprecated_rule=deprecated_list_credentials, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.STEIN + ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'create_credential', - check_str=base.RULE_ADMIN_REQUIRED, - # scope_types=['system', 'project'], + check_str=SYSTEM_ADMIN_OR_CRED_OWNER, + scope_types=['system', 'project'], description='Create credential.', operations=[{'path': '/v3/credentials', - 'method': 'POST'}]), + 'method': 'POST'}], + deprecated_rule=deprecated_create_credential, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.STEIN + ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'update_credential', - check_str=base.RULE_ADMIN_REQUIRED, - # scope_types=['system', 'project'], + check_str=SYSTEM_MEMBER_OR_CRED_OWNER, + scope_types=['system', 'project'], description='Update credential.', operations=[{'path': '/v3/credentials/{credential_id}', - 'method': 'PATCH'}]), + 'method': 'PATCH'}], + deprecated_rule=deprecated_update_credential, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.STEIN + ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_credential', - check_str=base.RULE_ADMIN_REQUIRED, - # scope_types=['system', 'project'], + check_str=SYSTEM_ADMIN_OR_CRED_OWNER, + scope_types=['system', 'project'], description='Delete credential.', operations=[{'path': '/v3/credentials/{credential_id}', - 'method': 'DELETE'}]) + 'method': 'DELETE'}], + deprecated_rule=deprecated_delete_credential, + deprecated_reason=DEPRECATED_REASON, + deprecated_since=versionutils.deprecated.STEIN + ) ] diff --git a/keystone/tests/unit/base_classes.py b/keystone/tests/unit/base_classes.py index fb6e3af9b2..39001d750e 100644 --- a/keystone/tests/unit/base_classes.py +++ b/keystone/tests/unit/base_classes.py @@ -41,6 +41,7 @@ class TestCaseWithBootstrap(core.BaseTestCase): self.useFixture(database.Database()) super(TestCaseWithBootstrap, self).setUp() self.config_fixture = self.useFixture(config_fixture.Config(CONF)) + CONF(args=[], project='keystone') self.useFixture( ksfixtures.KeyRepository( self.config_fixture, diff --git a/keystone/tests/unit/protection/__init__.py b/keystone/tests/unit/protection/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/tests/unit/protection/v3/__init__.py b/keystone/tests/unit/protection/v3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/keystone/tests/unit/protection/v3/test_credentials.py b/keystone/tests/unit/protection/v3/test_credentials.py new file mode 100644 index 0000000000..21a26519ef --- /dev/null +++ b/keystone/tests/unit/protection/v3/test_credentials.py @@ -0,0 +1,1137 @@ +# 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 credential as cp +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 + + +class _UserCredentialTests(object): + """Test cases for anyone that has a valid user token.""" + + def test_user_can_create_credentials_for_themselves(self): + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'user_id': self.user_id, + 'type': uuid.uuid4().hex + } + } + with self.test_client() as c: + c.post('/v3/credentials', json=create, headers=self.headers) + + def test_user_can_get_their_credentials(self): + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': self.user_id + } + } + r = c.post('/v3/credentials', json=create, headers=self.headers) + credential_id = r.json['credential']['id'] + + path = '/v3/credentials/%s' % credential_id + r = c.get(path, headers=self.headers) + self.assertEqual( + self.user_id, r.json['credential']['user_id'] + ) + + def test_user_can_list_their_credentials(self): + with self.test_client() as c: + expected = [] + for _ in range(2): + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': self.user_id + } + } + r = c.post( + '/v3/credentials', json=create, headers=self.headers + ) + expected.append(r.json['credential']) + + r = c.get('/v3/credentials', headers=self.headers) + for credential in expected: + self.assertIn(credential, r.json['credentials']) + + def test_user_can_filter_their_credentials_by_type_and_user(self): + with self.test_client() as c: + credential_type = uuid.uuid4().hex + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': credential_type, + 'user_id': self.user_id + } + } + r = c.post( + '/v3/credentials', json=create, headers=self.headers + ) + expected_credential_id = r.json['credential']['id'] + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': self.user_id + } + } + r = c.post( + '/v3/credentials', json=create, headers=self.headers + ) + + path = '/v3/credentials?type=%s' % credential_type + r = c.get(path, headers=self.headers) + self.assertEqual( + expected_credential_id, r.json['credentials'][0]['id'] + ) + + path = '/v3/credentials?user=%s' % self.user_id + r = c.get(path, headers=self.headers) + self.assertEqual( + expected_credential_id, r.json['credentials'][0]['id'] + ) + + def test_user_can_update_their_credential(self): + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': self.user_id + } + } + + r = c.post('/v3/credentials', json=create, headers=self.headers) + credential_id = r.json['credential']['id'] + + updated_blob = uuid.uuid4().hex + update = {'credential': {'blob': updated_blob}} + path = '/v3/credentials/%s' % credential_id + r = c.patch(path, json=update, headers=self.headers) + self.assertEqual(updated_blob, r.json['credential']['blob']) + + def test_user_can_delete_their_credentials(self): + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': self.user_id + } + } + r = c.post('/v3/credentials', json=create, headers=self.headers) + credential_id = r.json['credential']['id'] + + path = '/v3/credentials/%s' % credential_id + c.delete(path, headers=self.headers) + + +class _ProjectUsersTests(object): + """Users who have project role authorization observe the same behavior.""" + + def test_user_cannot_get_credentials_for_other_users(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + c.get( + path, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_get_non_existant_credential_forbidden(self): + with self.test_client() as c: + c.get( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_list_credentials_for_other_users(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post('/v3/credentials', json=create, headers=headers) + + with self.test_client() as c: + path = '/v3/credentials?user_id=%s' % user['id'] + r = c.get(path, headers=self.headers) + self.assertEqual([], r.json['credentials']) + + def test_user_cannot_filter_credentials_by_type_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + credential_type = uuid.uuid4().hex + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': credential_type, + 'user_id': user['id'] + } + } + c.post('/v3/credentials', json=create, headers=headers) + + with self.test_client() as c: + path = '/v3/credentials?type=%s' % credential_type + r = c.get(path, headers=self.headers) + self.assertEqual(0, len(r.json['credentials'])) + + def test_user_cannot_filter_credentials_by_user_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + expected_cred_ids = [] + for _ in range(2): + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + expected_cred_ids.append(r.json['credential']['id']) + + with self.test_client() as c: + path = '/v3/credentials?user_id=%s' % user['id'] + r = c.get(path, headers=self.headers) + self.assertEqual([], r.json['credentials']) + + def test_user_cannot_update_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + path = '/v3/credentials/%s' % credential_id + c.patch( + path, json=update, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_non_existant_credential_forbidden(self): + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + + c.patch( + '/v3/credentials/%s' % uuid.uuid4().hex, json=update, + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_create_credentials_for_other_users(self): + user = PROVIDERS.identity_api.create_user( + unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + ) + + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post( + '/v3/credentials', json=create, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + c.delete( + path, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_non_existant_credential_forbidden(self): + with self.test_client() as c: + c.delete( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class _SystemUserCredentialTests(object): + """Tests that are common across all system users.""" + + def test_user_can_list_credentials_for_other_users(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + r = c.get('/v3/credentials', headers=self.headers) + self.assertEqual(1, len(r.json['credentials'])) + self.assertEqual(credential_id, r.json['credentials'][0]['id']) + self.assertEqual(user['id'], r.json['credentials'][0]['user_id']) + + def test_user_cannot_get_non_existant_credential_not_found(self): + with self.test_client() as c: + c.get( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.NOT_FOUND + ) + + def test_user_can_filter_credentials_by_type_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + credential_type = uuid.uuid4().hex + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': credential_type, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post('/v3/credentials', json=create, headers=headers) + + with self.test_client() as c: + path = '/v3/credentials?type=%s' % credential_type + r = c.get(path, headers=self.headers) + self.assertEqual(1, len(r.json['credentials'])) + self.assertEqual(credential_id, r.json['credentials'][0]['id']) + self.assertEqual(user['id'], r.json['credentials'][0]['user_id']) + + def test_user_can_filter_credentials_by_user_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + expected_cred_ids = [] + for _ in range(2): + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + expected_cred_ids.append(r.json['credential']['id']) + + with self.test_client() as c: + path = '/v3/credentials?user_id=%s' % user['id'] + r = c.get(path, headers=self.headers) + self.assertEqual(2, len(r.json['credentials'])) + for credential in r.json['credentials']: + self.assertIn(credential['id'], expected_cred_ids) + self.assertEqual(user['id'], credential['user_id']) + + +class SystemReaderTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _SystemUserCredentialTests): + + 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} + + def test_user_cannot_create_credentials_for_other_users(self): + user = PROVIDERS.identity_api.create_user( + unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + ) + + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post( + '/v3/credentials', json=create, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + path = '/v3/credentials/%s' % credential_id + c.patch( + path, json=update, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_update_non_existant_credential_forbidden(self): + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + + c.patch( + '/v3/credentials/%s' % uuid.uuid4().hex, json=update, + headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + c.delete( + path, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_non_existant_credential_forbidden(self): + with self.test_client() as c: + c.delete( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class SystemMemberTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _SystemUserCredentialTests): + + 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} + + def test_user_cannot_create_credentials_for_other_users(self): + user = PROVIDERS.identity_api.create_user( + unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + ) + + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post( + '/v3/credentials', json=create, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_can_update_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + path = '/v3/credentials/%s' % credential_id + c.patch(path, json=update, headers=self.headers) + + def test_user_cannot_update_non_existant_credential_not_found(self): + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + + c.patch( + '/v3/credentials/%s' % uuid.uuid4().hex, json=update, + headers=self.headers, + expected_status_code=http_client.NOT_FOUND + ) + + def test_user_cannot_delete_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + c.delete( + path, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + def test_user_cannot_delete_non_existant_credential_forbidden(self): + with self.test_client() as c: + c.delete( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.FORBIDDEN + ) + + +class SystemAdminTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _SystemUserCredentialTests): + + 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) + + # Reuse the system administrator account created during + # ``keystone-manage bootstrap`` + 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_credentials_for_other_users(self): + user = PROVIDERS.identity_api.create_user( + unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + ) + + with self.test_client() as c: + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + c.post('/v3/credentials', json=create, headers=self.headers) + + def test_user_can_update_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + updated_blob = uuid.uuid4().hex + update = {'credential': {'blob': updated_blob}} + r = c.patch(path, json=update, headers=self.headers) + self.assertEqual(updated_blob, r.json['credential']['blob']) + self.assertEqual(user['id'], r.json['credential']['user_id']) + + def test_user_cannot_update_non_existant_credential_not_found(self): + with self.test_client() as c: + update = {'credential': {'blob': uuid.uuid4().hex}} + + c.patch( + '/v3/credentials/%s' % uuid.uuid4().hex, json=update, + headers=self.headers, + expected_status_code=http_client.NOT_FOUND + ) + + def test_user_can_delete_credentials_for_others(self): + user = unit.new_user_ref(domain_id=CONF.identity.default_domain_id) + user_password = user['password'] + user = PROVIDERS.identity_api.create_user(user) + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + project = PROVIDERS.resource_api.create_project(project['id'], project) + PROVIDERS.assignment_api.create_grant( + self.bootstrapper.member_role_id, user_id=user['id'], + project_id=project['id'] + ) + user_auth = self.build_authentication_request( + user_id=user['id'], password=user_password, + project_id=project['id'] + ) + + with self.test_client() as c: + r = c.post('/v3/auth/tokens', json=user_auth) + token_id = r.headers['X-Subject-Token'] + headers = {'X-Auth-Token': token_id} + + create = { + 'credential': { + 'blob': uuid.uuid4().hex, + 'type': uuid.uuid4().hex, + 'user_id': user['id'] + } + } + r = c.post('/v3/credentials', json=create, headers=headers) + credential_id = r.json['credential']['id'] + + with self.test_client() as c: + path = '/v3/credentials/%s' % credential_id + c.delete(path, headers=self.headers) + + def test_user_cannot_delete_non_existant_credential_not_found(self): + with self.test_client() as c: + c.delete( + '/v3/credentials/%s' % uuid.uuid4().hex, headers=self.headers, + expected_status_code=http_client.NOT_FOUND + ) + + +class ProjectReaderTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _ProjectUsersTests): + + def setUp(self): + super(ProjectReaderTests, self).setUp() + self.loadapp() + self.useFixture(ksfixtures.Policy(self.config_fixture)) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + project_reader = unit.new_user_ref( + domain_id=CONF.identity.default_domain_id + ) + self.user_id = PROVIDERS.identity_api.create_user( + project_reader + )['id'] + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + self.project_id = PROVIDERS.resource_api.create_project( + project['id'], project + )['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=project_reader['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 ProjectMemberTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _ProjectUsersTests): + + def setUp(self): + super(ProjectMemberTests, self).setUp() + self.loadapp() + self.useFixture(ksfixtures.Policy(self.config_fixture)) + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + project_member = unit.new_user_ref( + domain_id=CONF.identity.default_domain_id + ) + self.user_id = PROVIDERS.identity_api.create_user( + project_member + )['id'] + project = unit.new_project_ref( + domain_id=CONF.identity.default_domain_id + ) + self.project_id = PROVIDERS.resource_api.create_project( + project['id'], project + )['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=project_member['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 ProjectAdminTests(base_classes.TestCaseWithBootstrap, + common_auth.AuthTestMixin, + _UserCredentialTests, + _ProjectUsersTests): + + 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 + ) + ) + self._override_policy() + self.config_fixture.config(group='oslo_policy', enforce_scope=True) + + # Reuse the system administrator account created during + # ``keystone-manage bootstrap`` + self.user_id = self.bootstrapper.admin_user_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 _override_policy(self): + # TODO(lbragstad): Remove this once the deprecated policies in + # keystone.common.policies.credentials 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(self.policy_file_name, 'w') as f: + overridden_policies = { + 'identity:get_credential': cp.SYSTEM_READER_OR_CRED_OWNER, + 'identity:list_credentials': cp.SYSTEM_READER_OR_CRED_OWNER, + 'identity:create_credential': cp.SYSTEM_ADMIN_OR_CRED_OWNER, + 'identity:update_credential': cp.SYSTEM_MEMBER_OR_CRED_OWNER, + 'identity:delete_credential': cp.SYSTEM_ADMIN_OR_CRED_OWNER + } + f.write(jsonutils.dumps(overridden_policies)) diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 1907c2093d..b5e08249ac 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -113,6 +113,11 @@ class CredentialTestCase(CredentialBaseTestCase): def test_list_credentials_filtered_by_type(self): """Call ``GET /credentials?type={type}``.""" + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + # The type ec2 was chosen, instead of a random string, # because the type must be in the list of supported types ec2_credential = unit.new_credential_ref(user_id=uuid.uuid4().hex, @@ -123,14 +128,14 @@ class CredentialTestCase(CredentialBaseTestCase): ec2_credential['id'], ec2_credential) # The type cert was chosen for the same reason as ec2 - r = self.get('/credentials?type=cert') + r = self.get('/credentials?type=cert', token=token) # Testing the filter for two different types self.assertValidCredentialListResponse(r, ref=self.credential) for cred in r.result['credentials']: self.assertEqual('cert', cred['type']) - r_ec2 = self.get('/credentials?type=ec2') + r_ec2 = self.get('/credentials?type=ec2', token=token) self.assertThat(r_ec2.result['credentials'], matchers.HasLength(1)) cred_ec2 = r_ec2.result['credentials'][0] @@ -143,6 +148,11 @@ class CredentialTestCase(CredentialBaseTestCase): user1_id = uuid.uuid4().hex user2_id = uuid.uuid4().hex + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + # Creating credentials for two different users credential_user1_ec2 = unit.new_credential_ref(user_id=user1_id, type=CRED_TYPE_EC2) @@ -156,7 +166,9 @@ class CredentialTestCase(CredentialBaseTestCase): PROVIDERS.credential_api.create_credential( credential_user2_cert['id'], credential_user2_cert) - r = self.get('/credentials?user_id=%s&type=ec2' % user1_id) + r = self.get( + '/credentials?user_id=%s&type=ec2' % user1_id, token=token + ) self.assertValidCredentialListResponse(r, ref=credential_user1_ec2) self.assertThat(r.result['credentials'], matchers.HasLength(1)) cred = r.result['credentials'][0] diff --git a/releasenotes/notes/bug-1788415-3190279e9c900f76.yaml b/releasenotes/notes/bug-1788415-3190279e9c900f76.yaml new file mode 100644 index 0000000000..9f9f2a92bb --- /dev/null +++ b/releasenotes/notes/bug-1788415-3190279e9c900f76.yaml @@ -0,0 +1,25 @@ +--- +upgrade: + - | + [`bug 1788415 `_] + [`bug 968696 `_] + Policies protecting the ``/v3/credentials`` API have changed defaults in + order to make the credentials API more accessible for all users and not + just operators or system administrator. Please consider these updates when + using this version of keystone since it could affect API behavior in your + deployment, especially if you're using a customized policy file. +security: + - | + [`bug 1788415 `_] + [`bug 968696 `_] + More granular policy checks have been applied to the credential API in + order to make it more self-service for users. By default, end users will + now have the ability to manage their credentials. +fixes: + - | + [`bug 1788415 `_] + [`bug 968696 `_] + Improved self-service support has been implemented in the credential API. + This means that end users have the ability to manage their own credentials + as opposed to filing tickets to have deployment administrators manage + credentials for users.