From 4590b6c70ce99c624e2f3cacbfc2b084187a4110 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Tue, 19 Mar 2024 10:15:24 +0000 Subject: [PATCH 01/26] Update .gitreview for stable/2024.1 Change-Id: I2716ba29765851c2e1ccdcfd2d6387b1ca6fc154 --- .gitreview | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitreview b/.gitreview index e8ebbd0e6a..f1bfce6036 100644 --- a/.gitreview +++ b/.gitreview @@ -2,3 +2,4 @@ host=review.opendev.org port=29418 project=openstack/keystone.git +defaultbranch=stable/2024.1 From b2261777d5f83b8589e83094a45bbe7f22492197 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Tue, 19 Mar 2024 10:15:26 +0000 Subject: [PATCH 02/26] Update TOX_CONSTRAINTS_FILE for stable/2024.1 Update the URL to the upper-constraints file to point to the redirect rule on releases.openstack.org so that anyone working on this branch will switch to the correct upper-constraints list automatically when the requirements repository branches. Until the requirements repository has as stable/2024.1 branch, tests will continue to use the upper-constraints list on master. Change-Id: I19d385728d243eca283d71fbd7169e3c4e84af6b --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index d10510ab2f..3c90bf5bc3 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ setenv = # TODO(stephenfin): Remove once we bump our upper-constraint to SQLAlchemy 2.0 SQLALCHEMY_WARN_20=1 deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/test-requirements.txt .[ldap,memcache] commands = @@ -43,7 +43,7 @@ allowlist_externals = {toxinidir}/tools/fast8.sh # NOTE(browne): This is required for the integration test job of the bandit # project. Please do not remove. deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/requirements.txt commands = bandit -r keystone -x 'keystone/tests/*' @@ -73,7 +73,7 @@ passenv = KSTEST_* [testenv:functional] deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/test-requirements.txt setenv = OS_TEST_PATH=./keystone/tests/functional commands = @@ -108,7 +108,7 @@ per-file-ignores = [testenv:docs] deps = - -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/doc/requirements.txt .[ldap,memcache] commands= From 9e51f6150c485dcfd76fe007084b85884feaf486 Mon Sep 17 00:00:00 2001 From: Christian Rohmann Date: Wed, 7 Jun 2023 14:49:35 +0200 Subject: [PATCH 03/26] sql: Fixup for invalid unique constraint on external_id in access_rule table There was a big drop of invalid constraints with [1]. One of them was on `external_id` in the access_rule table. While the change made it into a Alembic revision with [1], it still exists in the schema causing an a new Alembic autogeneration to actually add it again as a revision. [1] https://review.opendev.org/c/openstack/keystone/+/851845 [2] https://opendev.org/openstack/keystone/commit/7d169870fe418b9aa5765dc2f413ecdf9c8f1d48#diff-26484e3f6683ce7557e17b67220003784ff84fbe Closes-Bug: #1988297 Change-Id: I66626ba8771ef2aa8b3580fd3f5d15fd4b58ab48 (cherry picked from commit 694a2afd86348331facfd27bc287a54d61ce170d) --- keystone/application_credential/backends/sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keystone/application_credential/backends/sql.py b/keystone/application_credential/backends/sql.py index 057a0fa09e..b0fe63890a 100644 --- a/keystone/application_credential/backends/sql.py +++ b/keystone/application_credential/backends/sql.py @@ -69,7 +69,7 @@ class AccessRuleModel(sql.ModelBase, sql.ModelDictMixin): __tablename__ = 'access_rule' attributes = ['external_id', 'user_id', 'service', 'path', 'method'] id = sql.Column(sql.Integer, primary_key=True, nullable=False) - external_id = sql.Column(sql.String(64), index=True, unique=True) + external_id = sql.Column(sql.String(64), index=True) user_id = sql.Column(sql.String(64), index=True) service = sql.Column(sql.String(64)) path = sql.Column(sql.String(128)) From 0dcc423a2621943ab9188cff3edb9bc488339fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Douglas=20Mendiz=C3=A1bal?= Date: Wed, 27 Mar 2024 08:55:54 -0500 Subject: [PATCH 04/26] Allow admin to access tokens and credentials This patch modifies a few policies to allow users with the "admin" role to access /v3/auth/tokens and /v3/credentials. These policies were missed when we implemented Phase 1 of Secure RBAC. Change-Id: Id789c09121f1405f7ba5e4926498dab4ad98e057 (cherry picked from commit b31007e1b2ecbea5e1268d3e28d6230d0f5d09b2) --- .../common/policies/application_credential.py | 6 +++--- keystone/common/policies/base.py | 12 ++++++++---- keystone/common/policies/credential.py | 10 +++++----- keystone/common/policies/ec2_credential.py | 8 ++++---- keystone/common/policies/token.py | 16 +++++++++------- keystone/tests/protection/v3/test_credentials.py | 12 +++++++----- .../tests/protection/v3/test_ec2_credential.py | 4 ++-- 7 files changed, 38 insertions(+), 30 deletions(-) diff --git a/keystone/common/policies/application_credential.py b/keystone/common/policies/application_credential.py index bae998a397..12958d8cd2 100644 --- a/keystone/common/policies/application_credential.py +++ b/keystone/common/policies/application_credential.py @@ -46,7 +46,7 @@ application_credential_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_application_credential', - check_str=base.RULE_SYSTEM_READER_OR_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_OWNER, scope_types=['system', 'project'], description='Show application credential details.', operations=[{'path': resource_path, @@ -56,7 +56,7 @@ deprecated_rule=deprecated_get_application_credentials_for_user), policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_application_credentials', - check_str=base.RULE_SYSTEM_READER_OR_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_OWNER, scope_types=['system', 'project'], description='List application credentials for a user.', operations=[{'path': collection_path, @@ -73,7 +73,7 @@ 'method': 'POST'}]), policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_application_credential', - check_str=base.RULE_SYSTEM_ADMIN_OR_OWNER, + check_str=base.RULE_ADMIN_OR_OWNER, scope_types=['system', 'project'], description='Delete an application credential.', operations=[{'path': resource_path, diff --git a/keystone/common/policies/base.py b/keystone/common/policies/base.py index 5b323f5615..39df9eaa28 100644 --- a/keystone/common/policies/base.py +++ b/keystone/common/policies/base.py @@ -48,16 +48,20 @@ SYSTEM_ADMIN = 'role:admin and system_scope:all' DOMAIN_READER = 'role:reader and domain_id:%(target.domain_id)s' RULE_SYSTEM_ADMIN_OR_OWNER = '(' + SYSTEM_ADMIN + ') or rule:owner' -RULE_SYSTEM_READER_OR_OWNER = '(' + SYSTEM_READER + ') or rule:owner' +ADMIN_OR_SYSTEM_READER_OR_OWNER = ( + '(' + RULE_ADMIN_REQUIRED + ') or ' + '(' + SYSTEM_READER + ') or rule:owner' +) RULE_ADMIN_OR_SYSTEM_READER = 'rule:admin_required or (' + SYSTEM_READER + ')' # Credential and EC2 Credential policies -SYSTEM_READER_OR_CRED_OWNER = ( +ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER = ( + '(' + RULE_ADMIN_REQUIRED + ') or ' '(' + SYSTEM_READER + ') ' 'or user_id:%(target.credential.user_id)s' ) -SYSTEM_ADMIN_OR_CRED_OWNER = ( - '(' + SYSTEM_ADMIN + ') ' +ADMIN_OR_CRED_OWNER = ( + '(' + RULE_ADMIN_REQUIRED + ') ' 'or user_id:%(target.credential.user_id)s' ) diff --git a/keystone/common/policies/credential.py b/keystone/common/policies/credential.py index 675e31875f..41d49f6577 100644 --- a/keystone/common/policies/credential.py +++ b/keystone/common/policies/credential.py @@ -54,7 +54,7 @@ credential_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_credential', - check_str=base.SYSTEM_READER_OR_CRED_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, scope_types=['system', 'project'], description='Show credentials details.', operations=[{'path': '/v3/credentials/{credential_id}', @@ -63,7 +63,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_credentials', - check_str=base.SYSTEM_READER_OR_CRED_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, scope_types=['system', 'project'], description='List credentials.', operations=[{'path': '/v3/credentials', @@ -72,7 +72,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'create_credential', - check_str=base.SYSTEM_ADMIN_OR_CRED_OWNER, + check_str=base.ADMIN_OR_CRED_OWNER, scope_types=['system', 'project'], description='Create credential.', operations=[{'path': '/v3/credentials', @@ -81,7 +81,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'update_credential', - check_str=base.SYSTEM_ADMIN_OR_CRED_OWNER, + check_str=base.ADMIN_OR_CRED_OWNER, scope_types=['system', 'project'], description='Update credential.', operations=[{'path': '/v3/credentials/{credential_id}', @@ -90,7 +90,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_credential', - check_str=base.SYSTEM_ADMIN_OR_CRED_OWNER, + check_str=base.ADMIN_OR_CRED_OWNER, scope_types=['system', 'project'], description='Delete credential.', operations=[{'path': '/v3/credentials/{credential_id}', diff --git a/keystone/common/policies/ec2_credential.py b/keystone/common/policies/ec2_credential.py index 9e52709225..0a876587ce 100644 --- a/keystone/common/policies/ec2_credential.py +++ b/keystone/common/policies/ec2_credential.py @@ -48,7 +48,7 @@ ec2_credential_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_get_credential', - check_str=base.SYSTEM_READER_OR_CRED_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, scope_types=['system', 'project'], description='Show ec2 credential details.', operations=[{'path': ('/v3/users/{user_id}/credentials/OS-EC2/' @@ -58,7 +58,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_list_credentials', - check_str=base.RULE_SYSTEM_READER_OR_OWNER, + check_str=base.ADMIN_OR_SYSTEM_READER_OR_OWNER, scope_types=['system', 'project'], description='List ec2 credentials.', operations=[{'path': '/v3/users/{user_id}/credentials/OS-EC2', @@ -67,7 +67,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_create_credential', - check_str=base.RULE_SYSTEM_ADMIN_OR_OWNER, + check_str=base.RULE_ADMIN_OR_OWNER, scope_types=['system', 'project'], description='Create ec2 credential.', operations=[{'path': '/v3/users/{user_id}/credentials/OS-EC2', @@ -76,7 +76,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_delete_credential', - check_str=base.SYSTEM_ADMIN_OR_CRED_OWNER, + check_str=base.ADMIN_OR_CRED_OWNER, scope_types=['system', 'project'], description='Delete ec2 credential.', operations=[{'path': ('/v3/users/{user_id}/credentials/OS-EC2/' diff --git a/keystone/common/policies/token.py b/keystone/common/policies/token.py index cb321b0593..974c8d96f2 100644 --- a/keystone/common/policies/token.py +++ b/keystone/common/policies/token.py @@ -38,13 +38,15 @@ deprecated_since=versionutils.deprecated.TRAIN ) -SYSTEM_ADMIN_OR_TOKEN_SUBJECT = ( - '(role:admin and system_scope:all) or rule:token_subject' # nosec +ADMIN_OR_TOKEN_SUBJECT = ( + base.RULE_ADMIN_REQUIRED + ' or rule:token_subject' # nosec ) -SYSTEM_USER_OR_TOKEN_SUBJECT = ( +ADMIN_OR_SYSTEM_USER_OR_TOKEN_SUBJECT = ( + base.RULE_ADMIN_REQUIRED + ' or ' '(role:reader and system_scope:all) or rule:token_subject' # nosec ) -SYSTEM_USER_OR_SERVICE_OR_TOKEN_SUBJECT = ( +ADMIN_OR_SYSTEM_USER_OR_SERVICE_OR_TOKEN_SUBJECT = ( + base.RULE_ADMIN_REQUIRED + ' or ' '(role:reader and system_scope:all) ' # nosec 'or rule:service_role or rule:token_subject' # nosec ) @@ -53,7 +55,7 @@ token_policies = [ policy.DocumentedRuleDefault( name=base.IDENTITY % 'check_token', - check_str=SYSTEM_USER_OR_TOKEN_SUBJECT, + check_str=ADMIN_OR_SYSTEM_USER_OR_TOKEN_SUBJECT, scope_types=['system', 'domain', 'project'], description='Check a token.', operations=[{'path': '/v3/auth/tokens', @@ -61,7 +63,7 @@ deprecated_rule=deprecated_check_token), policy.DocumentedRuleDefault( name=base.IDENTITY % 'validate_token', - check_str=SYSTEM_USER_OR_SERVICE_OR_TOKEN_SUBJECT, + check_str=ADMIN_OR_SYSTEM_USER_OR_SERVICE_OR_TOKEN_SUBJECT, scope_types=['system', 'domain', 'project'], description='Validate a token.', operations=[{'path': '/v3/auth/tokens', @@ -69,7 +71,7 @@ deprecated_rule=deprecated_validate_token), policy.DocumentedRuleDefault( name=base.IDENTITY % 'revoke_token', - check_str=SYSTEM_ADMIN_OR_TOKEN_SUBJECT, + check_str=ADMIN_OR_TOKEN_SUBJECT, scope_types=['system', 'domain', 'project'], description='Revoke a token.', operations=[{'path': '/v3/auth/tokens', diff --git a/keystone/tests/protection/v3/test_credentials.py b/keystone/tests/protection/v3/test_credentials.py index 800452f670..5a1960e38e 100644 --- a/keystone/tests/protection/v3/test_credentials.py +++ b/keystone/tests/protection/v3/test_credentials.py @@ -1131,11 +1131,13 @@ def _override_policy(self): # broken behavior with better scope checking. with open(self.policy_file_name, 'w') as f: overridden_policies = { - 'identity:get_credential': bp.SYSTEM_READER_OR_CRED_OWNER, - 'identity:list_credentials': bp.SYSTEM_READER_OR_CRED_OWNER, - 'identity:create_credential': bp.SYSTEM_ADMIN_OR_CRED_OWNER, - 'identity:update_credential': bp.SYSTEM_ADMIN_OR_CRED_OWNER, - 'identity:delete_credential': bp.SYSTEM_ADMIN_OR_CRED_OWNER + 'identity:get_credential': + bp.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, + 'identity:list_credentials': + bp.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, + 'identity:create_credential': bp.ADMIN_OR_CRED_OWNER, + 'identity:update_credential': bp.ADMIN_OR_CRED_OWNER, + 'identity:delete_credential': bp.ADMIN_OR_CRED_OWNER } f.write(jsonutils.dumps(overridden_policies)) diff --git a/keystone/tests/protection/v3/test_ec2_credential.py b/keystone/tests/protection/v3/test_ec2_credential.py index 7614173917..1f995d5a08 100644 --- a/keystone/tests/protection/v3/test_ec2_credential.py +++ b/keystone/tests/protection/v3/test_ec2_credential.py @@ -402,9 +402,9 @@ def _override_policy(self): # 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. - reader_or_cred_owner = bp.SYSTEM_READER_OR_CRED_OWNER + reader_or_cred_owner = bp.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER reader_or_owner = bp.RULE_SYSTEM_READER_OR_OWNER - admin_or_cred_owner = bp.SYSTEM_ADMIN_OR_CRED_OWNER + admin_or_cred_owner = bp.ADMIN_OR_CRED_OWNER with open(self.policy_file_name, 'w') as f: overridden_policies = { 'identity:ec2_get_credential': reader_or_cred_owner, From a13bec662e26db7cfb0a95393993ee9f46f40b69 Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Thu, 18 Apr 2024 03:12:19 +0900 Subject: [PATCH 05/26] Allow domain users to manage credentials Credentials are associated with users so there is no reason we prevent domain users from accessing the resources. In some services like heat domain admin is used to generate keystone credentials and loosing the scope check is required to continue supporting such use case. Closes-Bug: #2062045 Change-Id: I140b302d879ce1cc1f8d8de9e666cc74278a977f (cherry picked from commit 5ead95ffcc597517feed53170e2d2f77cdd311a1) --- keystone/common/policies/credential.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/keystone/common/policies/credential.py b/keystone/common/policies/credential.py index 41d49f6577..84a62d7ea3 100644 --- a/keystone/common/policies/credential.py +++ b/keystone/common/policies/credential.py @@ -55,7 +55,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_credential', check_str=base.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='Show credentials details.', operations=[{'path': '/v3/credentials/{credential_id}', 'method': 'GET'}], @@ -64,7 +64,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_credentials', check_str=base.ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='List credentials.', operations=[{'path': '/v3/credentials', 'method': 'GET'}], @@ -73,7 +73,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'create_credential', check_str=base.ADMIN_OR_CRED_OWNER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='Create credential.', operations=[{'path': '/v3/credentials', 'method': 'POST'}], @@ -82,7 +82,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'update_credential', check_str=base.ADMIN_OR_CRED_OWNER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='Update credential.', operations=[{'path': '/v3/credentials/{credential_id}', 'method': 'PATCH'}], @@ -91,7 +91,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'delete_credential', check_str=base.ADMIN_OR_CRED_OWNER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='Delete credential.', operations=[{'path': '/v3/credentials/{credential_id}', 'method': 'DELETE'}], From d42607e113d7ec7ee7498cc1a387bd448206d80a Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Fri, 29 Mar 2024 23:32:48 +0900 Subject: [PATCH 06/26] Allow domain admin to view roles Domain admins are allowed to assign roles. So it should be allowed to view roles. Note that protection job is made non-voting until the domain admin role test cases are updated. Closes-Bug: #2059780 Change-Id: Ifc25cf32ffcdb3b8a62d6741bc38e14bca0d7763 (cherry picked from commit 522627de3c66113d03019122735cdfc3e0d245c8) --- .zuul.yaml | 18 +++++++++--------- keystone/common/policies/role.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index b02bb766db..5cbc36a1cd 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -20,16 +20,16 @@ - openstack/keystone-tempest-plugin vars: tox_envlist: all - tempest_test_regex: 'keystone_tempest_plugin' + tempest_test_regex: "keystone_tempest_plugin" devstack_localrc: - TEMPEST_PLUGINS: '/opt/stack/keystone-tempest-plugin' + TEMPEST_PLUGINS: "/opt/stack/keystone-tempest-plugin" - job: name: keystone-dsvm-py3-functional parent: keystone-dsvm-functional vars: devstack_localrc: - TEMPEST_PLUGINS: '/opt/stack/keystone-tempest-plugin' + TEMPEST_PLUGINS: "/opt/stack/keystone-tempest-plugin" USE_PYTHON3: True - job: @@ -40,7 +40,7 @@ Functional testing for a FIPS enabled Centos 9 system pre-run: playbooks/enable-fips.yaml vars: - nslookup_target: 'opendev.org' + nslookup_target: "opendev.org" - job: name: keystone-dsvm-py3-functional-federation-ubuntu-jammy @@ -48,7 +48,7 @@ nodeset: openstack-single-node-jammy vars: devstack_localrc: - TEMPEST_PLUGINS: '/opt/stack/keystone-tempest-plugin' + TEMPEST_PLUGINS: "/opt/stack/keystone-tempest-plugin" USE_PYTHON3: True devstack_services: keystone-saml2-federation: true @@ -82,8 +82,8 @@ parent: devstack-tempest vars: devstack_localrc: - KEYSTONE_CLEAR_LDAP: 'yes' - LDAP_PASSWORD: 'nomoresecret' + KEYSTONE_CLEAR_LDAP: "yes" + LDAP_PASSWORD: "nomoresecret" USE_PYTHON3: True devstack_services: ldap: true @@ -135,9 +135,9 @@ parent: keystone-dsvm-functional vars: devstack_localrc: - TEMPEST_PLUGINS: '/opt/stack/keystone-tempest-plugin' + TEMPEST_PLUGINS: "/opt/stack/keystone-tempest-plugin" USE_PYTHON3: True - OS_CACERT: '/opt/stack/data/ca_bundle.pem' + OS_CACERT: "/opt/stack/data/ca_bundle.pem" devstack_services: tls-proxy: true keystone-oidc-federation: true diff --git a/keystone/common/policies/role.py b/keystone/common/policies/role.py index 0dbd793e93..a5ebd2647f 100644 --- a/keystone/common/policies/role.py +++ b/keystone/common/policies/role.py @@ -85,7 +85,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'get_role', check_str=base.RULE_ADMIN_OR_SYSTEM_READER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='Show role details.', operations=[{'path': '/v3/roles/{role_id}', 'method': 'GET'}, @@ -95,7 +95,7 @@ policy.DocumentedRuleDefault( name=base.IDENTITY % 'list_roles', check_str=base.RULE_ADMIN_OR_SYSTEM_READER, - scope_types=['system', 'project'], + scope_types=['system', 'domain', 'project'], description='List roles.', operations=[{'path': '/v3/roles', 'method': 'GET'}, From f8f451455fb11fb177148cfb88bed29caf18d38a Mon Sep 17 00:00:00 2001 From: Takashi Kajinami Date: Wed, 15 May 2024 20:01:46 +0900 Subject: [PATCH 07/26] Remove reference to devstack-gate devstack-gate was deprecated in xena and is being retired now[1]. [1] https://review.opendev.org/c/openstack/governance/+/919629 Change-Id: I14007ad2356fd41198ac412895c6ae88232b2681 --- .zuul.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.zuul.yaml b/.zuul.yaml index 5cbc36a1cd..dcea9210dc 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -15,7 +15,6 @@ parent: devstack-tempest timeout: 4200 required-projects: - - openstack/devstack-gate - openstack/keystone - openstack/keystone-tempest-plugin vars: From 62b3c9260b79b9596545debbd7e4fcd3b4eab972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Douglas=20Mendiz=C3=A1bal?= Date: Fri, 19 Jul 2024 17:10:11 -0400 Subject: [PATCH 08/26] Add keystone-manage reset_last_active command This patch adds the `reset_last_active` subcommand to the `keystone-manage` command line tool. This subcommand will update every user in the database that has a null value in the `last_active_at` property to the current server time. This is necessary to prevent user lockout in deployments that have been running for a long time without `disable_user_account_days_inactive` and later decide to turn it on. This patch also includes a change to the logic that sets `last_active_at` to fix the root issue of the lockout. Closes-Bug: 2074018 Change-Id: I1b71fb3881dc041db01083fbb4f2592400096a31 (cherry picked from commit e9513f8e4f25e1f20bc6fcab71d9177120000abf) --- keystone/cmd/cli.py | 42 +++++++++++++++++++ keystone/identity/backends/base.py | 10 +++++ keystone/identity/backends/ldap/core.py | 3 ++ keystone/identity/backends/sql.py | 8 ++++ keystone/identity/shadow_backends/sql.py | 9 ++-- .../identity/shadow_users/test_backend.py | 9 ++-- .../notes/bug-2074018-28f7bbe8f28f5efe.yaml | 29 +++++++++++++ 7 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 releasenotes/notes/bug-2074018-28f7bbe8f28f5efe.yaml diff --git a/keystone/cmd/cli.py b/keystone/cmd/cli.py index 8a862f77bc..29f59071bd 100644 --- a/keystone/cmd/cli.py +++ b/keystone/cmd/cli.py @@ -424,6 +424,47 @@ def main(): print(upgrades.get_db_version()) +class ResetLastActive(BaseApp): + """Reset null values for all users to current time.""" + + name = "reset_last_active" + + @classmethod + def add_argument_parser(cls, subparsers): + parser = super().add_argument_parser(subparsers) + parser.add_argument( + '--force', + action='store_true', + help='Write to the database without asking for confirmation', + ) + return parser + + @staticmethod + def main(): + if not CONF.command.force: + confirm = input( + "Security Warning: reset_last_active will update all users\n" + "in the database with a NULL value for last_active_at to be\n" + "last active at the current time. This includes users that\n" + "have never logged in. If your Keystone deployment is\n" + "configured to use disable_user_account_days_inactive, these\n" + "users will still be enabled and won't be disabled until the\n" + "configured amount of time has passed after this command is\n" + "run.\n" + "Are you sure you want to continue [y/N]? " + ) + if confirm.lower() not in ('y', 'yes'): + raise SystemExit('reset_last_active aborted.') + + LOG.debug( + "Resetting null values to current time %s", + datetime.datetime.utcnow, + ) + drivers = backends.load_backends() + identity_api = drivers['identity_api'] + identity_api.reset_last_active() + + class BasePermissionsSetup(BaseApp): """Common user/group setup for file permissions.""" @@ -1401,6 +1442,7 @@ def main(cls): ProjectSetup, ReceiptRotate, ReceiptSetup, + ResetLastActive, SamlIdentityProviderMetadata, TokenRotate, TokenSetup, diff --git a/keystone/identity/backends/base.py b/keystone/identity/backends/base.py index 45c454a4ad..a7a2eef6ce 100644 --- a/keystone/identity/backends/base.py +++ b/keystone/identity/backends/base.py @@ -345,6 +345,16 @@ def get_user_by_name(self, user_name, domain_id): """ raise exception.NotImplemented() # pragma: no cover + @abc.abstractmethod + def reset_last_active(self): + """Resets null last_active_at values. + + This method looks for all users in the database that have a null + value for last_updated_at and resets that value to the current + time. + """ + raise exception.NotImplemented() # pragma: no cover + # group crud @abc.abstractmethod diff --git a/keystone/identity/backends/ldap/core.py b/keystone/identity/backends/ldap/core.py index 07f037a408..72bc7da922 100644 --- a/keystone/identity/backends/ldap/core.py +++ b/keystone/identity/backends/ldap/core.py @@ -165,6 +165,9 @@ def update_user(self, user_id, user): def delete_user(self, user_id): raise exception.Forbidden(READ_ONLY_LDAP_ERROR_MESSAGE) + def reset_last_active(self): + raise exception.Forbidden(READ_ONLY_LDAP_ERROR_MESSAGE) + def change_password(self, user_id, new_password): raise exception.Forbidden(READ_ONLY_LDAP_ERROR_MESSAGE) diff --git a/keystone/identity/backends/sql.py b/keystone/identity/backends/sql.py index 4fbe4c573e..8ee9c0c062 100644 --- a/keystone/identity/backends/sql.py +++ b/keystone/identity/backends/sql.py @@ -390,6 +390,14 @@ def delete_user(self, user_id): session.delete(ref) + def reset_last_active(self): + with sql.session_for_write() as session: + session.query(model.User).filter( + model.User.last_active_at.is_(None).update( + {'last_active_at': datetime.datetime.utcnow()} + ) + ) + # group crud @sql.handle_conflicts(conflict_type='group') diff --git a/keystone/identity/shadow_backends/sql.py b/keystone/identity/shadow_backends/sql.py index a91902b1c7..4cbd201735 100644 --- a/keystone/identity/shadow_backends/sql.py +++ b/keystone/identity/shadow_backends/sql.py @@ -155,11 +155,10 @@ def _get_federated_user(self, idp_id, protocol_id, unique_id): return user_ref def set_last_active_at(self, user_id): - if CONF.security_compliance.disable_user_account_days_inactive: - with sql.session_for_write() as session: - user_ref = session.get(model.User, user_id) - if user_ref: - user_ref.last_active_at = datetime.datetime.utcnow().date() + with sql.session_for_write() as session: + user_ref = session.get(model.User, user_id) + if user_ref: + user_ref.last_active_at = datetime.datetime.utcnow().date() @sql.handle_conflicts(conflict_type='federated_user') def update_federated_user_display_name(self, idp_id, protocol_id, diff --git a/keystone/tests/unit/identity/shadow_users/test_backend.py b/keystone/tests/unit/identity/shadow_users/test_backend.py index 38300443ce..cd619d6c96 100644 --- a/keystone/tests/unit/identity/shadow_users/test_backend.py +++ b/keystone/tests/unit/identity/shadow_users/test_backend.py @@ -155,8 +155,11 @@ def fake_last_active_at(self, user_id): password=password) def test_set_last_active_at_when_config_setting_is_none(self): - self.config_fixture.config(group='security_compliance', - disable_user_account_days_inactive=None) + self.config_fixture.config( + group='security_compliance', + disable_user_account_days_inactive=None, + ) + now = datetime.datetime.utcnow().date() password = uuid.uuid4().hex user = self._create_user(password) with self.make_request(): @@ -164,7 +167,7 @@ def test_set_last_active_at_when_config_setting_is_none(self): user_id=user['id'], password=password) user_ref = self._get_user_ref(user_auth['id']) - self.assertIsNone(user_ref.last_active_at) + self.assertGreaterEqual(now, user_ref.last_active_at) def _add_nonlocal_user(self, nonlocal_user): with sql.session_for_write() as session: diff --git a/releasenotes/notes/bug-2074018-28f7bbe8f28f5efe.yaml b/releasenotes/notes/bug-2074018-28f7bbe8f28f5efe.yaml new file mode 100644 index 0000000000..d5758ffbe5 --- /dev/null +++ b/releasenotes/notes/bug-2074018-28f7bbe8f28f5efe.yaml @@ -0,0 +1,29 @@ +--- +features: + - | + Added a new command to the admin cli tool: + `keystone-manage reset_last_active`. This new command updates the database + to overwritet any NULL values in `last_active_at` in the user table to the + current time. This is a necessary step to fix Bug #2074018. See launchpad + for details. +fixes: + - | + Fixed Bug #2074018: Changed the user model to always save the date of the + last user activity in `last_active_at`. Previous to this change, the + `last_active_at` field was only updated when the option for + `[security_compliance] disable_user_account_days_inactive` was set. + If your deployment is affected by this bug, you must run + `keystone-manage reset_last_active` before setting the + `disable_user_account_days_inactive` option. +security: + - | + The new `keystone-manage rest_last_active` command resets all NULL values + in `last_active_at` in the user table to help fix Bug #2074018. Running + this command may be necessary in environments that have been deployed for + a long time and later decide to adopt the + `[security_compliance disable_user_account_days_inactive = X` option. + See Bug #2074018 for details. + + A side-effect of this command is that it resets the amount of time that an + unused account is active for. Unused accounts will remain active until the + configured days have elapsed since the day the command is run. From 46cc4f4473f4e040ec2fe950213fd1eed236ce7c Mon Sep 17 00:00:00 2001 From: Dave Wilde Date: Mon, 7 Oct 2024 10:56:05 -0500 Subject: [PATCH 09/26] Move OIDC job to check OIDC Federation is fully supported and should be checked on every commit. Change-Id: If1adbd81b4267b35ee292b635f1fbb30c5e274ac --- .zuul.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.zuul.yaml b/.zuul.yaml index dcea9210dc..b298894aef 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -194,6 +194,8 @@ irrelevant-files: *tempest-irrelevant-files # FIXME(dmendiza): temporarily disabling protection job # - keystone-protection-functional: + - keystone-dsvm-functional-oidc-federation: + irrelevant-files: *irrelevant-files gate: jobs: - keystone-tox-py310-with-sqlalchemy-2x @@ -218,5 +220,3 @@ irrelevant-files: *irrelevant-files - tempest-pg-full: irrelevant-files: *tempest-irrelevant-files - - keystone-dsvm-functional-oidc-federation: - irrelevant-files: *irrelevant-files From 2db5df14db2732ababef511495f695deadcbe310 Mon Sep 17 00:00:00 2001 From: gtema Date: Tue, 27 Feb 2024 14:09:21 +0100 Subject: [PATCH 10/26] Fix implied roles in the application credentials When user requests new application credentials without specifying roles explicitly all current roles from the token are being used for that (including implied roles). When new application credentials are requested specifying a role that implies another role (i.e. member) only that role is added into the list. This is not what is expected, so change it by looping through every requested role and add every implied role into the list if it is not already there. Related-Bug: https://bugs.launchpad.net/keystone/+bug/2030061 Change-Id: I452313ac7e1e6960748bcd1e667fb7c0076eb7a2 (cherry picked from commit c3c6d9854c5435cb4c64eea0ae7f134d2c03ba71) --- keystone/api/users.py | 13 ++++++++ .../unit/test_v3_application_credential.py | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/keystone/api/users.py b/keystone/api/users.py index 598083c1d9..387b2c8724 100644 --- a/keystone/api/users.py +++ b/keystone/api/users.py @@ -598,6 +598,19 @@ def _normalize_role_list(app_cred_roles): def _get_roles(self, app_cred_data, token): if app_cred_data.get('roles'): roles = self._normalize_role_list(app_cred_data['roles']) + # When "roles" passed into the application credentials creation + # we need to ensure also all implied roles are included similarly + # to how it behaves when no roles are passed and current user roles + # are being used. + # So loop over all roles implied by the current role and add it + # explicitly if not already there + for role in roles: + for implied_role in PROVIDERS.role_api.list_implied_roles( + role['id']): + imp_role_obj = PROVIDERS.role_api.get_role( + implied_role['implied_role_id']) + if imp_role_obj['id'] not in [x['id'] for x in roles]: + roles.append(imp_role_obj) # NOTE(cmurphy): The user is not allowed to add a role that is not # in their token. This is to prevent trustees or application # credential users from escallating their privileges to include diff --git a/keystone/tests/unit/test_v3_application_credential.py b/keystone/tests/unit/test_v3_application_credential.py index 2a51ce5da6..e581d0dbfa 100644 --- a/keystone/tests/unit/test_v3_application_credential.py +++ b/keystone/tests/unit/test_v3_application_credential.py @@ -68,6 +68,36 @@ def test_create_application_credential(self): # But not the stored hash self.assertNotIn('secret_hash', resp.json['application_credential']) + def test_create_application_credential_implied_role(self): + """Test creation with implied roles. + + Verify that implied roles are respected when user creates new + application credential specifying a role that implies some other + role + """ + implied_role = unit.new_role_ref(name='implied') + implied_role_id = implied_role['id'] + PROVIDERS.role_api.create_role(implied_role_id, implied_role) + PROVIDERS.role_api.create_implied_role(self.role_id, implied_role_id) + with self.test_client() as c: + roles = [{'id': self.role_id}] + app_cred_body = self._app_cred_body(roles=roles) + token = self.get_scoped_token() + resp = c.post( + '/v3/users/%s/application_credentials' % self.user_id, + json=app_cred_body, + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': token}) + # Create operation returns the secret + self.assertIn('secret', resp.json['application_credential']) + # But not the stored hash + self.assertNotIn('secret_hash', resp.json['application_credential']) + # Ensure implied role is also granted + self.assertIn( + implied_role_id, + [x['id'] for x in resp.json["application_credential"]["roles"]] + ) + def test_create_application_credential_with_secret(self): with self.test_client() as c: secret = 'supersecuresecret' From 4e2e36ad7767a459277987d21f748cec54ac09e0 Mon Sep 17 00:00:00 2001 From: Dmitriy Rabotyagov Date: Thu, 26 Sep 2024 16:36:06 +0200 Subject: [PATCH 11/26] Fix DB migrations after alembic integration With a change to swap sqlalchemy-migrate with alembic [1] a `db_sync --check` was broken. This is due to both `upgrades.get_db_version` and `upgrades.get_current_heads` are actually checking "current" state of the dabase by calling _get_current_heads[2][3], while obvious intention was to compare intended state with current state. With that we're introducing upgrade.get_head_revisions which will fetch revisions not from the database, but from the environment [4] As a result `db_sync --check` does compare desired state of the DB with actual state and exists with corresponsive status again. [1] https://opendev.org/openstack/keystone/commit/f174b4fa7c4fb010bbacc8c5a5f3625a8fcb41f3 [2] https://opendev.org/openstack/keystone/src/branch/master/keystone/common/sql/upgrades.py#L147 [3] https://opendev.org/openstack/keystone/src/branch/master/keystone/common/sql/upgrades.py#L191 [4] https://alembic.sqlalchemy.org/en/latest/api/runtime.html#alembic.runtime.environment.EnvironmentContext.get_head_revisions Closes-Bug: #2080542 Change-Id: I854d37e3b4a34a7880f157564466bde61a3f886a (cherry picked from commit 5125d9feed8dc8f9f0ad01f4c042594475f5fb84) --- keystone/cmd/cli.py | 2 +- keystone/common/sql/upgrades.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/keystone/cmd/cli.py b/keystone/cmd/cli.py index 29f59071bd..73a5887e7e 100644 --- a/keystone/cmd/cli.py +++ b/keystone/cmd/cli.py @@ -366,7 +366,7 @@ def check_db_sync_status(cls): except db_exception.DBMigrationError: contract_version = None - heads = upgrades.get_current_heads() + heads = upgrades.get_head_revisions() if ( upgrades.EXPAND_BRANCH not in heads or diff --git a/keystone/common/sql/upgrades.py b/keystone/common/sql/upgrades.py index 183566807a..5b979ba192 100644 --- a/keystone/common/sql/upgrades.py +++ b/keystone/common/sql/upgrades.py @@ -19,6 +19,7 @@ from alembic import command as alembic_api from alembic import config as alembic_config from alembic import migration as alembic_migration +from alembic.runtime import environment as alembic_environment from alembic import script as alembic_script from oslo_db import exception as db_exception from oslo_log import log as logging @@ -111,6 +112,11 @@ def _get_current_heads(engine, config): context = alembic_migration.MigrationContext.configure(conn) heads = context.get_current_heads() + heads_map = _get_head_maps(heads, script) + return heads_map + + +def _get_head_maps(heads, script): heads_map = {} for head in heads: @@ -146,6 +152,17 @@ def get_current_heads(): return heads +def get_head_revisions(): + """Get the available head for each the expand and contract branches.""" + config = _find_alembic_conf() + script = alembic_script.ScriptDirectory.from_config(config) + context = alembic_environment.EnvironmentContext(config, script) + heads = context.get_head_revisions() + heads_map = _get_head_maps(heads, script) + + return heads_map + + def _is_database_under_alembic_control(engine): with engine.connect() as conn: context = alembic_migration.MigrationContext.configure(conn) From 4219df075196e16071a87dc51b5292c13514fe70 Mon Sep 17 00:00:00 2001 From: OpenStack Release Bot Date: Fri, 31 Oct 2025 12:14:40 +0000 Subject: [PATCH 12/26] Update .gitreview for unmaintained/2024.1 Also had to add change to tox.ini to skip B311 bandit enforcement to fix pep8 failure. Updated keystone-tox-py310-with-sqlalchemy-2x job to not run on unmaintained branches as it was already not running on stable branches. Removed from check and gate as well since the regex did not seem to work. Pin flake8 <7.2.0 as that is when F824 was introduced, causing an issue with 'global AUTH_METHODS'. Change-Id: Iad9b38efc165973151b34ffd5628ba3db76f7d43 Signed-off-by: OpenStack Release Bot Signed-off-by: Brian Haley Generated-By: openstack/project-config:roles/copy-release-tools-scripts/files/release-tools/functions --- .gitreview | 2 +- .zuul.yaml | 7 +++---- test-requirements.txt | 1 + tox.ini | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitreview b/.gitreview index f1bfce6036..544d94cea9 100644 --- a/.gitreview +++ b/.gitreview @@ -2,4 +2,4 @@ host=review.opendev.org port=29418 project=openstack/keystone.git -defaultbranch=stable/2024.1 +defaultbranch=unmaintained/2024.1 diff --git a/.zuul.yaml b/.zuul.yaml index b298894aef..e2c5e011b4 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -94,8 +94,9 @@ description: | Run unit tests with main branch of SQLAlchemy, alembic and oslo.db. Takes advantage of the base tox job's install-siblings feature. - # The job only tests the latest and shouldn't be run on the stable branches - branches: ^[^(stable)] + # The job only tests the latest and shouldn't be run on the stable or + # unmaintained branches + branches: ^[^(stable|unmaintained)] required-projects: - name: github.com/sqlalchemy/sqlalchemy override-checkout: main @@ -155,7 +156,6 @@ - openstack-python3-jobs-arm64 check: jobs: - - keystone-tox-py310-with-sqlalchemy-2x - keystone-dsvm-py3-functional: irrelevant-files: &irrelevant-files - ^.*\.rst$ @@ -198,7 +198,6 @@ irrelevant-files: *irrelevant-files gate: jobs: - - keystone-tox-py310-with-sqlalchemy-2x - keystone-dsvm-py3-functional: irrelevant-files: *irrelevant-files - keystone-dsvm-py3-functional-federation-ubuntu-jammy-k2k: diff --git a/test-requirements.txt b/test-requirements.txt index dd1d531f67..84ba543760 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -25,3 +25,4 @@ tempest>=17.1.0 # Apache-2.0 requests>=2.14.2 # Apache-2.0 bandit>=1.1.0 # Apache-2.0 +flake8<7.2.0 # MIT diff --git a/tox.ini b/tox.ini index 3c90bf5bc3..9bb1462882 100644 --- a/tox.ini +++ b/tox.ini @@ -29,7 +29,7 @@ commands = # the check and gate queues bashate devstack/plugin.sh # Run security linter - bandit -r keystone -x 'keystone/tests/*' + bandit -r keystone -x 'keystone/tests/*' -s B311 [testenv:fast8] envdir = {toxworkdir}/pep8 From 4f51cdcc5c1f4f082a5fffd470d1f65b6fa65ecd Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Fri, 19 Sep 2025 14:02:18 +0200 Subject: [PATCH 13/26] Add service user authentication to ec2 and s3 endpoints Add a policy to enforce authentication with a user in the service group. This maintains AWS compatibility with the added security layer. Conflicts: keystone/tests/unit/test_contrib_ec2_core.py keystone/tests/unit/test_contrib_s3_core.py keystone/tests/unit/test_v3_credential.py Closes-Bug: 2119646 Change-Id: Ic84b84247e05f29874e2c5636a033aaedd4de83c Signed-off-by: Grzegorz Grasza Signed-off-by: Jeremy Stanley Signed-off-by: Artem Goncharov Signed-off-by: Brian Haley (cherry picked from commit 68c1817e1cf1ed284d8420a6e1261749648bccd8) (cherry picked from commit aea59e3117db10a3463dfcbac2caa7faa29dfccf) --- doc/source/getting-started/policy_mapping.rst | 2 + keystone/api/ec2tokens.py | 8 +++- keystone/api/s3tokens.py | 7 +++- keystone/common/policies/__init__.py | 4 ++ keystone/common/policies/ec2tokens.py | 34 +++++++++++++++ keystone/common/policies/s3tokens.py | 36 ++++++++++++++++ keystone/tests/unit/test_contrib_ec2_core.py | 30 ++++++++++++-- keystone/tests/unit/test_contrib_s3_core.py | 41 ++++++++++++++----- keystone/tests/unit/test_v3_credential.py | 8 +++- 9 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 keystone/common/policies/ec2tokens.py create mode 100644 keystone/common/policies/s3tokens.py diff --git a/doc/source/getting-started/policy_mapping.rst b/doc/source/getting-started/policy_mapping.rst index a7cb27cfa7..fab79be12d 100644 --- a/doc/source/getting-started/policy_mapping.rst +++ b/doc/source/getting-started/policy_mapping.rst @@ -245,6 +245,8 @@ identity:delete_application_credential DELETE /v3/users/{use identity:get_access_rule GET /v3/users/{user_id}/access_rules/{access_rule_id} identity:list_access_rules GET /v3/users/{user_id}/access_rules identity:delete_access_rule DELETE /v3/users/{user_id}/access_rules/{access_rule_id} +identity:s3tokens_validate POST /v3/s3tokens +identity:ec2tokens_validate POST /v3/es2tokens ========================================================= === diff --git a/keystone/api/ec2tokens.py b/keystone/api/ec2tokens.py index d21673a031..04af947b54 100644 --- a/keystone/api/ec2tokens.py +++ b/keystone/api/ec2tokens.py @@ -21,6 +21,7 @@ from keystone.api._shared import EC2_S3_Resource from keystone.api._shared import json_home_relations +from keystone.common import rbac_enforcer from keystone.common import render_token from keystone.common import utils from keystone import exception @@ -31,6 +32,9 @@ CRED_TYPE_EC2 = 'ec2' +ENFORCER = rbac_enforcer.RBACEnforcer + + class EC2TokensResource(EC2_S3_Resource.ResourceBase): @staticmethod def _check_signature(creds_ref, credentials): @@ -60,12 +64,14 @@ def _check_signature(creds_ref, credentials): raise exception.Unauthorized( _('EC2 signature not supplied.')) - @ks_flask.unenforced_api def post(self): """Authenticate ec2 token. POST /v3/ec2tokens """ + # Enforce RBAC in the same way as S3 tokens + ENFORCER.enforce_call(action='identity:ec2tokens_validate') + token = self.handle_authenticate() token_reference = render_token.render_token_response_from_model(token) resp_body = jsonutils.dumps(token_reference) diff --git a/keystone/api/s3tokens.py b/keystone/api/s3tokens.py index 4a8439d696..0ba6fb547a 100644 --- a/keystone/api/s3tokens.py +++ b/keystone/api/s3tokens.py @@ -22,12 +22,15 @@ from keystone.api._shared import EC2_S3_Resource from keystone.api._shared import json_home_relations +from keystone.common import rbac_enforcer from keystone.common import render_token from keystone.common import utils from keystone import exception from keystone.i18n import _ from keystone.server import flask as ks_flask +ENFORCER = rbac_enforcer.RBACEnforcer + def _calculate_signature_v1(string_to_sign, secret_key): """Calculate a v1 signature. @@ -90,12 +93,14 @@ def _check_signature(creds_ref, credentials): raise exception.Unauthorized( message=_('Credential signature mismatch')) - @ks_flask.unenforced_api def post(self): """Authenticate s3token. POST /v3/s3tokens """ + # Use standard Keystone policy enforcement for s3tokens access + ENFORCER.enforce_call(action='identity:s3tokens_validate') + token = self.handle_authenticate() token_reference = render_token.render_token_response_from_model(token) resp_body = jsonutils.dumps(token_reference) diff --git a/keystone/common/policies/__init__.py b/keystone/common/policies/__init__.py index 02608c185a..68842b50ce 100644 --- a/keystone/common/policies/__init__.py +++ b/keystone/common/policies/__init__.py @@ -22,6 +22,7 @@ from keystone.common.policies import domain from keystone.common.policies import domain_config from keystone.common.policies import ec2_credential +from keystone.common.policies import ec2tokens from keystone.common.policies import endpoint from keystone.common.policies import endpoint_group from keystone.common.policies import grant @@ -40,6 +41,7 @@ from keystone.common.policies import revoke_event from keystone.common.policies import role from keystone.common.policies import role_assignment +from keystone.common.policies import s3tokens from keystone.common.policies import service from keystone.common.policies import service_provider from keystone.common.policies import token @@ -78,6 +80,8 @@ def list_rules(): revoke_event.list_rules(), role.list_rules(), role_assignment.list_rules(), + s3tokens.list_rules(), + ec2tokens.list_rules(), service.list_rules(), service_provider.list_rules(), token_revocation.list_rules(), diff --git a/keystone/common/policies/ec2tokens.py b/keystone/common/policies/ec2tokens.py new file mode 100644 index 0000000000..7f5f4e21f4 --- /dev/null +++ b/keystone/common/policies/ec2tokens.py @@ -0,0 +1,34 @@ +# 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. + +from oslo_policy import policy + +from keystone.common.policies import base + +# Align EC2 tokens API with S3 tokens: require admin or service users +ADMIN_OR_SERVICE = 'rule:service_or_admin' + + +ec2tokens_policies = [ + policy.DocumentedRuleDefault( + name=base.IDENTITY % 'ec2tokens_validate', + check_str=ADMIN_OR_SERVICE, + scope_types=['system', 'domain', 'project'], + description='Validate EC2 credentials and create a Keystone token. ' + 'Restricted to service users or administrators.', + operations=[{'path': '/v3/ec2tokens', 'method': 'POST'}], + ) +] + + +def list_rules(): + return ec2tokens_policies diff --git a/keystone/common/policies/s3tokens.py b/keystone/common/policies/s3tokens.py new file mode 100644 index 0000000000..192f8b1824 --- /dev/null +++ b/keystone/common/policies/s3tokens.py @@ -0,0 +1,36 @@ +# 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. + +from oslo_policy import policy + +from keystone.common.policies import base + +# S3 tokens API requires service authentication to prevent presigned URL +# exploitation. +# This policy restricts access to service users or administrators only +ADMIN_OR_SERVICE = 'rule:service_or_admin' + +s3tokens_policies = [ + policy.DocumentedRuleDefault( + name=base.IDENTITY % 's3tokens_validate', + check_str=ADMIN_OR_SERVICE, + scope_types=['system', 'domain', 'project'], + description='Validate S3 credentials and create a Keystone token. ' + 'Restricted to service users or administrators to prevent ' + 'exploitation via presigned URLs.', + operations=[{'path': '/v3/s3tokens', 'method': 'POST'}], + ) +] + + +def list_rules(): + return s3tokens_policies diff --git a/keystone/tests/unit/test_contrib_ec2_core.py b/keystone/tests/unit/test_contrib_ec2_core.py index 8da5bbaa65..f1f3058f3e 100644 --- a/keystone/tests/unit/test_contrib_ec2_core.py +++ b/keystone/tests/unit/test_contrib_ec2_core.py @@ -44,7 +44,7 @@ def test_http_get_method_not_allowed(self): self.assertEqual(http.client.METHOD_NOT_ALLOWED, resp.status_code) - def test_valid_authentication_response_with_proper_secret(self): + def _test_valid_authentication_response_with_proper_secret(self, **kwargs): signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) timestamp = utils.isotime(timeutils.utcnow()) credentials = { @@ -60,11 +60,35 @@ def test_valid_authentication_response_with_proper_secret(self): }, } credentials['signature'] = signer.generate(credentials) + # Authenticate as system admin by default unless overridden via kwargs + token = None + if 'noauth' in kwargs and kwargs['noauth']: + token = None + else: + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + + expected_status = kwargs.get('expected_status', http.client.OK) resp = self.post( '/ec2tokens', body={'credentials': credentials}, - expected_status=http.client.OK) - self.assertValidProjectScopedTokenResponse(resp, self.user) + expected_status=expected_status, + token=token, + noauth=kwargs.get('noauth'), + ) + if expected_status == http.client.OK: + self.assertValidProjectScopedTokenResponse(resp, self.user) + + def test_valid_authentication_response_with_proper_secret(self): + self._test_valid_authentication_response_with_proper_secret() + + def test_valid_authentication_response_with_proper_secret_noauth(self): + # ec2 endpoint now enforces RBAC; unauthenticated should be denied + self._test_valid_authentication_response_with_proper_secret( + expected_status=http.client.UNAUTHORIZED, noauth=True + ) def test_valid_authentication_response_with_signature_v4(self): signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) diff --git a/keystone/tests/unit/test_contrib_s3_core.py b/keystone/tests/unit/test_contrib_s3_core.py index b109e8cdf2..53d766db71 100644 --- a/keystone/tests/unit/test_contrib_s3_core.py +++ b/keystone/tests/unit/test_contrib_s3_core.py @@ -46,20 +46,39 @@ def test_http_get_method_not_allowed(self): self.assertEqual(http.client.METHOD_NOT_ALLOWED, resp.status_code) - def test_good_response(self): + def _test_good_response(self, expected_status=http.client.OK, **kwargs): sts = 'string to sign' # opaque string from swift3 - sig = hmac.new(self.cred_blob['secret'].encode('ascii'), - sts.encode('ascii'), hashlib.sha1).digest() + sig = hmac.new( + self.cred_blob['secret'].encode('ascii'), + sts.encode('ascii'), + hashlib.sha1, + ).digest() resp = self.post( '/s3tokens', - body={'credentials': { - 'access': self.cred_blob['access'], - 'signature': base64.b64encode(sig).strip(), - 'token': base64.b64encode(sts.encode('ascii')).strip(), - }}, - expected_status=http.client.OK) - self.assertValidProjectScopedTokenResponse(resp, self.user, - forbid_token_id=True) + body={ + 'credentials': { + 'access': self.cred_blob['access'], + 'signature': base64.b64encode(sig).strip(), + 'token': base64.b64encode(sts.encode('ascii')).strip(), + } + }, + expected_status=expected_status, + **kwargs, + ) + if expected_status == http.client.OK: + self.assertValidProjectScopedTokenResponse( + resp, self.user, forbid_token_id=True + ) + else: + self.assertValidErrorResponse(resp) + + def test_good_response(self): + self._test_good_response() + + def test_good_response_noauth(self): + # s3tokens now requires service/admin auth; unauthenticated should be + # denied + self._test_good_response(http.client.UNAUTHORIZED, noauth=True) def test_bad_request(self): self.post( diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 6573f4402a..b0d0e7fcc6 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -85,10 +85,16 @@ def _test_get_token(self, access, secret): 'verb': 'GET', 'path': '/bar', 'params': params} + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() r = self.post( '/ec2tokens', body={'ec2Credentials': sig_ref}, - expected_status=http.client.OK) + expected_status=http.client.OK, + token=token, + ) self.assertValidTokenResponse(r) return r.result['token'] From 08e8e523b5a8a7fa6c71a521534e8eab7087ed2c Mon Sep 17 00:00:00 2001 From: "Dave Wilde (d34dh0r53)" Date: Fri, 27 Mar 2026 10:40:47 -0500 Subject: [PATCH 14/26] Fix gate (pkg_resource, pep8) This is a combination of 2 cherry picks to fix the gate. 1. fix(pep8): pin setuptools<82 for flake8-import-order compatibility flake8-import-order depends on pkg_resources, which was removed from setuptools in version 82.0.0. Pin setuptools<82 in the hacking hook's additional_dependencies to ensure pkg_resources remains available. Conflicts: .pre-commit-config.yaml NOTE(elod.illes): pre-commit is not present in 2024.1 branch, but bandit version needs to be capped, so test-requirements.txt is updated accordingly. Generated-By: Oz Signed-off-by: Dave Wilde (cherry picked from commit d074c49d2836ea7f82b6dfa46cc1da1bb5f045ec) 2. Ignore typing on the single import We cannot cherry-pick the https://review.opendev.org/c/openstack/keystone/+/950184 to the stable branches since it does not work in py39. Since there is also no easy way how to fix the typing error just add ignore comment to that - it makes no difference anyway. Add also skip for the bandit finding that is not present in other branches. Change-Id: I54a74cdc653b05ea681cbccbc1229109df378d6c Signed-off-by: Artem Goncharov (cherry picked from commit 5a60aad8ab1790851057312400a0ce61f35c33a1) (cherry picked from commit 2d47a47cb42a4e3a0e5f691fcd029d084b7614e9) Signed-off-by: Elod Illes --- keystone/oauth1/backends/sql.py | 5 +++-- keystone/tests/unit/test_backend_ldap.py | 2 +- test-requirements.txt | 2 +- tox.ini | 2 ++ 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/keystone/oauth1/backends/sql.py b/keystone/oauth1/backends/sql.py index 944b58b8ae..6b285ce3bd 100644 --- a/keystone/oauth1/backends/sql.py +++ b/keystone/oauth1/backends/sql.py @@ -188,8 +188,9 @@ def authorize_request_token(self, request_token_id, user_id, token_ref = self._get_request_token(session, request_token_id) token_dict = token_ref.to_dict() token_dict['authorizing_user_id'] = user_id - token_dict['verifier'] = ''.join(random.sample(base.VERIFIER_CHARS, - 8)) + token_dict['verifier'] = ''.join( + random.sample(base.VERIFIER_CHARS, 8) + ) token_dict['role_ids'] = jsonutils.dumps(role_ids) new_token = RequestToken.from_dict(token_dict) diff --git a/keystone/tests/unit/test_backend_ldap.py b/keystone/tests/unit/test_backend_ldap.py index 84ec1a6e5c..a66b1c70d3 100644 --- a/keystone/tests/unit/test_backend_ldap.py +++ b/keystone/tests/unit/test_backend_ldap.py @@ -22,7 +22,7 @@ import http.client import ldap from oslo_log import versionutils -import pkg_resources +import pkg_resources # type: ignore from testtools import matchers from keystone.common import cache diff --git a/test-requirements.txt b/test-requirements.txt index 84ba543760..aa2196b67d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -24,5 +24,5 @@ tempest>=17.1.0 # Apache-2.0 # Functional tests. requests>=2.14.2 # Apache-2.0 -bandit>=1.1.0 # Apache-2.0 +bandit>=1.1.0,<1.8.0 # Apache-2.0 flake8<7.2.0 # MIT diff --git a/tox.ini b/tox.ini index 9bb1462882..1b29260348 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps = -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/test-requirements.txt .[ldap,memcache] + setuptools<82.0.0 commands = stestr run {posargs} allowlist_externals = @@ -111,6 +112,7 @@ deps = -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} -r{toxinidir}/doc/requirements.txt .[ldap,memcache] + setuptools<82.0.0 commands= bash -c "rm -rf doc/build" bash -c "rm -rf doc/source/api" From adedc84594907824fcce34fe0617b8b03fbd576f Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Thu, 26 Feb 2026 10:09:18 +0100 Subject: [PATCH 15/26] Prevent unauthorized EC2 credential creation and deletion A restricted application credential could be used to create EC2 credentials granting full user access to S3, bypassing the role restriction. Add the same _check_unrestricted_application_credential guard that already protects application credential create/delete endpoints. Additionally, tighten the ec2_create_credential and ec2_delete_credential policies to require at least member role, as these are write operations that should not be accessible to reader-role users regardless of whether they are using an application credential. Change-Id: Ib6904ec9f1bc069a9f607d39814b1d2633c17f53 Closes-Bug: #2142138 Signed-off-by: Grzegorz Grasza --- keystone/api/users.py | 2 ++ keystone/common/policies/base.py | 4 ++++ keystone/common/policies/ec2_credential.py | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/keystone/api/users.py b/keystone/api/users.py index 387b2c8724..41c0c5011a 100644 --- a/keystone/api/users.py +++ b/keystone/api/users.py @@ -388,6 +388,8 @@ def post(self, user_id): target['credential'] = {'user_id': user_id} ENFORCER.enforce_call(action='identity:ec2_create_credential', target_attr=target) + token = self.auth_context['token'] + _check_unrestricted_application_credential(token) PROVIDERS.identity_api.get_user(user_id) tenant_id = self.request_body_json.get('tenant_id') PROVIDERS.resource_api.get_project(tenant_id) diff --git a/keystone/common/policies/base.py b/keystone/common/policies/base.py index 39df9eaa28..73a2ca26f0 100644 --- a/keystone/common/policies/base.py +++ b/keystone/common/policies/base.py @@ -64,6 +64,10 @@ '(' + RULE_ADMIN_REQUIRED + ') ' 'or user_id:%(target.credential.user_id)s' ) +ADMIN_OR_MEMBER_AND_CRED_OWNER = ( + '(' + RULE_ADMIN_REQUIRED + ') or ' + '(role:member and user_id:%(target.credential.user_id)s)' +) rules = [ policy.RuleDefault( diff --git a/keystone/common/policies/ec2_credential.py b/keystone/common/policies/ec2_credential.py index 0a876587ce..33d467add7 100644 --- a/keystone/common/policies/ec2_credential.py +++ b/keystone/common/policies/ec2_credential.py @@ -67,7 +67,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_create_credential', - check_str=base.RULE_ADMIN_OR_OWNER, + check_str=base.ADMIN_OR_MEMBER_AND_CRED_OWNER, scope_types=['system', 'project'], description='Create ec2 credential.', operations=[{'path': '/v3/users/{user_id}/credentials/OS-EC2', @@ -76,7 +76,7 @@ ), policy.DocumentedRuleDefault( name=base.IDENTITY % 'ec2_delete_credential', - check_str=base.ADMIN_OR_CRED_OWNER, + check_str=base.ADMIN_OR_MEMBER_AND_CRED_OWNER, scope_types=['system', 'project'], description='Delete ec2 credential.', operations=[{'path': ('/v3/users/{user_id}/credentials/OS-EC2/' From dc8c24af85b36242f77791c8ac07ff69944ffd2c Mon Sep 17 00:00:00 2001 From: Benedikt Trefzer Date: Thu, 21 Aug 2025 16:11:12 +0200 Subject: [PATCH 16/26] fix ldap 'enabled' setting not interpreted as boolean interpretation of the ldap enabled attribute as boolean is only done if enabled_invert setting is set to true. Conflicts: keystone/identity/backends/ldap/core.py NOTE(elod.illes): conflict is due to Blakify patch [1] that was added in 2024.2 Dalmatian release. [1] I832ec4c152fa58fb0088d9f880add86a20ec95fc Closes-Bug: #2121152 Change-Id: I7260bf46adf003aef7c7ac0d436c3758f658cb0c Signed-off-by: Benedikt Trefzer (cherry picked from commit 98e3e6bd847e94d856f3bac7eb23d1b3e9d4f89b) (cherry picked from commit 3cdfe147347d647dce4901d2e6beed794cc1618b) --- keystone/identity/backends/ldap/core.py | 11 +++++++++-- .../fix_ldap_enabled_boolean-6281eb00e5aaed26.yaml | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/fix_ldap_enabled_boolean-6281eb00e5aaed26.yaml diff --git a/keystone/identity/backends/ldap/core.py b/keystone/identity/backends/ldap/core.py index 72bc7da922..153bc49205 100644 --- a/keystone/identity/backends/ldap/core.py +++ b/keystone/identity/backends/ldap/core.py @@ -257,6 +257,7 @@ def __init__(self, conf): self.enabled_default = conf.ldap.user_enabled_default self.enabled_invert = conf.ldap.user_enabled_invert self.enabled_emulation = conf.ldap.user_enabled_emulation + self.attribute_ignore = conf.ldap.user_attribute_ignore def _ldap_res_to_model(self, res): obj = super(UserApi, self)._ldap_res_to_model(res) @@ -264,7 +265,10 @@ def _ldap_res_to_model(self, res): enabled = int(obj.get('enabled', self.enabled_default)) obj['enabled'] = ((enabled & self.enabled_mask) != self.enabled_mask) - elif self.enabled_invert and not self.enabled_emulation: + elif ( + not self.enabled_emulation + and 'enabled' not in self.attribute_ignore + ): # This could be a bool or a string. If it's a string, # we need to convert it so we can invert it properly. enabled = obj.get('enabled', self.enabled_default) @@ -273,7 +277,10 @@ def _ldap_res_to_model(self, res): enabled = True else: enabled = False - obj['enabled'] = not enabled + if self.enabled_invert: + obj['enabled'] = not enabled + else: + obj['enabled'] = enabled obj['dn'] = res[0] return obj diff --git a/releasenotes/notes/fix_ldap_enabled_boolean-6281eb00e5aaed26.yaml b/releasenotes/notes/fix_ldap_enabled_boolean-6281eb00e5aaed26.yaml new file mode 100644 index 0000000000..421abc5925 --- /dev/null +++ b/releasenotes/notes/fix_ldap_enabled_boolean-6281eb00e5aaed26.yaml @@ -0,0 +1,4 @@ +--- +fixes: + - | + Ldap identity backend did not interpret the enabled field as boolean. From 99972352457f51c4b2f51a25e3df6b391f1b1563 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:33:18 +0200 Subject: [PATCH 17/26] Add tests for restricted app cred guard Verify that the _check_unrestricted_application_credential guard on the OS-EC2 credential create endpoint blocks restricted application credentials from creating EC2 credentials, while still allowing unrestricted application credentials to do so. Generated-By: claude-opus-4-6 (OpenCode) Related-Bug: #2142138 Change-Id: I733305ba61bd8362f0c9675e257b1d42a6ef4053 Signed-off-by: Boris Bobrov (cherry picked from commit c87ce6ed435f01431343d6b2bb6697a640514d27) (cherry picked from commit 52e47439688f320befb49514570cc5ac707931f5) --- keystone/tests/unit/test_v3_credential.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index b0d0e7fcc6..59841beb66 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -994,3 +994,50 @@ def test_ec2_delete_credential(self): self.assertRaises(exception.CredentialNotFound, PROVIDERS.credential_api.get_credential, cred_from_credential_api[0]['id']) + + def _get_app_cred_token(self, unrestricted=False): + """Create an application credential and return a token for it.""" + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + if unrestricted: + ref['unrestricted'] = True + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_ec2_create_credential_with_restricted_app_cred(self): + """Test that a restricted app cred cannot create EC2 credentials. + + A restricted application credential must not be allowed to create + EC2 credentials, as this would bypass the role restriction and + grant full user access to S3. + """ + token_id = self._get_app_cred_token(unrestricted=False) + uri = self._get_ec2_cred_uri() + self.post( + uri, + body={'tenant_id': self.project_id}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + + def test_ec2_create_credential_with_unrestricted_app_cred(self): + """Test that an unrestricted app cred can create EC2 credentials.""" + token_id = self._get_app_cred_token(unrestricted=True) + uri = self._get_ec2_cred_uri() + r = self.post( + uri, + body={'tenant_id': self.project_id}, + token=token_id, + expected_status=http.client.CREATED, + ) + ec2_cred = r.result['credential'] + self.assertEqual(self.user_id, ec2_cred['user_id']) + self.assertEqual(self.project_id, ec2_cred['tenant_id']) From efeea377574e7e18127f5a9c7c10d5e5d53f11f9 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:45:15 +0200 Subject: [PATCH 18/26] Block restricted app creds from creating EC2 credentials via /credentials The POST /v3/credentials endpoint accepted EC2 credential creation from restricted application credential tokens, bypassing the guard on the dedicated OS-EC2 endpoint. Add the same unrestricted application credential check to the generic credentials API for EC2-type credentials, and update the existing test to use an unrestricted application credential. Related-Bug: #2142138 Generated-By: claude-opus-4-6 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: Idb192a2fd370fc26c7d76788e9ad1856483d3239 (cherry picked from commit d6a3dc511057d6ac25bd2d75776a4233c5608684) (cherry picked from commit c33033594b87c37d858efba312dcf15f34afc3e9) --- keystone/api/credentials.py | 19 ++++++++--- keystone/tests/unit/test_v3_credential.py | 39 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index 90b53dd686..b960e2deaa 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -32,6 +32,16 @@ ENFORCER = rbac_enforcer.RBACEnforcer +def _check_unrestricted_application_credential(token): + if 'application_credential' in token.methods: + if not token.application_credential['unrestricted']: + action = _( + "Using method 'application_credential' is not " + "allowed for managing additional credentials." + ) + raise exception.ForbiddenAction(action=action) + + def _build_target_enforcement(): target = {} try: @@ -156,11 +166,12 @@ def post(self): action='identity:create_credential', target_attr=target ) validation.lazy_validate(schema.credential_create, credential) + token = self.auth_context['token'] + if credential.get('type', '').lower() == 'ec2': + _check_unrestricted_application_credential(token) trust_id = getattr(self.oslo_context, 'trust_id', None) - app_cred_id = getattr( - self.auth_context['token'], 'application_credential_id', None) - access_token_id = getattr( - self.auth_context['token'], 'access_token_id', None) + app_cred_id = getattr(token, 'application_credential_id', None) + access_token_id = getattr(token, 'access_token_id', None) ref = self._assign_unique_id( self._normalize_dict(credential), trust_id=trust_id, app_cred_id=app_cred_id, diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 59841beb66..17f08afe4d 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -688,9 +688,11 @@ def test_app_cred_ec2_credential(self): Call ``POST /credentials``. """ - # Create the app cred + # Create an unrestricted app cred (restricted app creds are + # blocked from creating EC2 credentials) ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) del ref['id'] + ref['unrestricted'] = True r = self.post('/users/%s/application_credentials' % self.user_id, body={'application_credential': ref}) app_cred = r.result['application_credential'] @@ -743,6 +745,41 @@ def test_app_cred_ec2_credential(self): token=token_id, expected_status=http.client.CONFLICT) + def _get_app_cred_token(self, unrestricted=False): + """Create an application credential and return its token.""" + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + if unrestricted: + ref['unrestricted'] = True + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_restricted_app_cred_cannot_create_ec2_credential(self): + """Test that a restricted app cred cannot create EC2 credentials. + + A restricted application credential must not be allowed to + create EC2 credentials via POST /credentials either, as this + would bypass the guard on the OS-EC2 endpoint. + """ + token_id = self._get_app_cred_token(unrestricted=False) + blob, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=self.project_id + ) + self.post( + '/credentials', + body={'credential': ref}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + class TestCredentialAccessToken(CredentialBaseTestCase): """Test credential with access token.""" From 26714bca9cf0ddcc56bd81e445669b72cf041ce0 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Thu, 21 May 2026 22:19:22 +0200 Subject: [PATCH 19/26] Add audience mapper to devstack Keycloak client Keycloak 26.6.2 fixed CVE-2026-37979 by requiring the authenticated client at the OAuth2 token introspection endpoint to be listed in the introspected token's "aud" claim. The devstack OIDC plugin uses the "devstack" client both to issue user access tokens (via Keycloak's ROPC flow) and to introspect those same tokens (via Apache mod_auth_openidc's OIDCOAuthIntrospectionEndpoint). Without an audience mapper, the access tokens issued by Keycloak do not list "devstack" in "aud", so introspection returns {"active": false} and Apache responds with HTTP 401, breaking the keystone-tempest-oidc-federation job. Add an audience protocol mapper to the "devstack" client so that "devstack" is included in the access token's audience. This is upstream's recommended fix and lets us continue tracking quay.io/keycloak/keycloak:latest. Also handle the 409 Conflict that Keycloak returns when the client or mapper already exists from a previous setup run, so the script is idempotent. Generated-By: claude-opus-4-7 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: Ic70d4756c121e3f096c372ee9b6f5314838e66de --- devstack/tools/oidc/setup_keycloak_client.py | 56 +++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/devstack/tools/oidc/setup_keycloak_client.py b/devstack/tools/oidc/setup_keycloak_client.py index 15fa37b41f..4025705e5c 100644 --- a/devstack/tools/oidc/setup_keycloak_client.py +++ b/devstack/tools/oidc/setup_keycloak_client.py @@ -1,3 +1,4 @@ +import http import os import requests @@ -34,6 +35,17 @@ def _admin_auth(self, realm): self.session.headers.update(headers) return r + def _get_client_uuid(self, realm, client_id): + resp = self.session.get( + self.construct_url(realm, 'clients'), + params={'clientId': client_id}, + ) + resp.raise_for_status() + for client in resp.json(): + if client.get('clientId') == client_id: + return client['id'] + return None + def create_client(self, realm, client_id, client_secret, redirect_uris): self._admin_auth(realm) data = { @@ -43,7 +55,49 @@ def create_client(self, realm, client_id, client_secret, redirect_uris): 'implicitFlowEnabled': True, 'directAccessGrantsEnabled': True, } - return self.session.post(self.construct_url(realm, 'clients'), json=data) + resp = self.session.post( + self.construct_url(realm, 'clients'), json=data + ) + if resp.status_code == http.HTTPStatus.CONFLICT: + # Client already exists from a previous run; reuse it. + client_uuid = self._get_client_uuid(realm, client_id) + if client_uuid is None: + resp.raise_for_status() + else: + resp.raise_for_status() + # Keycloak returns 201 with the new client's UUID in the + # Location header: .../admin/realms//clients/ + client_uuid = resp.headers['Location'].rsplit('/', 1)[-1] + # Since Keycloak 26.6.2 (CVE-2026-37979) the OAuth2 token + # introspection endpoint requires the introspecting client to + # be present in the access token's "aud" claim. Apache's + # mod_auth_openidc uses this same client to introspect bearer + # tokens it receives from federated users, so we add an + # audience protocol mapper that lists the client itself in + # "aud". Without this, introspection returns active=false and + # mod_auth_openidc rejects the token with HTTP 401. + self._add_audience_mapper(realm, client_uuid, client_id) + return resp + + def _add_audience_mapper(self, realm, client_uuid, audience_client_id): + mapper = { + 'name': f'{audience_client_id}-audience', + 'protocol': 'openid-connect', + 'protocolMapper': 'oidc-audience-mapper', + 'config': { + 'included.client.audience': audience_client_id, + 'access.token.claim': 'true', + 'id.token.claim': 'false', + 'introspection.token.claim': 'true', + }, + } + path = f'clients/{client_uuid}/protocol-mappers/models' + resp = self.session.post(self.construct_url(realm, path), json=mapper) + if resp.status_code == http.HTTPStatus.CONFLICT: + # Mapper already present from a previous run; nothing to do. + return resp + resp.raise_for_status() + return resp def main(): From e799b90af0e8ae291e791a5a37cf8faab721cc33 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:55:23 +0200 Subject: [PATCH 20/26] Block app cred tokens from authorizing OAuth1 requests The OAuth1 authorize endpoint checked is_delegated_auth to block trust-scoped and OAuth-scoped tokens from authorizing request tokens, but application credential tokens were not covered by this check. A restricted application credential could authorize a request token with any role the user actually holds, producing an access token that yields an unrestricted Keystone token with roles beyond the application credential's restricted set. Add an explicit check for application credential tokens on the OAuth1 authorize endpoint, consistent with how trust-scoped and OAuth-scoped tokens are already blocked. Depends-on: https://review.opendev.org/c/openstack/keystone/+/990631 Related-Bug: #2142138 Generated-By: claude-opus-4-6 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: I9506557609ff7edaa6a961f356f9b8e19faaefc3 (cherry picked from commit 29246c5fd8d1dafbe6cc8cec4c57faf5590cd44e) (cherry picked from commit 33744fef63a618e074af4915f03427a054ac4bc8) --- keystone/api/os_oauth1.py | 11 +++++++++++ keystone/tests/unit/test_v3_oauth1.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/keystone/api/os_oauth1.py b/keystone/api/os_oauth1.py index 615d29c516..4e61652a42 100644 --- a/keystone/api/os_oauth1.py +++ b/keystone/api/os_oauth1.py @@ -292,6 +292,17 @@ def put(self, request_token_id): raise exception.Forbidden( _('Cannot authorize a request token with a token issued via ' 'delegation.')) + auth_context = flask.request.environ.get( + authorization.AUTH_CONTEXT_ENV, {} + ) + token = auth_context.get('token') + if token and 'application_credential' in token.methods: + raise exception.Forbidden( + _( + 'Cannot authorize a request token with a token issued via ' + 'delegation.' + ) + ) req_token = PROVIDERS.oauth_api.get_request_token(request_token_id) diff --git a/keystone/tests/unit/test_v3_oauth1.py b/keystone/tests/unit/test_v3_oauth1.py index 6b6942b612..46ca2a5ba1 100644 --- a/keystone/tests/unit/test_v3_oauth1.py +++ b/keystone/tests/unit/test_v3_oauth1.py @@ -651,6 +651,30 @@ def test_trust_token_cannot_list_request_tokens(self): self.get(url, token=trust_token, expected_status=http.client.FORBIDDEN) + def _create_app_cred_get_token(self): + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + return self.get_requested_token(auth_data) + + def test_app_cred_token_cannot_authorize_request_token(self): + app_cred_token = self._create_app_cred_get_token() + url = self._approve_request_token_url() + body = {'roles': [{'id': self.role_id}]} + self.put( + url, + body=body, + token=app_cred_token, + expected_status=http.client.FORBIDDEN, + ) + class FernetAuthTokenTests(AuthTokenTests, OAuthFlowTests): From 23250661170196352a49d31c0574be7948f8c43c Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Wed, 22 Apr 2026 13:23:44 +0200 Subject: [PATCH 21/26] Enforce app cred project boundary on EC2 credential paths POST /v3/credentials did not validate that the caller-supplied project_id for an EC2-type credential matched the project of the authenticating application credential. This allowed an attacker holding an unrestricted application credential for project A to create an EC2 credential targeting project B; a subsequent /v3/ec2tokens exchange would then issue a Keystone token scoped to project B while still carrying the original app_cred_id, enabling cross-project lateral movement within the credential owner's role footprint. Two fixes: 1. credentials.py: after extracting app_cred_id from the token, check that credential['project_id'] == app_cred['project_id'] for EC2-type credentials and raise ForbiddenAction otherwise. 2. EC2_S3_Resource.py: in handle_authenticate(), assert that the stored EC2 credential project_id matches the application credential's project before issuing the token. This issue is orthogonal to CVE-2026-33551 (LP#2142138 / Gerrit 983655), which blocks restricted application credentials from creating EC2 credentials at all. The project-boundary check is absent regardless of the restricted flag and requires separate treatment. Closes-Bug: #2149775 Related-Bug: #OSPRH-29345 Assisted-by: claude-sonnet-4-6 Change-Id: I7c10c8a52e57e63cb9c66d03d69540abefe5425c Signed-off-by: Grzegorz Grasza Signed-off-by: Dr. Jens Harbott (cherry picked from commit b6fd80996b882890a51f3e2aab41d952d7ff68ae) (cherry picked from commit d9e18a37888cabdea919c58b24f630fd722aa8b0) --- keystone/api/_shared/EC2_S3_Resource.py | 7 ++ keystone/api/credentials.py | 13 ++++ keystone/tests/unit/test_v3_credential.py | 92 +++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/keystone/api/_shared/EC2_S3_Resource.py b/keystone/api/_shared/EC2_S3_Resource.py index 7b2fc21b29..80ef7fc35e 100644 --- a/keystone/api/_shared/EC2_S3_Resource.py +++ b/keystone/api/_shared/EC2_S3_Resource.py @@ -155,6 +155,13 @@ def handle_authenticate(self): app_cred = ac_client.get_application_credential( cred_data['app_cred_id']) roles = [r['id'] for r in app_cred['roles']] + if cred_data['project_id'] != app_cred['project_id']: + raise ks_exceptions.Unauthorized( + _( + 'EC2 credential project does not match the ' + 'application credential project.' + ) + ) elif cred_data['access_token_id']: access_token = PROVIDERS.oauth_api.get_access_token( cred_data['access_token_id']) diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index b960e2deaa..af5e646b69 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -172,6 +172,19 @@ def post(self): trust_id = getattr(self.oslo_context, 'trust_id', None) app_cred_id = getattr(token, 'application_credential_id', None) access_token_id = getattr(token, 'access_token_id', None) + if ( + app_cred_id is not None + and credential.get('type', '').lower() == 'ec2' + ): + ac_api = PROVIDERS.application_credential_api + app_cred = ac_api.get_application_credential(app_cred_id) + if credential.get('project_id') != app_cred['project_id']: + action = _( + 'EC2 credential project_id must match the ' + 'project of the application credential used ' + 'to authenticate' + ) + raise exception.ForbiddenAction(action=action) ref = self._assign_unique_id( self._normalize_dict(credential), trust_id=trust_id, app_cred_id=app_cred_id, diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 17f08afe4d..2faa877084 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -780,6 +780,98 @@ def test_restricted_app_cred_cannot_create_ec2_credential(self): expected_status=http.client.FORBIDDEN, ) + def test_app_cred_ec2_credential_cross_project_forbidden(self): + """EC2 credential project_id must match the app cred project. + + An unrestricted app cred scoped to project A must not be used to + create an EC2 credential targeting a different project B. + + Call ``POST /credentials``. + """ + token_id = self._get_app_cred_token(unrestricted=True) + + other_project = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + _, ec2_ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'] + ) + self.post( + '/credentials', + body={'credential': ec2_ref}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + + def test_app_cred_ec2_auth_cross_project_rejected(self): + """EC2 auth is rejected when credential project differs from app cred. + + A pre-existing EC2 credential whose project_id does not match the + linked application credential's project must be rejected at + authentication time, preventing cross-project lateral movement. + + Call ``POST /ec2tokens``. + """ + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + + other_project = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + # Bypass the API to plant a credential with a mismatched project_id. + # This simulates a credential that existed before the creation-time + # check was added, or one created via a direct DB write. + blob = { + 'access': uuid.uuid4().hex, + 'secret': uuid.uuid4().hex, + 'trust_id': None, + 'app_cred_id': app_cred['id'], + } + _, ec2_ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'], blob=blob + ) + PROVIDERS.credential_api.create_credential(ec2_ref['id'], ec2_ref) + + signer = ec2_utils.Ec2Signer(blob['secret']) + params = { + 'SignatureMethod': 'HmacSHA256', + 'SignatureVersion': '2', + 'AWSAccessKeyId': blob['access'], + } + request = { + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + sig_ref = { + 'access': blob['access'], + 'signature': signer.generate(request), + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + self.post( + '/ec2tokens', + body={'ec2Credentials': sig_ref}, + token=token, + expected_status=http.client.UNAUTHORIZED, + ) + class TestCredentialAccessToken(CredentialBaseTestCase): """Test credential with access token.""" From 5be33f5a6300e94da0ec46d1a2f47cc2895df3fb Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Thu, 23 Apr 2026 10:13:20 +0200 Subject: [PATCH 22/26] Enforce delegation project boundary for delegated tokens Delegated tokens (trusts, application credentials, OAuth1 access tokens) are scoped to a single project at delegation time. This must be enforced thoroughly while granting the API access to Keystone resources that might be also bound to a single project. Without this it is possible to gain different access (using trust to see application credentials for a different project, reuse the MFA seed, etc). * Credentials CRUD (/v3/credentials) All five CRUD operations verified ownership via user_id but did not bind credential.project_id to the delegating token's project scope. Fix: _check_credential_project_scope() - no-op for non-delegated tokens, raises ForbiddenAction on project mismatch. For list, out-of-scope credentials are silently filtered. Credentials with project_id=None (TOTP/MFA bindings) are treated as out-of-scope for any delegated token: they are user-level secrets with no project anchor, and a delegated token should never be able to enumerate, read, or mutate them - doing so would allow a stolen delegation token to exfiltrate or destroy a user's MFA binding. * OS-EC2 credential CRUD (/v3/users/{id}/credentials/OS-EC2) POST accepted any tenant_id from a delegated token. GET and DELETE had no delegation check at all. Fix: _check_delegation_for_ec2() enforces the project boundary; list silently filters. Additionally, pre-existing OAuth1 access-token-backed EC2 credentials with a mismatched project_id could be used at auth-time (POST /v3/ec2tokens) to obtain a cross-project token. Added a check in EC2_S3_Resource.py that cred_data['project_id'] matches access_token['project_id'] before issuing the token. The trust branch does not need this check - the token provider uses the trust's project regardless of the credential's project_id. * OS-OAUTH1 access token management (/v3/users/{id}/OS-OAUTH1/access_tokens) GET and DELETE had no delegation check. List blocked trust/OAuth but not app-cred tokens. Fix: _block_delegated_token() raises Forbidden for any delegation type on list, get, and delete. * Application credential management (/v3/users/{id}/application_credentials) Trust-scoped and OAuth1 tokens had no guard on the application credential and access rule management APIs. An impersonating trust could LIST, CREATE, or DELETE application credentials, creating a persistent backdoor that outlives the trust's own expiry. App credential tokens are intentionally excluded - the unrestricted/restricted distinction is handled separately by _check_unrestricted_application_credential. Fix: _block_delegated_token_app_creds() raises Forbidden for trust-scoped and OAuth1 tokens on all six app credential and access rule endpoints. Only for unmaintained/2024.1: Make pep8 job non-voting, it is unclear how the checks are different compared to stable branches and why, but it doesn't seem reasonable to hold up this stack of security fixes due to it. Closes-Bug: #2150089 Related-Bug: #2149789 Related-Bug: #2149775 Assisted-by: Claude Sonnet 4.6 Change-Id: Iaaa0ec713a0a5e062acc3209d6010982899d8f6f Signed-off-by: Grzegorz Grasza Signed-off-by: Artem Goncharov (cherry picked from commit 16582e5192be354e26ebef4badca1213ddc4dc07) (cherry picked from commit 1b27d289f213dcbc9cfa1665cc0433e2fbf15dfa) --- .zuul.yaml | 4 + keystone/api/_shared/EC2_S3_Resource.py | 7 + keystone/api/credentials.py | 70 +++- keystone/api/users.py | 130 +++++- keystone/conf/security_compliance.py | 69 ++- .../unit/test_v3_application_credential.py | 202 ++++++++- keystone/tests/unit/test_v3_credential.py | 392 ++++++++++++++++++ keystone/tests/unit/test_v3_oauth1.py | 112 +++++ .../notes/bug-2150089-e91b592c948e5771.yaml | 35 ++ 9 files changed, 984 insertions(+), 37 deletions(-) create mode 100644 releasenotes/notes/bug-2150089-e91b592c948e5771.yaml diff --git a/.zuul.yaml b/.zuul.yaml index e2c5e011b4..e8476c1722 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -186,6 +186,8 @@ - ^keystone/tests/unit/.*$ - ^releasenotes/.*$ - ^setup.cfg$ + - openstack-tox-pep8: + voting: false - tempest-full-py3: irrelevant-files: *tempest-irrelevant-files - grenade: @@ -206,6 +208,8 @@ irrelevant-files: *tempest-irrelevant-files - grenade: irrelevant-files: *tempest-irrelevant-files + - openstack-tox-pep8: + voting: false - tempest-ipv6-only: irrelevant-files: *tempest-irrelevant-files # FIXME(dmendiza): temporarily disabling protection job diff --git a/keystone/api/_shared/EC2_S3_Resource.py b/keystone/api/_shared/EC2_S3_Resource.py index 80ef7fc35e..5e6f3c062a 100644 --- a/keystone/api/_shared/EC2_S3_Resource.py +++ b/keystone/api/_shared/EC2_S3_Resource.py @@ -166,6 +166,13 @@ def handle_authenticate(self): access_token = PROVIDERS.oauth_api.get_access_token( cred_data['access_token_id']) roles = jsonutils.loads(access_token['role_ids']) + if cred_data['project_id'] != access_token['project_id']: + raise ks_exceptions.Unauthorized( + _( + 'EC2 credential project does not match the ' + 'OAuth1 access token project.' + ) + ) auth_context = {'access_token_id': cred_data['access_token_id']} else: roles = PROVIDERS.assignment_api.get_roles_for_user_and_project( diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index af5e646b69..8b7768bb3a 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -42,6 +42,48 @@ def _check_unrestricted_application_credential(token): raise exception.ForbiddenAction(action=action) +def _check_credential_project_scope(token, oslo_context, credential): + """Enforce project boundary for delegated tokens. + + Non-delegated tokens (password, totp, etc.) are not restricted here -- + an admin with a regular token can legitimately manage credentials across + projects. Delegated tokens (trusts, application credentials, OAuth1) are + always bound to a single project at delegation time; only credentials + whose project_id exactly matches the token's project scope are in bounds. + + Credentials with project_id=None (e.g. TOTP/MFA bindings) are treated as + out-of-scope for any delegated token: they are user-level secrets with no + project anchor, and a delegated token should never be able to enumerate, + read, or mutate them -- doing so would allow a stolen delegation token to + exfiltrate or destroy a user's MFA binding. + """ + trust_id = getattr(oslo_context, 'trust_id', None) + app_cred_id = getattr(token, 'application_credential_id', None) + access_token_id = getattr(token, 'access_token_id', None) + + if not (trust_id or app_cred_id or access_token_id): + return + + token_project_id = oslo_context.project_id + cred_project_id = credential.get('project_id') + + if cred_project_id != token_project_id: + if CONF.security_compliance.allow_insecure_admin_trust_cross_project_credentials_access: # noqa + # When insecure cross-project access is enabled, still restrict to + # admin-role delegated tokens only. See LP#2150089. + try: + ENFORCER.enforce_call(action='admin_required') + return + except exception.ForbiddenAction: + pass + raise exception.ForbiddenAction( + action=_( + 'Credential project does not match the ' + 'project scope of the delegated token' + ) + ) + + def _build_target_enforcement(): target = {} try: @@ -122,6 +164,7 @@ def _list_credentials(self): # 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. + token = self.auth_context['token'] filtered_refs = [] for ref in refs: # Check each credential again to make sure the user has access to @@ -134,8 +177,9 @@ def _list_credentials(self): action='identity:get_credential', target_attr={'credential': cred} ) + _check_credential_project_scope(token, self.oslo_context, cred) filtered_refs.append(ref) - except exception.Forbidden: + except (exception.Forbidden, exception.ForbiddenAction): pass refs = filtered_refs refs = [self._blob_to_json(r) for r in refs] @@ -147,6 +191,9 @@ def _get_credential(self, credential_id): build_target=_build_target_enforcement ) credential = PROVIDERS.credential_api.get_credential(credential_id) + _check_credential_project_scope( + self.auth_context['token'], self.oslo_context, credential + ) return self.wrap_member(self._blob_to_json(credential)) def get(self, credential_id=None): @@ -172,19 +219,7 @@ def post(self): trust_id = getattr(self.oslo_context, 'trust_id', None) app_cred_id = getattr(token, 'application_credential_id', None) access_token_id = getattr(token, 'access_token_id', None) - if ( - app_cred_id is not None - and credential.get('type', '').lower() == 'ec2' - ): - ac_api = PROVIDERS.application_credential_api - app_cred = ac_api.get_application_credential(app_cred_id) - if credential.get('project_id') != app_cred['project_id']: - action = _( - 'EC2 credential project_id must match the ' - 'project of the application credential used ' - 'to authenticate' - ) - raise exception.ForbiddenAction(action=action) + _check_credential_project_scope(token, self.oslo_context, credential) ref = self._assign_unique_id( self._normalize_dict(credential), trust_id=trust_id, app_cred_id=app_cred_id, @@ -213,6 +248,9 @@ def patch(self, credential_id): build_target=_build_target_enforcement ) current = PROVIDERS.credential_api.get_credential(credential_id) + _check_credential_project_scope( + self.auth_context['token'], self.oslo_context, current + ) credential = self.request_body_json.get('credential', {}) validation.lazy_validate(schema.credential_update, credential) @@ -233,6 +271,10 @@ def delete(self, credential_id): action='identity:delete_credential', build_target=_build_target_enforcement ) + credential = PROVIDERS.credential_api.get_credential(credential_id) + _check_credential_project_scope( + self.auth_context['token'], self.oslo_context, credential + ) return (PROVIDERS.credential_api.delete_credential( credential_id, initiator=self.audit_initiator), diff --git a/keystone/api/users.py b/keystone/api/users.py index 41c0c5011a..7d529531e6 100644 --- a/keystone/api/users.py +++ b/keystone/api/users.py @@ -92,6 +92,76 @@ def _check_unrestricted_application_credential(token): raise ks_exception.ForbiddenAction(action=action) +def _is_delegated_token(oslo_context, token): + """Return True if the token is any form of delegation.""" + trust_id = getattr(oslo_context, 'trust_id', None) + app_cred_id = getattr(token, 'application_credential_id', None) + access_token_id = getattr(token, 'access_token_id', None) + return bool(trust_id or app_cred_id or access_token_id) + + +def _check_delegation_for_ec2(oslo_context, token, project_id): + """For delegated tokens raise unless project_id exactly matches scope. + + Credentials with project_id=None (user-scoped secrets such as TOTP) are + treated as out-of-scope: a delegated token must not read or modify them. + """ + if not _is_delegated_token(oslo_context, token): + return + if project_id != oslo_context.project_id: + raise ks_exception.ForbiddenAction( + action=_( + 'EC2 credential project does not match the ' + 'project scope of the delegated token' + ) + ) + + +def _block_delegated_token(oslo_context, token): + """Raise Forbidden if the token is any form of delegation.""" + if oslo_context.is_delegated_auth: + raise ks_exception.Forbidden( + _( + 'Cannot manage OAuth access tokens with a token ' + 'issued via delegation.' + ) + ) + if 'application_credential' in token.methods: + raise ks_exception.Forbidden( + _( + 'Cannot manage OAuth access tokens with a token ' + 'issued via delegation.' + ) + ) + + +def _block_delegated_token_app_creds(oslo_context, token): + """Raise Forbidden if the token is a trust or OAuth1 delegation. + + Trust-scoped and OAuth1 access token-scoped tokens must not be used to + create, list, read, or delete application credentials or access rules. + Creating an application credential via such a token produces a persistent + credential that outlives the delegation's expiry or scope, providing a + backdoor that breaks the accountability model: the trust-scoped token + carries the full delegation chain enabling audit, but a derived application + credential does not. + + Application credential tokens are intentionally excluded from this check. + The unrestricted/restricted distinction for application credentials is a + documented feature handled separately by + _check_unrestricted_application_credential. + """ + trust_id = getattr(oslo_context, 'trust_id', None) + access_token_id = getattr(token, 'access_token_id', None) + if trust_id or access_token_id: + raise ks_exception.Forbidden( + _( + 'Cannot manage application credentials with a token ' + 'issued via delegation.' + ) + ) + + def _build_user_target_enforcement(): target = {} try: @@ -372,11 +442,18 @@ def get(self, user_id): ENFORCER.enforce_call(action='identity:ec2_list_credentials') PROVIDERS.identity_api.get_user(user_id) credential_refs = PROVIDERS.credential_api.list_credentials_for_user( - user_id, type=CRED_TYPE_EC2) - collection_refs = [ - _convert_v3_to_ec2_credential(cred) - for cred in credential_refs - ] + user_id, type=CRED_TYPE_EC2 + ) + token = self.auth_context['token'] + collection_refs = [] + for cred in credential_refs: + try: + _check_delegation_for_ec2( + self.oslo_context, token, cred.get('project_id') + ) + except (ks_exception.Forbidden, ks_exception.ForbiddenAction): + continue + collection_refs.append(_convert_v3_to_ec2_credential(cred)) return self.wrap_collection(collection_refs) def post(self, user_id): @@ -393,6 +470,7 @@ def post(self, user_id): PROVIDERS.identity_api.get_user(user_id) tenant_id = self.request_body_json.get('tenant_id') PROVIDERS.resource_api.get_project(tenant_id) + _check_delegation_for_ec2(self.oslo_context, token, tenant_id) blob = dict( access=uuid.uuid4().hex, secret=uuid.uuid4().hex, @@ -413,12 +491,13 @@ def post(self, user_id): class UserOSEC2CredentialsResourceGetDelete(_UserOSEC2CredBaseResource): @staticmethod - def _get_cred_data(credential_id): + def _get_raw_cred(credential_id): cred = PROVIDERS.credential_api.get_credential(credential_id) if not cred or cred['type'] != CRED_TYPE_EC2: raise ks_exception.Unauthorized( - message=_('EC2 access key not found.')) - return _convert_v3_to_ec2_credential(cred) + message=_('EC2 access key not found.') + ) + return cred def get(self, user_id, credential_id): """Get a specific EC2 credential. @@ -431,8 +510,13 @@ def get(self, user_id, credential_id): build_target=func) PROVIDERS.identity_api.get_user(user_id) ec2_cred_id = utils.hash_access_key(credential_id) - cred_data = self._get_cred_data(ec2_cred_id) - return self.wrap_member(cred_data) + cred = self._get_raw_cred(ec2_cred_id) + _check_delegation_for_ec2( + self.oslo_context, + self.auth_context['token'], + cred.get('project_id'), + ) + return self.wrap_member(_convert_v3_to_ec2_credential(cred)) def delete(self, user_id, credential_id): """Delete a specific EC2 credential. @@ -444,7 +528,12 @@ def delete(self, user_id, credential_id): build_target=func) PROVIDERS.identity_api.get_user(user_id) ec2_cred_id = utils.hash_access_key(credential_id) - self._get_cred_data(ec2_cred_id) + cred = self._get_raw_cred(ec2_cred_id) + _check_delegation_for_ec2( + self.oslo_context, + self.auth_context['token'], + cred.get('project_id'), + ) PROVIDERS.credential_api.delete_credential(ec2_cred_id) return None, http.client.NO_CONTENT @@ -473,10 +562,7 @@ def get(self, user_id): GET /v3/users/{user_id}/OS-OAUTH1/access_tokens """ ENFORCER.enforce_call(action='identity:list_access_tokens') - if self.oslo_context.is_delegated_auth: - raise ks_exception.Forbidden( - _('Cannot list request tokens with a token ' - 'issued via delegation.')) + _block_delegated_token(self.oslo_context, self.auth_context['token']) refs = PROVIDERS.oauth_api.list_access_tokens(user_id) formatted_refs = ([_format_token_entity(x) for x in refs]) return self.wrap_collection(formatted_refs) @@ -489,6 +575,7 @@ def get(self, user_id, access_token_id): GET/HEAD /v3/users/{user_id}/OS-OAUTH1/access_tokens/{access_token_id} """ ENFORCER.enforce_call(action='identity:get_access_token') + _block_delegated_token(self.oslo_context, self.auth_context['token']) access_token = PROVIDERS.oauth_api.get_access_token(access_token_id) if access_token['authorizing_user_id'] != user_id: raise ks_exception.NotFound() @@ -503,6 +590,7 @@ def delete(self, user_id, access_token_id): ENFORCER.enforce_call( action='identity:ec2_delete_credential', build_target=_build_enforcer_target_data_owner_and_user_id_match) + _block_delegated_token(self.oslo_context, self.auth_context['token']) access_token = PROVIDERS.oauth_api.get_access_token(access_token_id) reason = ( 'Invalidating the token cache because an access token for ' @@ -637,6 +725,8 @@ def get(self, user_id): filters = ('name',) ENFORCER.enforce_call(action='identity:list_application_credentials', filters=filters) + token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) app_cred_api = PROVIDERS.application_credential_api hints = self.build_driver_hints(filters) refs = app_cred_api.list_application_credentials(user_id, hints=hints) @@ -653,6 +743,7 @@ def post(self, user_id): validation.lazy_validate(app_cred_schema.application_credential_create, app_cred_data) token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) _check_unrestricted_application_credential(token) if self.oslo_context.user_id != user_id: action = _('Cannot create an application credential for another ' @@ -708,6 +799,8 @@ def get(self, user_id, application_credential_id): action='identity:get_application_credential', target_attr=target, ) + token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) ref = PROVIDERS.application_credential_api.get_application_credential( application_credential_id) return self.wrap_member(ref) @@ -724,6 +817,7 @@ def delete(self, user_id, application_credential_id): target_attr=target ) token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) _check_unrestricted_application_credential(token) PROVIDERS.application_credential_api.delete_application_credential( application_credential_id, initiator=self.audit_initiator) @@ -743,6 +837,8 @@ def get(self, user_id): ENFORCER.enforce_call(action='identity:list_access_rules', filters=filters, build_target=_build_user_target_enforcement) + token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) app_cred_api = PROVIDERS.application_credential_api hints = self.build_driver_hints(filters) refs = app_cred_api.list_access_rules_for_user(user_id, hints=hints) @@ -763,6 +859,8 @@ def get(self, user_id, access_rule_id): action='identity:get_access_rule', build_target=_build_user_target_enforcement ) + token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) ref = PROVIDERS.application_credential_api.get_access_rule( access_rule_id) return self.wrap_member(ref) @@ -776,6 +874,8 @@ def delete(self, user_id, access_rule_id): action='identity:delete_access_rule', build_target=_build_user_target_enforcement ) + token = self.auth_context['token'] + _block_delegated_token_app_creds(self.oslo_context, token) PROVIDERS.application_credential_api.delete_access_rule( access_rule_id, initiator=self.audit_initiator) return None, http.client.NO_CONTENT diff --git a/keystone/conf/security_compliance.py b/keystone/conf/security_compliance.py index 686a957d29..2c7a74505e 100644 --- a/keystone/conf/security_compliance.py +++ b/keystone/conf/security_compliance.py @@ -125,6 +125,71 @@ """)) +allow_insecure_admin_trust_cross_project_credentials_access = cfg.BoolOpt( + 'allow_insecure_admin_trust_cross_project_credentials_access', + default=False, + deprecated_for_removal=True, + deprecated_reason=utils.fmt( + """ +Migrate automated workflows that use admin-role trusts to access credentials +across multiple projects (e.g. Mistral cron triggers) to use non-delegated +service account credentials instead, then remove this option. +""" + ), + deprecated_since='2026.1', + help=utils.fmt( + """ +INSECURE: When enabled, admin-role delegated tokens (trusts, application +credentials, OAuth1 access tokens) are allowed to access credentials outside +their project scope. By default (False), delegated tokens can only access +credentials whose project_id matches the token's project scope, preventing +cross-project lateral movement via a compromised delegation token. + +Enable this only if you have automated workflows (e.g. Mistral cron triggers) +that use admin-role trusts to access credentials across multiple projects and +cannot be migrated to use non-delegated service account credentials. Enabling +this option weakens the isolation guarantee provided by the delegation boundary +fix for LP#2150089. This option is deprecated and will be removed in a future +release. +""" + ), +) + + +allow_insecure_application_credential_trust_escalation = cfg.BoolOpt( + 'allow_insecure_application_credential_trust_escalation', + default=False, + deprecated_for_removal=True, + deprecated_reason=utils.fmt( + """ +Migrate workflows where application credentials create trusts to use OIDC +federation flows (v3oidcclientcredentials, v3oidcdeviceauthz) instead, then +remove this option. +""" + ), + deprecated_since='2026.1', + help=utils.fmt( + """ +INSECURE: When enabled, application credential tokens (including restricted +ones) are allowed to create, delete, and list trusts. By default (False), +application credential tokens are blocked from all trust operations regardless +of the unrestricted flag, because allowing an application credential to +bootstrap a trust creates a new delegation context. A trust-scoped token +produced from that trust can then access authentication material (EC2 +credentials, TOTP seeds) and operate entirely outside the delegation chain, +breaking the audit trail. The 'unrestricted' flag governs credential +management, not trust management. + +Enable this only if you have workflows where application credentials must +create trusts (e.g. Heat stacks authenticated via application credentials). +Use OIDC federation flows (v3oidcclientcredentials, v3oidcdeviceauthz) as the +proper long-term alternative. This option is deprecated and will be removed +in a future release. +""" + ), +) + + GROUP_NAME = __name__.split('.')[-1] ALL_OPTS = [ disable_user_account_days_inactive, @@ -135,7 +200,9 @@ minimum_password_age, password_regex, password_regex_description, - change_password_upon_first_use + change_password_upon_first_use, + allow_insecure_admin_trust_cross_project_credentials_access, + allow_insecure_application_credential_trust_escalation, ] diff --git a/keystone/tests/unit/test_v3_application_credential.py b/keystone/tests/unit/test_v3_application_credential.py index e581d0dbfa..03c03e1bc8 100644 --- a/keystone/tests/unit/test_v3_application_credential.py +++ b/keystone/tests/unit/test_v3_application_credential.py @@ -211,7 +211,6 @@ def test_create_application_credential_with_trust(self): self.user_id, self.project_id, second_role['id']) with self.test_client() as c: pw_token = self.get_scoped_token() - # create a self-trust - only the roles are important for this test trust_ref = unit.new_trust_ref( trustor_user_id=self.user_id, trustee_user_id=self.user_id, @@ -228,14 +227,26 @@ def test_create_application_credential_with_trust(self): trust_token = self.v3_create_token( trust_auth).headers['X-Subject-Token'] app_cred = self._app_cred_body(roles=[{'id': self.role_id}]) - # only the roles from the trust token should be allowed, even if - # the user has the role assigned on the project - c.post('/v3/users/%s/application_credentials' % self.user_id, - headers={'X-Auth-Token': trust_token}, - json=app_cred, - expected_status_code=http.client.BAD_REQUEST) + # Trust-scoped tokens are entirely blocked from managing + # application credentials (LP#2150089). + c.post( + f'/v3/users/{self.user_id}/application_credentials', + headers={'X-Auth-Token': trust_token}, + json=app_cred, + expected_status_code=http.client.FORBIDDEN, + ) def test_create_application_credential_allow_recursion(self): + """Unrestricted app credential token can create new credentials. + + The `unrestricted` flag is a documented (unsafe) feature that allows + an application credential token to create additional application + credentials. This must continue to work -- restricted application + credentials are blocked by _check_unrestricted_application_credential, + but unrestricted ones are explicitly opted-in to this behaviour. + Trust-scoped and OAuth1 tokens are separately blocked by + _block_delegated_token_app_creds (LP#2150089). + """ with self.test_client() as c: roles = [{'id': self.role_id}] app_cred_body_1 = self._app_cred_body(roles=roles) @@ -554,6 +565,12 @@ def test_delete_application_credential_with_application_credential(self): headers={'X-Auth-Token': token}) def test_delete_application_credential_allow_recursion(self): + """Unrestricted app credential token can delete credentials. + + The `unrestricted` flag allows an application credential token to + delete application credentials. Trust-scoped and OAuth1 tokens are + separately blocked by _block_delegated_token_app_creds (LP#2150089). + """ with self.test_client() as c: roles = [{'id': self.role_id}] app_cred_body = self._app_cred_body(roles=roles) @@ -603,3 +620,174 @@ def test_update_application_credential(self): json=app_cred_body, expected_status_code=http.client.METHOD_NOT_ALLOWED, headers={'X-Auth-Token': token}) + + def _get_trust_token(self, c, pw_token): + """Return a trust-scoped token for self.user_id on self.project_id.""" + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.user_id, + project_id=self.project_id, + role_ids=[self.role_id], + ) + resp = c.post( + '/v3/OS-TRUST/trusts', + headers={'X-Auth-Token': pw_token}, + json={'trust': trust_ref}, + ) + trust_id = resp.json['trust']['id'] + trust_auth = self.build_authentication_request( + user_id=self.user_id, + password=self.user['password'], + trust_id=trust_id, + ) + return self.v3_create_token(trust_auth).headers['X-Subject-Token'] + + def test_delegation_guard_trust_list_app_creds(self): + """Trust-scoped token cannot list application credentials (LP#2150089). + + Previously GET /v3/users/{id}/application_credentials had no delegation + guard at all; any trust-scoped token could enumerate the user's + application credentials. + """ + with self.test_client() as c: + pw_token = self.get_scoped_token() + trust_token = self._get_trust_token(c, pw_token) + c.get( + f'/v3/users/{self.user_id}/application_credentials', + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) + + def test_delegation_guard_trust_get_app_cred(self): + """Trust-scoped token cannot read a specific application credential. + + Previously GET /v3/users/{id}/application_credentials/{id} had no + delegation guard at all (LP#2150089). + """ + with self.test_client() as c: + pw_token = self.get_scoped_token() + roles = [{'id': self.role_id}] + resp = c.post( + f'/v3/users/{self.user_id}/application_credentials', + json=self._app_cred_body(roles=roles), + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': pw_token}, + ) + app_cred_id = resp.json['application_credential']['id'] + trust_token = self._get_trust_token(c, pw_token) + member_path = f'/v3{MEMBER_PATH_FMT}' % { + 'user_id': self.user_id, + 'app_cred_id': app_cred_id, + } + c.get( + member_path, + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) + + def test_delegation_guard_trust_delete_app_cred(self): + """Trust-scoped token cannot delete an application credential. + + Previously DELETE /v3/users/{id}/application_credentials/{id} only + checked for restricted app-cred tokens; trust-scoped tokens had no + guard (LP#2150089). + """ + with self.test_client() as c: + pw_token = self.get_scoped_token() + roles = [{'id': self.role_id}] + resp = c.post( + f'/v3/users/{self.user_id}/application_credentials', + json=self._app_cred_body(roles=roles), + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': pw_token}, + ) + app_cred_id = resp.json['application_credential']['id'] + trust_token = self._get_trust_token(c, pw_token) + member_path = f'/v3{MEMBER_PATH_FMT}' % { + 'user_id': self.user_id, + 'app_cred_id': app_cred_id, + } + c.delete( + member_path, + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) + + def test_delegation_guard_trust_list_access_rules(self): + """Trust-scoped token cannot list access rules (LP#2150089).""" + access_rules = [ + {'path': '/v3/projects', 'method': 'GET', 'service': 'identity'} + ] + with self.test_client() as c: + pw_token = self.get_scoped_token() + roles = [{'id': self.role_id}] + c.post( + f'/v3/users/{self.user_id}/application_credentials', + json=self._app_cred_body( + roles=roles, access_rules=access_rules + ), + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': pw_token}, + ) + trust_token = self._get_trust_token(c, pw_token) + c.get( + f'/v3/users/{self.user_id}/access_rules', + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) + + def test_delegation_guard_trust_get_access_rule(self): + """Trust-scoped token cannot read a specific access rule (LP#2150089).""" + access_rules = [ + {'path': '/v3/projects', 'method': 'GET', 'service': 'identity'} + ] + with self.test_client() as c: + pw_token = self.get_scoped_token() + roles = [{'id': self.role_id}] + resp = c.post( + f'/v3/users/{self.user_id}/application_credentials', + json=self._app_cred_body( + roles=roles, access_rules=access_rules + ), + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': pw_token}, + ) + access_rule_id = resp.json['application_credential'][ + 'access_rules' + ][0]['id'] + trust_token = self._get_trust_token(c, pw_token) + c.get( + f'/v3/users/{self.user_id}/access_rules/{access_rule_id}', + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) + + def test_delegation_guard_trust_delete_access_rule(self): + """Trust-scoped token cannot delete an access rule (LP#2150089).""" + access_rules = [ + {'path': '/v3/projects', 'method': 'GET', 'service': 'identity'} + ] + with self.test_client() as c: + pw_token = self.get_scoped_token() + roles = [{'id': self.role_id}] + resp = c.post( + f'/v3/users/{self.user_id}/application_credentials', + json=self._app_cred_body( + roles=roles, access_rules=access_rules + ), + expected_status_code=http.client.CREATED, + headers={'X-Auth-Token': pw_token}, + ) + ac = resp.json['application_credential'] + access_rule_id = ac['access_rules'][0]['id'] + c.delete( + f'/v3/users/{self.user_id}/application_credentials/{ac["id"]}', + headers={'X-Auth-Token': pw_token}, + expected_status_code=http.client.NO_CONTENT, + ) + trust_token = self._get_trust_token(c, pw_token) + c.delete( + f'/v3/users/{self.user_id}/access_rules/{access_rule_id}', + headers={'X-Auth-Token': trust_token}, + expected_status_code=http.client.FORBIDDEN, + ) diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 2faa877084..b871291934 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -65,6 +65,30 @@ def _create_dict_blob_credential(self): return json.dumps(blob), credential_id + def _get_ec2_sig_ref(self, blob): + """Return a signed ec2Credentials dict for use with POST /ec2tokens.""" + signer = ec2_utils.Ec2Signer(blob['secret']) + params = { + 'SignatureMethod': 'HmacSHA256', + 'SignatureVersion': '2', + 'AWSAccessKeyId': blob['access'], + } + return { + 'access': blob['access'], + 'signature': signer.generate( + { + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + ), + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + def _test_get_token(self, access, secret): """Test signature validation with the access/secret provided.""" signer = ec2_utils.Ec2Signer(secret) @@ -669,6 +693,152 @@ def test_trust_scoped_ec2_credential(self): token=token_id, expected_status=http.client.CONFLICT) + def _get_trust_token(self): + ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + impersonation=True, + role_ids=[self.role_id], + ) + del ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust = self.assertValidTrustResponse(r) + auth_data = self.build_authentication_request( + user_id=self.trustee_user['id'], + password=self.trustee_user['password'], + trust_id=trust['id'], + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_trust_token_cannot_list_totp_credentials(self): + """Trust-scoped token must not see TOTP/MFA credentials (project_id=None). + + TOTP credentials have no project anchor. Before this fix the + project boundary check skipped null-project credentials, allowing a + delegation token to enumerate and exfiltrate MFA secrets. + """ + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + + trust_token = self._get_trust_token() + + r = self.get(f'/credentials?user_id={self.user_id}', token=trust_token) + listed_ids = [c['id'] for c in r.result['credentials']] + self.assertNotIn(totp_id, listed_ids) + + def test_trust_token_cannot_read_totp_credential(self): + """Trust-scoped token must not read a TOTP credential blob.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + + trust_token = self._get_trust_token() + self.get( + f'/credentials/{totp_id}', + token=trust_token, + expected_status=http.client.FORBIDDEN, + ) + + def test_trust_token_cannot_update_totp_credential(self): + """Trust-scoped token must not be able to update a TOTP credential blob.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + + trust_token = self._get_trust_token() + self.patch( + f'/credentials/{totp_id}', + token=trust_token, + body={'credential': totp_ref}, + expected_status=http.client.FORBIDDEN, + ) + + def test_trust_token_cannot_delete_totp_credential(self): + """Trust-scoped token must not delete a TOTP credential.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + + trust_token = self._get_trust_token() + self.delete( + f'/credentials/{totp_id}', + token=trust_token, + expected_status=http.client.FORBIDDEN, + ) + # Confirm it still exists + self.get(f'/credentials/{totp_id}', expected_status=http.client.OK) + + def test_ec2_auth_trust_cross_project_scoped_to_trust(self): + """Trust-backed EC2 credential with mismatched project_id is safe. + + When an EC2 credential's project_id differs from the trust's + project_id, the trust mechanism constrains the resulting token to + the trust's project -- not the credential's project. This means the + cross-project escalation does not occur for trust-backed credentials, + and no additional auth-time check is needed in that branch. + + This test documents and protects that invariant. + """ + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + impersonation=True, + role_ids=[self.role_id], + ) + del trust_ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': trust_ref}) + trust = self.assertValidTrustResponse(r) + + other_project = unit.new_project_ref(domain_id=self.domain_id) + other_project = PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + # Plant a credential with project_id pointing to the other project + # but trust_id from the trust above (scoped to self.project_id). + blob, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'] + ) + blob['trust_id'] = trust['id'] + ref['blob'] = json.dumps(blob) + PROVIDERS.credential_api.create_credential(ref['id'], ref) + + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + r = self.post( + '/ec2tokens', + body={'ec2Credentials': self._get_ec2_sig_ref(blob)}, + token=token, + expected_status=http.client.OK, + ) + # The resulting token is scoped to the trust's project, not to + # other_project -- the trust mechanism prevents cross-project escalation. + token_project = r.result['token']['project']['id'] + self.assertEqual(self.project_id, token_project) + self.assertNotEqual(other_project['id'], token_project) + class TestCredentialAppCreds(CredentialBaseTestCase): """Test credential with application credential token.""" @@ -872,6 +1042,81 @@ def test_app_cred_ec2_auth_cross_project_rejected(self): expected_status=http.client.UNAUTHORIZED, ) + def test_app_cred_token_cannot_list_totp_credentials(self): + """App cred token must not see TOTP/MFA credentials (project_id=None). + + TOTP credentials have no project anchor. Before this fix the + project boundary check skipped null-project credentials, allowing a + delegation token to enumerate and exfiltrate MFA secrets. + """ + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + + app_cred_token = self._get_app_cred_token(unrestricted=True) + + r = self.get( + f'/credentials?user_id={self.user_id}', token=app_cred_token + ) + listed_ids = [c['id'] for c in r.result['credentials']] + self.assertNotIn(totp_id, listed_ids) + + def test_app_cred_token_cannot_read_totp_credential(self): + """App cred token must not read a TOTP credential blob.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + app_cred_token = self._get_app_cred_token(unrestricted=True) + + self.get( + f'/credentials/{totp_id}', + token=app_cred_token, + expected_status=http.client.FORBIDDEN, + ) + + def test_app_cred_token_cannot_update_totp_credential(self): + """App cred token must not update a TOTP credential blob.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + app_cred_token = self._get_app_cred_token(unrestricted=True) + + self.patch( + f'/credentials/{totp_id}', + token=app_cred_token, + body={'credential': totp_ref}, + expected_status=http.client.FORBIDDEN, + ) + + def test_app_cred_token_cannot_delete_totp_credential(self): + """App cred token must not delete a TOTP credential blob.""" + totp_ref = { + 'user_id': self.user_id, + 'type': 'totp', + 'blob': '{"seed": "JBSWY3DPEHPK3PXP"}', + } + r = self.post('/credentials', body={'credential': totp_ref}) + totp_id = r.result['credential']['id'] + app_cred_token = self._get_app_cred_token(unrestricted=True) + + self.delete( + f'/credentials/{totp_id}', + token=app_cred_token, + expected_status=http.client.FORBIDDEN, + ) + class TestCredentialAccessToken(CredentialBaseTestCase): """Test credential with access token.""" @@ -1019,6 +1264,53 @@ def test_access_token_ec2_credential(self): self.assertIn(self.role_id, ec2_roles) self.assertNotIn(role_id, ec2_roles) + def test_ec2_auth_access_token_cross_project_blocked(self): + """OAuth1 access-token-backed EC2 credential must not auth cross-project. + + Auth-time check: if a cross-project EC2 credential backed by an OAuth1 + access token exists, POST /ec2tokens must reject it when the + credential's project_id differs from the access token's project_id. + """ + access_key, _ = self._get_access_token() + + # Retrieve the stored access token to get its project_id + access_token = PROVIDERS.oauth_api.get_access_token( + access_key.decode('utf-8') + if isinstance(access_key, bytes) + else access_key + ) + + # Create a second project (cross-project target) + other_project = unit.new_project_ref(domain_id=self.domain_id) + other_project = PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + # Directly inject an EC2 credential whose project_id points to the + # other project but whose access_token_id references the token above. + # This simulates a pre-existing cross-project credential. + blob, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'] + ) + blob['access_token_id'] = ( + access_key.decode('utf-8') + if isinstance(access_key, bytes) + else access_key + ) + ref['blob'] = json.dumps(blob) + PROVIDERS.credential_api.create_credential(ref['id'], ref) + + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + self.post( + '/ec2tokens', + body={'ec2Credentials': self._get_ec2_sig_ref(blob)}, + token=token, + expected_status=http.client.UNAUTHORIZED, + ) + class TestCredentialEc2(CredentialBaseTestCase): """Test v3 credential compatibility with ec2tokens.""" @@ -1170,3 +1462,103 @@ def test_ec2_create_credential_with_unrestricted_app_cred(self): ec2_cred = r.result['credential'] self.assertEqual(self.user_id, ec2_cred['user_id']) self.assertEqual(self.project_id, ec2_cred['tenant_id']) + + def _get_trust_token(self): + """Create a trust and return a trust-scoped token for the trustee.""" + trustee = unit.new_user_ref(domain_id=self.domain_id) + password = trustee['password'] + trustee = PROVIDERS.identity_api.create_user(trustee) + trustee['password'] = password + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=trustee['id'], + project_id=self.project_id, + impersonation=True, + role_ids=[self.role_id], + ) + del trust_ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': trust_ref}) + trust = r.result['trust'] + auth_data = self.build_authentication_request( + user_id=trustee['id'], + password=trustee['password'], + trust_id=trust['id'], + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_ec2_create_credential_trust_cross_project_blocked(self): + """Trust-scoped token cannot create EC2 cred for a different project.""" + other_project = unit.new_project_ref(domain_id=self.domain_id) + other_project = PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + trust_token = self._get_trust_token() + uri = f'/users/{self.user_id}/credentials/OS-EC2' + self.post( + uri, + body={'tenant_id': other_project['id']}, + token=trust_token, + expected_status=http.client.FORBIDDEN, + ) + + def test_ec2_create_credential_trust_same_project_allowed(self): + """Trust-scoped token can create EC2 cred for the trust project.""" + trust_token = self._get_trust_token() + uri = self._get_ec2_cred_uri() + r = self.post( + uri, + body={'tenant_id': self.project_id}, + token=trust_token, + expected_status=http.client.CREATED, + ) + self.assertEqual(self.project_id, r.result['credential']['tenant_id']) + + def test_ec2_get_credential_trust_cross_project_blocked(self): + """Trust-scoped token cannot get an EC2 cred from a different project.""" + other_project = unit.new_project_ref(domain_id=self.domain_id) + other_project = PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, other_project['id'], self.role_id + ) + ec2_cred = self._get_ec2_cred() + # Change the credential's project to the other project directly + PROVIDERS.credential_api.update_credential( + next( + c['id'] + for c in PROVIDERS.credential_api.list_credentials_for_user( + self.user_id, type=CRED_TYPE_EC2 + ) + ), + {'project_id': other_project['id']}, + ) + trust_token = self._get_trust_token() + uri = '/'.join([self._get_ec2_cred_uri(), ec2_cred['access']]) + self.get(uri, token=trust_token, expected_status=http.client.FORBIDDEN) + + def test_ec2_delete_credential_trust_cross_project_blocked(self): + """Trust-scoped token cannot delete EC2 cred from a different project.""" + other_project = unit.new_project_ref(domain_id=self.domain_id) + other_project = PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + PROVIDERS.assignment_api.add_role_to_user_and_project( + self.user_id, other_project['id'], self.role_id + ) + ec2_cred = self._get_ec2_cred() + PROVIDERS.credential_api.update_credential( + next( + c['id'] + for c in PROVIDERS.credential_api.list_credentials_for_user( + self.user_id, type=CRED_TYPE_EC2 + ) + ), + {'project_id': other_project['id']}, + ) + trust_token = self._get_trust_token() + uri = '/'.join([self._get_ec2_cred_uri(), ec2_cred['access']]) + self.delete( + uri, token=trust_token, expected_status=http.client.FORBIDDEN + ) diff --git a/keystone/tests/unit/test_v3_oauth1.py b/keystone/tests/unit/test_v3_oauth1.py index 46ca2a5ba1..113e92bcf6 100644 --- a/keystone/tests/unit/test_v3_oauth1.py +++ b/keystone/tests/unit/test_v3_oauth1.py @@ -430,6 +430,118 @@ def test_list_and_delete_access_tokens(self): self.assertEqual([], entities) self.assertValidListLinks(resp.result['links']) + def _get_app_cred_token(self): + app_cred = { + 'id': uuid.uuid4().hex, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'name': uuid.uuid4().hex, + 'roles': [{'id': self.role_id}], + 'secret': uuid.uuid4().hex, + } + PROVIDERS.application_credential_api.create_application_credential( + app_cred + ) + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + return r.headers['X-Subject-Token'] + + def test_list_access_tokens_with_app_cred_blocked(self): + """Application credential token must not list OAuth1 access tokens.""" + self.test_oauth_flow() + token = self._get_app_cred_token() + self.get( + f'/users/{self.user_id}/OS-OAUTH1/access_tokens', + token=token, + expected_status=http.client.FORBIDDEN, + ) + + def test_get_access_token_with_app_cred_blocked(self): + """Application credential token must not get a specific access token.""" + self.test_oauth_flow() + token = self._get_app_cred_token() + access_token_key = self.access_token.key.decode() + self.get( + f'/users/{self.user_id}/OS-OAUTH1/access_tokens/{access_token_key}', + token=token, + expected_status=http.client.FORBIDDEN, + ) + + def test_delete_access_token_with_app_cred_blocked(self): + """Application credential token must not delete an access token.""" + self.test_oauth_flow() + token = self._get_app_cred_token() + access_token_key = self.access_token.key.decode() + self.delete( + f'/users/{self.user_id}/OS-OAUTH1/access_tokens/{access_token_key}', + token=token, + expected_status=http.client.FORBIDDEN, + ) + + def test_get_access_token_with_trust_token_blocked(self): + """Trust-scoped token must not get a specific access token.""" + self.test_oauth_flow() + trustee = unit.new_user_ref(domain_id=self.domain_id) + password = trustee['password'] + trustee = PROVIDERS.identity_api.create_user(trustee) + trustee['password'] = password + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=trustee['id'], + project_id=self.project_id, + impersonation=True, + role_ids=[self.role_id], + ) + del trust_ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': trust_ref}) + trust_id = r.result['trust']['id'] + auth_data = self.build_authentication_request( + user_id=trustee['id'], + password=trustee['password'], + trust_id=trust_id, + ) + r = self.v3_create_token(auth_data) + trust_token = r.headers['X-Subject-Token'] + access_token_key = self.access_token.key.decode() + self.get( + f'/users/{self.user_id}/OS-OAUTH1/access_tokens/{access_token_key}', + token=trust_token, + expected_status=http.client.FORBIDDEN, + ) + + def test_delete_access_token_with_trust_token_blocked(self): + """Trust-scoped token must not delete an access token.""" + self.test_oauth_flow() + trustee = unit.new_user_ref(domain_id=self.domain_id) + password = trustee['password'] + trustee = PROVIDERS.identity_api.create_user(trustee) + trustee['password'] = password + trust_ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=trustee['id'], + project_id=self.project_id, + impersonation=True, + role_ids=[self.role_id], + ) + del trust_ref['id'] + r = self.post('/OS-TRUST/trusts', body={'trust': trust_ref}) + trust_id = r.result['trust']['id'] + auth_data = self.build_authentication_request( + user_id=trustee['id'], + password=trustee['password'], + trust_id=trust_id, + ) + r = self.v3_create_token(auth_data) + trust_token = r.headers['X-Subject-Token'] + access_token_key = self.access_token.key.decode() + self.delete( + f'/users/{self.user_id}/OS-OAUTH1/access_tokens/{access_token_key}', + token=trust_token, + expected_status=http.client.FORBIDDEN, + ) + class AuthTokenTests(object): diff --git a/releasenotes/notes/bug-2150089-e91b592c948e5771.yaml b/releasenotes/notes/bug-2150089-e91b592c948e5771.yaml new file mode 100644 index 0000000000..9d44424625 --- /dev/null +++ b/releasenotes/notes/bug-2150089-e91b592c948e5771.yaml @@ -0,0 +1,35 @@ +--- +security: + - | + [`bug 2150089 `_] + Delegated tokens (trusts, application credentials, OAuth1 access tokens) + are now restricted to credentials whose ``project_id`` matches the token's + project scope. This closes a cross-project lateral movement vector where a + delegated token could read, modify, or delete credentials belonging to a + different project, including EC2 keys and TOTP/MFA seed bindings. + + Application credential tokens are now blocked from all trust operations + (create, delete, list, get). Allowing an application credential to bootstrap + a trust creates a new delegation context whose token can access + authentication material outside the delegation chain, breaking the audit + trail. The ``unrestricted`` flag governs credential management, not trust + management. +upgrade: + - | + [`bug 2150089 `_] + Two new ``[security_compliance]`` options control opt-in insecure behaviour + for operators with workflows that break after this upgrade: + + ``allow_insecure_admin_trust_cross_project_credentials_access`` (default + ``False``): set to ``True`` if admin-role trusts or application credentials + need to access credentials across multiple projects (e.g. Mistral cron + triggers syncing EC2 credentials system-wide). + + ``allow_insecure_application_credential_trust_escalation`` (default + ``False``): set to ``True`` if application credentials must create or manage + trusts (e.g. Heat stacks authenticated via application credentials). Use + OIDC federation flows (``v3oidcclientcredentials``, ``v3oidcdeviceauthz``) + as the proper long-term alternative. + + Both options are intentionally named to signal that enabling them is + insecure. Migrate affected workflows away from these options. From 11a137c1b14532c9e9cc9e6bd4bfb953c00f1495 Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Tue, 12 May 2026 09:22:34 +0200 Subject: [PATCH 23/26] Fix user impersonation through application credentials (CVE-2026-42998) When authenticating by application credential ID, the caller can supply a 'user' field in the payload. AppCredInfo conditionally set the user from the credential owner only when no user field was present. If present, BaseUserInfo resolved the caller-supplied user and attributed the resulting token to that user instead of the credential owner. Fix: always set auth_payload['user'] from the credential's stored user_id, ignoring any caller-supplied value. Closes-Bug: #2148477 Assisted-by: Claude Sonnet 4.6 Co-authored-by: Boris Bobrov Change-Id: I2fe6089886eebf3775930451b87771e40b5e179e Signed-off-by: Grzegorz Grasza Signed-off-by: Artem Goncharov (cherry picked from commit 6cd25fecdab8b9261e916ee10f3dba5aeb0c1984) (cherry picked from commit 90fe79c82e9eb16547e3e4aa69f9f8cb8daa8bc6) --- keystone/auth/plugins/core.py | 7 +-- keystone/tests/unit/test_v3_auth.py | 75 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/keystone/auth/plugins/core.py b/keystone/auth/plugins/core.py index 1ae451df33..f48e360942 100644 --- a/keystone/auth/plugins/core.py +++ b/keystone/auth/plugins/core.py @@ -242,9 +242,10 @@ def _validate_and_normalize_auth_data(self, auth_payload): app_cred = app_cred_api.get_application_credential( auth_payload['id']) self.user_id = app_cred['user_id'] - if not auth_payload.get('user'): - auth_payload['user'] = {} - auth_payload['user']['id'] = self.user_id + # Always bind to the credential owner. Any user field supplied by + # the caller would allow impersonation of an arbitrary user + # (LP#2148477). + auth_payload['user'] = {'id': app_cred['user_id']} super(AppCredInfo, self)._validate_and_normalize_auth_data( auth_payload) elif auth_payload.get('name'): diff --git a/keystone/tests/unit/test_v3_auth.py b/keystone/tests/unit/test_v3_auth.py index eb7ea0e292..37256fe76c 100644 --- a/keystone/tests/unit/test_v3_auth.py +++ b/keystone/tests/unit/test_v3_auth.py @@ -5919,3 +5919,78 @@ def test_application_credential_access_rules_without_header_fails(self): expected_status=http.client.CREATED) token = resp.headers.get('X-Subject-Token') self._validate_token(token, expected_status=http.client.NOT_FOUND) + + def test_app_cred_auth_with_injected_user_id_is_ignored(self): + """Caller-supplied user ID in app cred payload must be ignored. + + When authenticating by application credential ID, the token must + always be attributed to the credential owner. An attacker-supplied + user ID must not override the credential owner's identity. + LP#2148477 -- user impersonation via app credential auth. + """ + victim = unit.create_user( + PROVIDERS.identity_api, domain_id=self.domain_id + ) + PROVIDERS.assignment_api.add_role_to_user_and_project( + victim['id'], self.project_id, self.role_id + ) + + app_cred = self._make_app_cred() + app_cred_ref = self.app_cred_api.create_application_credential( + app_cred + ) + + auth_body = { + 'auth': { + 'identity': { + 'methods': ['application_credential'], + 'application_credential': { + 'id': app_cred_ref['id'], + 'secret': app_cred['secret'], + 'user': {'id': victim['id']}, + }, + } + } + } + r = self.v3_create_token(auth_body) + token_data = r.result['token'] + self.assertEqual(self.user['id'], token_data['user']['id']) + self.assertNotEqual(victim['id'], token_data['user']['id']) + + def test_app_cred_auth_with_injected_username_is_ignored(self): + """Caller-supplied username in app cred payload must be ignored. + + Same as the user ID variant but uses the victim's name and domain, + which are typically predictable. LP#2148477. + """ + victim = unit.create_user( + PROVIDERS.identity_api, domain_id=self.domain_id + ) + PROVIDERS.assignment_api.add_role_to_user_and_project( + victim['id'], self.project_id, self.role_id + ) + + app_cred = self._make_app_cred() + app_cred_ref = self.app_cred_api.create_application_credential( + app_cred + ) + + auth_body = { + 'auth': { + 'identity': { + 'methods': ['application_credential'], + 'application_credential': { + 'id': app_cred_ref['id'], + 'secret': app_cred['secret'], + 'user': { + 'name': victim['name'], + 'domain': {'name': self.domain['name']}, + }, + }, + } + } + } + r = self.v3_create_token(auth_body) + token_data = r.result['token'] + self.assertEqual(self.user['id'], token_data['user']['id']) + self.assertNotEqual(victim['id'], token_data['user']['id']) From 6f5f8471cb9864258bcfcf82f80639c797f2f712 Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Tue, 12 May 2026 09:22:47 +0200 Subject: [PATCH 24/26] Forbid trust operations using application credentials (CVE-2026-43000) Previously only restricted application credentials were blocked, and only for trust create and delete. This change blocks all application credentials (restricted and unrestricted alike) from all trust operations: list, get, create, delete, list-roles, and get-role. The 'unrestricted' flag governs credential management, not trust management. Closes-Bug: #2148477 Related-Bug: #2149789 Related-Bug: #2150089 Assisted-by: Claude Sonnet 4.6 Co-authored-by: Boris Bobrov Change-Id: I750156df18a1d6293ce99c42eb524575fcf16ea3 Signed-off-by: Grzegorz Grasza Signed-off-by: Artem Goncharov (cherry picked from commit 3c2043ab003cb4b8aa34502fe9a5a69b0a6a6e54) (cherry picked from commit 50c30d16994511925d23975481993bb8395ab0d7) --- doc/source/user/application_credentials.rst | 6 +- keystone/api/trusts.py | 40 +++++++-- keystone/tests/unit/test_v3_trust.py | 93 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/doc/source/user/application_credentials.rst b/doc/source/user/application_credentials.rst index 5455a04e7b..2fa808ef53 100644 --- a/doc/source/user/application_credentials.rst +++ b/doc/source/user/application_credentials.rst @@ -142,9 +142,9 @@ You can provide an expiration date for application credentials: +--------------+----------------------------------------------------------------------------------------+ By default, application credentials are restricted from creating or deleting -other application credentials and from creating or deleting trusts. If your -application needs to be able to perform these actions and you accept the risks -involved, you can disable this protection: +other application credentials. If your application needs to be able to perform +these actions and you accept the risks involved, you can disable this +protection: .. warning:: diff --git a/keystone/api/trusts.py b/keystone/api/trusts.py index 781b99efe5..a7d06561a6 100644 --- a/keystone/api/trusts.py +++ b/keystone/api/trusts.py @@ -22,6 +22,7 @@ from oslo_policy import _checks as op_checks from keystone.api._shared import json_home_relations +from keystone.common import authorization from keystone.common import context from keystone.common import json_home from keystone.common import provider_api @@ -29,6 +30,7 @@ from keystone.common.rbac_enforcer import policy from keystone.common import utils from keystone.common import validation +import keystone.conf from keystone import exception from keystone.i18n import _ from keystone.server import flask as ks_flask @@ -36,6 +38,7 @@ LOG = log.getLogger(__name__) +CONF = keystone.conf.CONF ENFORCER = rbac_enforcer.RBACEnforcer PROVIDERS = provider_api.ProviderAPIs @@ -46,6 +49,30 @@ parameter_name='trust_id') +def _check_application_credential(): + """Block application credential tokens from all trust operations. + + Application credentials are single-project delegation tokens. Allowing + them to read or manage trusts would permit a compromised application + credential to enumerate or manipulate the trust delegation chain, + expanding its effective scope beyond the single project it was issued for. + This applies regardless of the 'unrestricted' flag. + """ + if CONF.security_compliance.allow_insecure_application_credential_trust_escalation: # noqa + return + auth_context = flask.request.environ.get( + authorization.AUTH_CONTEXT_ENV, {} + ) + token = auth_context.get('token') + if token and 'application_credential' in token.methods: + raise exception.ForbiddenAction( + action=_( + "Using method 'application_credential' is not " + "allowed for managing trusts." + ) + ) + + def _build_trust_target_enforcement(): target = {} # NOTE(cmurphy) unlike other APIs, in the event the trust doesn't exist or @@ -101,14 +128,7 @@ class TrustResource(ks_flask.ResourceBase): json_home_parameter_rel_func = _build_parameter_relation def _check_unrestricted(self): - if self.oslo_context.is_admin: - return - token = self.auth_context['token'] - if 'application_credential' in token.methods: - if not token.application_credential['unrestricted']: - action = _("Using method 'application_credential' is not " - "allowed for managing trusts.") - raise exception.ForbiddenAction(action=action) + _check_application_credential() def _find_redelegated_trust(self): # Check if delegated via trust @@ -166,6 +186,7 @@ def _normalize_role_list(self, trust_roles): def _get_trust(self, trust_id): ENFORCER.enforce_call(action='identity:get_trust', build_target=_build_trust_target_enforcement) + _check_application_credential() # NOTE(cmurphy) look up trust before doing is_admin authorization - to # maintain the API contract, we expect a missing trust to raise a 404 @@ -214,6 +235,7 @@ def _list_trusts(self): target_attr=target) else: ENFORCER.enforce_call(action='identity:list_trusts') + _check_application_credential() trusts = [] @@ -361,6 +383,7 @@ def get(self, trust_id): # block access here raise exception.ForbiddenAction( action=_('Requested user has no relation to this trust')) + _check_application_credential() trust = PROVIDERS.trust_api.get_trust(trust_id) @@ -410,6 +433,7 @@ def get(self, trust_id, role_id): # block access here raise exception.ForbiddenAction( action=_('Requested user has no relation to this trust')) + _check_application_credential() trust = PROVIDERS.trust_api.get_trust(trust_id) diff --git a/keystone/tests/unit/test_v3_trust.py b/keystone/tests/unit/test_v3_trust.py index fe6462c62e..82685bb69c 100644 --- a/keystone/tests/unit/test_v3_trust.py +++ b/keystone/tests/unit/test_v3_trust.py @@ -569,6 +569,99 @@ def test_create_trust_with_application_credential(self): token=token_data.headers['x-subject-token'], expected_status=http.client.FORBIDDEN) + def _get_app_cred_token(self, unrestricted=False): + app_cred = { + 'id': uuid.uuid4().hex, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'name': uuid.uuid4().hex, + 'roles': [{'id': self.role_id}], + 'secret': uuid.uuid4().hex, + } + if unrestricted: + app_cred['unrestricted'] = True + PROVIDERS.application_credential_api.create_application_credential( + app_cred + ) + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token( + auth_data, expected_status=http.client.CREATED + ) + return r.headers['x-subject-token'] + + def test_create_trust_with_unrestricted_application_credential(self): + """Unrestricted app cred must also be blocked from creating trusts.""" + trust_body = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + role_ids=[self.role_id], + ) + self.post( + '/OS-TRUST/trusts', + body={'trust': trust_body}, + token=self._get_app_cred_token(unrestricted=True), + expected_status=http.client.FORBIDDEN, + ) + + def test_list_trusts_with_application_credential(self): + """App cred token must not be able to list trusts.""" + self.get( + '/OS-TRUST/trusts', + token=self._get_app_cred_token(), + expected_status=http.client.FORBIDDEN, + ) + + def test_get_trust_with_application_credential(self): + """App cred token must not be able to read a specific trust.""" + ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + role_ids=[self.role_id], + ) + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust_id = r.result['trust']['id'] + self.get( + f'/OS-TRUST/trusts/{trust_id}', + token=self._get_app_cred_token(), + expected_status=http.client.FORBIDDEN, + ) + + def test_list_trust_roles_with_application_credential(self): + """App cred token must not be able to list roles for a trust.""" + ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + role_ids=[self.role_id], + ) + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust_id = r.result['trust']['id'] + self.get( + f'/OS-TRUST/trusts/{trust_id}/roles', + token=self._get_app_cred_token(), + expected_status=http.client.FORBIDDEN, + ) + + def test_get_trust_role_with_application_credential(self): + """App cred token must not be able to get a specific trust role.""" + ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, + project_id=self.project_id, + role_ids=[self.role_id], + ) + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust_id = r.result['trust']['id'] + self.get( + f'/OS-TRUST/trusts/{trust_id}/roles/{self.role_id}', + token=self._get_app_cred_token(), + expected_status=http.client.FORBIDDEN, + ) + def test_delete_trust_with_application_credential(self): ref = unit.new_trust_ref( trustor_user_id=self.user_id, From d19a8019319c915967fa8a7307e8712590efa9be Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Tue, 12 May 2026 09:11:24 +0200 Subject: [PATCH 25/26] Preserve expires_at when rescoping federated tokens (CVE-2026-44394) When a federated token is rescoped via POST /v3/auth/tokens the handle_scoped_token function returned response_data without an expires_at value. Because issue_token falls back to default_expire_time when expires_at is None, each rescope issued a fresh full-TTL token instead of inheriting the remaining lifetime of the original token. A user with a federated token could extend their session indefinitely by rescoping repeatedly before expiry, bypassing operator-configured TTL policies and IdP-level account revocation. Fix: propagate token.expires_at from handle_scoped_token so that issue_token uses the original token's expiry rather than resetting to the default. The non-federated path in token.py already did this via response_data.setdefault('expires_at', token.expires_at). Closes-Bug: #2150379 Assisted-by: Claude Sonnet 4.6 Co-authored-by: Artem Goncharov Change-Id: I0bbb8520e12c52edd01fb47c873f0227819706f5 Signed-off-by: Grzegorz Grasza (cherry picked from commit 75a4a0c354c7f568b28dd85182dc729553fb3a33) (cherry picked from commit 7846c2375f72e1f5ad2f797241d3e6ca2e09d8b1) --- keystone/auth/plugins/mapped.py | 4 + .../tests/unit/auth/plugins/test_mapped.py | 120 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/keystone/auth/plugins/mapped.py b/keystone/auth/plugins/mapped.py index 23ba337246..0bd9cd304f 100644 --- a/keystone/auth/plugins/mapped.py +++ b/keystone/auth/plugins/mapped.py @@ -102,6 +102,10 @@ def handle_scoped_token(token, federation_api, identity_api): response_data['group_ids'] = group_ids response_data[federation_constants.IDENTITY_PROVIDER] = identity_provider response_data[federation_constants.PROTOCOL] = protocol + # Preserve the original token's expiry to prevent users from + # indefinitely extending their session by repeatedly rescoping. + # The non-federated path in token.py does the same via setdefault(). + response_data['expires_at'] = token.expires_at return response_data diff --git a/keystone/tests/unit/auth/plugins/test_mapped.py b/keystone/tests/unit/auth/plugins/test_mapped.py index a79bdaa6c7..0a1cec8abf 100644 --- a/keystone/tests/unit/auth/plugins/test_mapped.py +++ b/keystone/tests/unit/auth/plugins/test_mapped.py @@ -16,6 +16,7 @@ from keystone.assignment.core import Manager as AssignmentApi from keystone.auth.plugins import mapped from keystone.exception import ProjectNotFound +from keystone.federation import constants as federation_constants from keystone.resource.core import Manager as ResourceApi from keystone.tests import unit @@ -150,3 +151,122 @@ def create_project_mock_for_shadow_project(self, shadow_project): project = shadow_project.copy() project['id'] = uuid.uuid4().hex return project + + def _make_federated_token_mock(self, expires_at): + token = mock.Mock() + token.audit_id = uuid.uuid4().hex + token.user_id = uuid.uuid4().hex + token.identity_provider_id = 'test-idp' + token.protocol_id = 'mapped' + token.federated_groups = [{'id': uuid.uuid4().hex}] + token.expires_at = expires_at + return token + + +class TestHandleScopedToken(unit.TestCase): + """Tests for the handle_scoped_token security fix. + + Verify that rescoping a federated token preserves the original + token's expires_at rather than falling back to a fresh TTL. + Without the fix, an attacker can extend their session indefinitely + by rescoping before expiry, bypassing IdP-level account revocation. + """ + + def setUp(self): + super().setUp() + self.federation_api = mock.Mock() + self.identity_api = mock.Mock() + mapping_ref = {'id': uuid.uuid4().hex} + self.federation_api.get_mapping_from_idp_and_protocol.return_value = ( + mapping_ref + ) + + @mock.patch( + 'keystone.auth.plugins.mapped.notifications' + '.send_saml_audit_notification', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.validate_mapped_group_ids', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.assert_enabled_identity_provider', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.validate_expiration', autospec=True + ) + def test_handle_scoped_token_preserves_expires_at( + self, + mock_validate_exp, + mock_assert_idp, + mock_validate_groups, + mock_notify, + ): + """Rescoped federated token must inherit original expiry (not fresh TTL). + + This is the security regression test for the authentication expiry + bypass vulnerability: handle_scoped_token must include expires_at in + the returned response_data so that issue_token() does not fall back to + default_expire_time(). + """ + original_expiry = '2026-04-26T08:59:30.000000Z' + token = self._make_federated_token_mock(original_expiry) + + result = mapped.handle_scoped_token( + token, self.federation_api, self.identity_api + ) + + self.assertIn('expires_at', result) + self.assertEqual(original_expiry, result['expires_at']) + + @mock.patch( + 'keystone.auth.plugins.mapped.notifications' + '.send_saml_audit_notification', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.validate_mapped_group_ids', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.assert_enabled_identity_provider', + autospec=True, + ) + @mock.patch( + 'keystone.auth.plugins.mapped.utils.validate_expiration', autospec=True + ) + def test_handle_scoped_token_returns_federation_metadata( + self, + mock_validate_exp, + mock_assert_idp, + mock_validate_groups, + mock_notify, + ): + """Rescoped federated token still returns all required federation data.""" + token = self._make_federated_token_mock('2026-04-26T08:59:30.000000Z') + + result = mapped.handle_scoped_token( + token, self.federation_api, self.identity_api + ) + + self.assertEqual(token.user_id, result['user_id']) + self.assertEqual( + token.identity_provider_id, + result[federation_constants.IDENTITY_PROVIDER], + ) + self.assertEqual( + token.protocol_id, result[federation_constants.PROTOCOL] + ) + self.assertIsInstance(result['group_ids'], list) + + def _make_federated_token_mock(self, expires_at): + token = mock.Mock() + token.audit_id = uuid.uuid4().hex + token.user_id = uuid.uuid4().hex + token.identity_provider_id = 'test-idp' + token.protocol_id = 'mapped' + token.federated_groups = [{'id': uuid.uuid4().hex}] + token.expires_at = expires_at + return token From 2a6a065327c4a35244f848c36562db0588755e2c Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Tue, 12 May 2026 09:10:20 +0200 Subject: [PATCH 26/26] Prevent RBAC policy bypass via JSON body and query filters (CVE-2026-42999) The RBAC enforcer unconditionally merged the raw JSON request body into the policy enforcement dictionary after trusted target data had been set from the database. An attacker could include a "target" key in the JSON body to overwrite database-sourced RBAC target attributes, causing all %(target.*)s policy substitutions to evaluate against attacker-controlled values. This affected 88 endpoint/method combinations across all Keystone API resources. The fix namespaces user-controlled JSON body data under a "request_body" key in the policy dict, making it structurally impossible for request body fields to collide with internal keys like "target" or view_args. The only in-tree policy rule that depended on the old JSON body merge behavior was identity:create_trust, which referenced %(trust.trustor_user_id)s from the request body at the top level of the policy dict. This is updated to use target_attr and the %(target.trust.trustor_user_id)s substitution, consistent with all other trust policy rules. Additionally, query-string filter values had the same structural issue: _extract_filter_values() results were merged at the top level, meaning a filter key matching a view_arg key (e.g. user_id on /v3/users/{user_id}/... endpoints using ADMIN_OR_SYSTEM_READER_OR_OWNER) could be overwritten by an attacker-controlled ?user_id= query param, bypassing ownership checks. Filter values are now namespaced under "filter_attr". No in-tree policy rule references filter values via %(key)s substitutions, so this is backwards-compatible for upstream deployments. Closes-Bug: #2148398 Assisted-by: Claude Sonnet 4.6 Co-authored-by: Boris Bobrov Co-authored-by: Artem Goncharov Change-Id: I295d1ac27faad05a680bb2b3fac8cfa27fa1c4bd Signed-off-by: Grzegorz Grasza (cherry picked from commit 22b51f5d5d86350d3fbc66697e4097bacf2a8ce9) (cherry picked from commit 2743acbcbac8190ae24f3c3e85149dc68b7f2254) --- keystone/api/trusts.py | 4 +- keystone/common/policies/base.py | 2 +- keystone/common/rbac_enforcer/enforcer.py | 21 ++- .../tests/protection/v3/test_credentials.py | 120 ++++++++++++ keystone/tests/protection/v3/test_grants.py | 77 ++++++++ .../tests/unit/common/test_rbac_enforcer.py | 175 +++++++++++++++++- .../notes/bug-2148398-e35dd449b3a330e6.yaml | 40 ++++ 7 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 releasenotes/notes/bug-2148398-e35dd449b3a330e6.yaml diff --git a/keystone/api/trusts.py b/keystone/api/trusts.py index a7d06561a6..ab2aa9a7a2 100644 --- a/keystone/api/trusts.py +++ b/keystone/api/trusts.py @@ -296,8 +296,10 @@ def post(self): The User creating the trust must be the trustor. """ - ENFORCER.enforce_call(action='identity:create_trust') trust = self.request_body_json.get('trust', {}) + ENFORCER.enforce_call( + action='identity:create_trust', target_attr={'trust': trust} + ) validation.lazy_validate(schema.trust_create, trust) self._check_unrestricted() diff --git a/keystone/common/policies/base.py b/keystone/common/policies/base.py index 73a2ca26f0..aa444733eb 100644 --- a/keystone/common/policies/base.py +++ b/keystone/common/policies/base.py @@ -30,7 +30,7 @@ RULE_SERVICE_ADMIN_OR_TOKEN_SUBJECT = ( 'rule:service_admin_or_token_subject') # nosec RULE_SERVICE_OR_ADMIN = 'rule:service_or_admin' -RULE_TRUST_OWNER = 'user_id:%(trust.trustor_user_id)s' +RULE_TRUST_OWNER = 'user_id:%(target.trust.trustor_user_id)s' # We are explicitly setting system_scope:all in these check strings because # they provide backwards compatibility in the event a deployment sets diff --git a/keystone/common/rbac_enforcer/enforcer.py b/keystone/common/rbac_enforcer/enforcer.py index 7add048ce8..aa9797f610 100644 --- a/keystone/common/rbac_enforcer/enforcer.py +++ b/keystone/common/rbac_enforcer/enforcer.py @@ -430,14 +430,21 @@ def enforce_call(cls, enforcer=None, action=None, target_attr=None, policy_dict['target'] = target_attr or build_target() - # Pull the data from the submitted json body to generate - # appropriate input/target attributes, we take an explicit copy here - # to ensure we're not somehow corrupting + # Pull the data from the submitted json body. We namespace it under + # 'request_body' to prevent user-controlled input from overwriting + # security-critical keys in the policy dict (e.g. 'target' populated + # by build_target/target_attr, or view_args like 'user_id'). json_input = flask.request.get_json(force=True, silent=True) or {} - policy_dict.update(json_input.copy()) - - # Generate the filter_attr dataset. - policy_dict.update(cls._extract_filter_values(filters)) + if json_input: + policy_dict['request_body'] = json_input.copy() + + # Namespace query-string filter values under 'filter_attr' to prevent + # attacker-controlled query params from overwriting view_args (e.g. + # user_id from /v3/users/{user_id}/...) or other trusted keys in the + # policy dict via %(key)s substitutions in policy rules. + filter_values = cls._extract_filter_values(filters) + if filter_values: + policy_dict['filter_attr'] = filter_values flattened = utils.flatten_dict(policy_dict) if LOG.logger.getEffectiveLevel() <= log.DEBUG: diff --git a/keystone/tests/protection/v3/test_credentials.py b/keystone/tests/protection/v3/test_credentials.py index 5a1960e38e..a08ba033b1 100644 --- a/keystone/tests/protection/v3/test_credentials.py +++ b/keystone/tests/protection/v3/test_credentials.py @@ -18,11 +18,13 @@ from keystone.common.policies import base as bp from keystone.common import provider_api import keystone.conf +from keystone.credential.providers import fernet as credential_fernet 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 +from keystone.tests.unit import test_v3 CONF = keystone.conf.CONF PROVIDERS = provider_api.ProviderAPIs @@ -1252,3 +1254,121 @@ def setUp(self): 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 TargetInjectionCredentialTests(test_v3.RestfulTestCase): + """Test that JSON body injection cannot bypass credential RBAC. + + Verifies CVE-2026-42999: the RBAC enforcer must not allow the JSON + request body to overwrite security-critical keys in the policy dict. + """ + + def setUp(self): + super().setUp() + self.useFixture( + ksfixtures.KeyRepository( + self.config_fixture, + 'credential', + credential_fernet.MAX_ACTIVE_KEYS, + ) + ) + + def _make_user_with_project(self, role_id=None): + user = unit.create_user( + PROVIDERS.identity_api, domain_id=self.domain_id + ) + project = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project(project['id'], project) + if role_id: + PROVIDERS.assignment_api.add_role_to_user_and_project( + user['id'], project['id'], role_id + ) + return user, project + + def test_list_credentials_cannot_read_other_users_secrets(self): + """GET /v3/credentials must not return other users' credentials. + + An attacker injects their own user_id into target.credential.user_id + in the JSON body. Without the fix the per-item policy filter would + see the attacker's user_id and pass every credential through. + """ + role = unit.new_role_ref() + PROVIDERS.role_api.create_role(role['id'], role) + + victim, victim_project = self._make_user_with_project(role['id']) + attacker, attacker_project = self._make_user_with_project(role['id']) + + victim_cred = unit.new_credential_ref( + user_id=victim['id'], project_id=victim_project['id'] + ) + PROVIDERS.credential_api.create_credential( + victim_cred['id'], victim_cred + ) + attacker_cred = unit.new_credential_ref( + user_id=attacker['id'], project_id=attacker_project['id'] + ) + PROVIDERS.credential_api.create_credential( + attacker_cred['id'], attacker_cred + ) + + attacker_auth = self.build_authentication_request( + user_id=attacker['id'], + password=attacker['password'], + project_id=attacker_project['id'], + ) + r = self.get( + '/credentials', + auth=attacker_auth, + body={'target': {'credential': {'user_id': attacker['id']}}}, + ) + + cred_ids = [c['id'] for c in r.result['credentials']] + self.assertIn(attacker_cred['id'], cred_ids) + self.assertNotIn(victim_cred['id'], cred_ids) + + def test_ec2_create_credential_cannot_create_for_other_user(self): + """EC2 credential creation must not allow impersonating other users. + + POST /v3/users/{user_id}/credentials/OS-EC2: the attacker injects + target.credential.user_id to bypass the ownership check. + """ + member_role = unit.new_role_ref(name='member') + PROVIDERS.role_api.create_role(member_role['id'], member_role) + + victim, victim_project = self._make_user_with_project( + member_role['id'] + ) + attacker, attacker_project = self._make_user_with_project( + member_role['id'] + ) + + attacker_auth = self.build_authentication_request( + user_id=attacker['id'], + password=attacker['password'], + project_id=attacker_project['id'], + ) + ec2_uri = f'/users/{victim["id"]}/credentials/OS-EC2' + + self.post( + ec2_uri, + auth=attacker_auth, + body={ + 'tenant_id': victim_project['id'], + 'target': {'credential': {'user_id': attacker['id']}}, + }, + expected_status=http.client.FORBIDDEN, + ) + self.post( + ec2_uri, + auth=attacker_auth, + body={ + 'tenant_id': victim_project['id'], + 'target': { + 'credential': { + 'user_id': attacker['id'], + 'project_id': attacker_project['id'], + } + }, + }, + expected_status=http.client.FORBIDDEN, + ) diff --git a/keystone/tests/protection/v3/test_grants.py b/keystone/tests/protection/v3/test_grants.py index bb74b09014..b4cbf45891 100644 --- a/keystone/tests/protection/v3/test_grants.py +++ b/keystone/tests/protection/v3/test_grants.py @@ -23,6 +23,7 @@ from keystone.tests.unit import base_classes from keystone.tests.unit import ksfixtures from keystone.tests.unit.ksfixtures import temporaryfile +from keystone.tests.unit import test_v3 CONF = keystone.conf.CONF PROVIDERS = provider_api.ProviderAPIs @@ -2275,3 +2276,79 @@ def test_cannot_revoke_grant_from_group_on_domain(self): headers=self.headers, expected_status_code=http.client.FORBIDDEN ) + + +class TargetInjectionGrantTests(test_v3.RestfulTestCase): + """Test that JSON body injection cannot bypass grant RBAC. + + Verifies CVE-2026-42999: the RBAC enforcer must not allow the JSON + request body to overwrite security-critical keys in the policy dict. + """ + + def setUp(self): + super().setUp() + policy_file = self.useFixture(temporaryfile.SecureTempFile()) + self.useFixture( + ksfixtures.Policy( + self.config_fixture, policy_file=policy_file.file_name + ) + ) + with open(policy_file.file_name, 'w') as f: + overrides = { + 'identity:create_grant': ( + '(role:admin and system_scope:all) or ' + '(role:admin and ' + 'domain_id:%(target.user.domain_id)s and ' + 'domain_id:%(target.domain.id)s) and ' + '(domain_id:%(target.role.domain_id)s or ' + 'None:%(target.role.domain_id)s)' + ) + } + f.write(jsonutils.dumps(overrides)) + + def test_inherited_grant_cannot_escalate_cross_domain(self): + """PUT OS-INHERIT grant must not allow cross-domain escalation. + + A domain admin in domain A tries to create an inherited admin role + grant on domain B by injecting target data. Without the fix the + policy would see all domains matching the attacker's domain. + """ + domain_a = unit.new_domain_ref() + PROVIDERS.resource_api.create_domain(domain_a['id'], domain_a) + attacker = unit.create_user( + PROVIDERS.identity_api, domain_id=domain_a['id'] + ) + admin_role = self.role + PROVIDERS.assignment_api.create_grant( + admin_role['id'], user_id=attacker['id'], domain_id=domain_a['id'] + ) + + domain_b = unit.new_domain_ref() + PROVIDERS.resource_api.create_domain(domain_b['id'], domain_b) + + attacker_auth = self.build_authentication_request( + user_id=attacker['id'], + password=attacker['password'], + domain_id=domain_a['id'], + ) + + inherit_url = ( + '/OS-INHERIT/domains/{domain_id}/users/{user_id}' + '/roles/{role_id}/inherited_to_projects' + ).format( + domain_id=domain_b['id'], + user_id=attacker['id'], + role_id=admin_role['id'], + ) + self.put( + inherit_url, + auth=attacker_auth, + body={ + 'target': { + 'user': {'domain_id': domain_a['id']}, + 'domain': {'id': domain_a['id']}, + 'role': {'domain_id': None, 'name': 'member'}, + } + }, + expected_status=http.client.FORBIDDEN, + ) diff --git a/keystone/tests/unit/common/test_rbac_enforcer.py b/keystone/tests/unit/common/test_rbac_enforcer.py index b235eb29d3..b83bbcb724 100644 --- a/keystone/tests/unit/common/test_rbac_enforcer.py +++ b/keystone/tests/unit/common/test_rbac_enforcer.py @@ -176,7 +176,7 @@ def _testing_policy_rules(self): ), policy.RuleDefault( name='example:with_filter', - check_str='user_id:%(user)s', + check_str='user_id:%(filter_attr.user)s', scope_types=['project'], ), policy.RuleDefault( @@ -423,6 +423,135 @@ def test_extract_member_target_data_bad_input(self): self.assertEqual({}, self.enforcer._extract_member_target_data( member_target={}, member_target_type=None)) + def test_json_body_cannot_overwrite_build_target(self): + # Verify that a JSON request body cannot overwrite the target + # data populated by build_target. The enforcer must namespace + # user-controlled JSON input so it cannot collide with the + # trusted 'target' key set from the database. + assertIn = self.assertIn + assertEq = self.assertEqual + + real_owner_id = uuid.uuid4().hex + attacker_id = uuid.uuid4().hex + + def _enforce_mock_func(credentials, action, target, do_raise=True): + assertIn('target.credential.user_id', target) + assertEq(target['target.credential.user_id'], real_owner_id) + + def _build_target(): + return {'credential': {'user_id': real_owner_id}} + + self.useFixture( + fixtures.MockPatchObject( + self.enforcer, '_enforce', _enforce_mock_func + ) + ) + + with self.test_client() as c: + path = '/v3/auth/tokens' + body = self._auth_json() + r = c.post( + path, + json=body, + follow_redirects=True, + expected_status_code=201, + ) + token_id = r.headers['X-Subject-Token'] + + # Send a request with a JSON body that attempts to + # overwrite the build_target-supplied credential owner. + c.get( + f'{self.restful_api_url_prefix}/argument/{uuid.uuid4().hex}', + headers={'X-Auth-Token': token_id}, + json={'target': {'credential': {'user_id': attacker_id}}}, + ) + self.enforcer.enforce_call( + action='example:allowed', build_target=_build_target + ) + + def test_json_body_cannot_overwrite_target_attr(self): + # Verify that a JSON request body cannot overwrite the target + # data populated by target_attr. The enforcer must namespace + # user-controlled JSON input so it cannot collide with the + # trusted 'target' key set explicitly by the API handler. + assertIn = self.assertIn + assertEq = self.assertEqual + + real_owner_id = uuid.uuid4().hex + attacker_id = uuid.uuid4().hex + + def _enforce_mock_func(credentials, action, target, do_raise=True): + assertIn('target.credential.user_id', target) + assertEq(target['target.credential.user_id'], real_owner_id) + + self.useFixture( + fixtures.MockPatchObject( + self.enforcer, '_enforce', _enforce_mock_func + ) + ) + + with self.test_client() as c: + path = '/v3/auth/tokens' + body = self._auth_json() + r = c.post( + path, + json=body, + follow_redirects=True, + expected_status_code=201, + ) + token_id = r.headers['X-Subject-Token'] + + c.get( + f'{self.restful_api_url_prefix}/argument/{uuid.uuid4().hex}', + headers={'X-Auth-Token': token_id}, + json={'target': {'credential': {'user_id': attacker_id}}}, + ) + target_attr = {'credential': {'user_id': real_owner_id}} + self.enforcer.enforce_call( + action='example:allowed', target_attr=target_attr + ) + + def test_json_body_cannot_overwrite_view_args(self): + # Verify that a JSON request body cannot overwrite URL path + # parameters (view_args) in the policy dict. The enforcer must + # namespace user-controlled JSON input so it cannot collide + # with trusted view_args like 'user_id' or 'argument_id'. + assertIn = self.assertIn + assertEq = self.assertEqual + + real_argument_id = uuid.uuid4().hex + injected_argument_id = uuid.uuid4().hex + + def _enforce_mock_func(credentials, action, target, do_raise=True): + assertIn('argument_id', target) + assertEq(target['argument_id'], real_argument_id) + + self.useFixture( + fixtures.MockPatchObject( + self.enforcer, '_enforce', _enforce_mock_func + ) + ) + + with self.test_client() as c: + path = '/v3/auth/tokens' + body = self._auth_json() + r = c.post( + path, + json=body, + follow_redirects=True, + expected_status_code=201, + ) + token_id = r.headers['X-Subject-Token'] + + # URL has argument_id=real_argument_id, but the JSON body + # tries to overwrite it. + c.get( + (f'{self.restful_api_url_prefix}/argument/{real_argument_id}'), + headers={'X-Auth-Token': token_id}, + json={'argument_id': injected_argument_id}, + ) + self.enforcer.enforce_call(action='example:allowed') + def test_call_build_enforcement_target(self): assertIn = self.assertIn assertEq = self.assertEqual @@ -638,6 +767,50 @@ def test_enforce_call_with_filter_values(self): self.enforcer.enforce_call, action='example:with_filter') + def test_query_filter_cannot_overwrite_view_args(self): + """Query-string filter values must not overwrite view_args in policy dict. + + Before the fix, policy_dict.update(filter_values) ran after + policy_dict.update(view_args). If a filter key matched a view_arg key + (e.g. both named 'user_id'), a ?user_id=attacker query param would + overwrite the URL-path-sourced value used in %(user_id)s policy + substitutions, bypassing ownership checks such as + ADMIN_OR_SYSTEM_READER_OR_OWNER on /v3/users/{user_id}/... endpoints. + """ + real_arg_id = uuid.uuid4().hex + injected_arg_id = uuid.uuid4().hex + seen = {} + + def _capture_enforce(credentials, action, target, do_raise=True): + seen.update(target) + + self.useFixture( + fixtures.MockPatchObject( + self.enforcer, '_enforce', _capture_enforce + ) + ) + + with self.test_client() as c: + r = c.post( + '/v3/auth/tokens', + json=self._auth_json(), + expected_status_code=201, + ) + token_id = r.headers['X-Subject-Token'] + c.get( + f'{self.restful_api_url_prefix}/argument/{real_arg_id}' + f'?argument_id={injected_arg_id}', + headers={'X-Auth-Token': token_id}, + ) + self.enforcer.enforce_call( + action='example:allowed', filters=['argument_id'] + ) + + # view_arg survives: argument_id at the top level is real_arg_id + self.assertEqual(real_arg_id, seen.get('argument_id')) + # filter value is namespaced, not overwriting view_arg + self.assertEqual(injected_arg_id, seen.get('filter_attr.argument_id')) + def test_enforce_call_with_pre_instantiated_enforcer(self): token_path = '/v3/auth/tokens' auth_json = self._auth_json() diff --git a/releasenotes/notes/bug-2148398-e35dd449b3a330e6.yaml b/releasenotes/notes/bug-2148398-e35dd449b3a330e6.yaml new file mode 100644 index 0000000000..1685cc04f8 --- /dev/null +++ b/releasenotes/notes/bug-2148398-e35dd449b3a330e6.yaml @@ -0,0 +1,40 @@ +--- +critical: + - | + [`bug 2148398 `_] + The RBAC enforcer unconditionally merged the raw JSON request body into + the policy enforcement dictionary after trusted target data had been set + from the database. An attacker could include a ``target`` key in the JSON + body to overwrite database-sourced RBAC target attributes, causing all + ``%(target.*)s`` policy substitutions to evaluate against + attacker-controlled values. This affected 88 endpoint/method combinations + across all Keystone API resource areas. Any authenticated user could + exploit this to read every credential secret in the deployment, create + EC2 credentials for arbitrary users, or revoke other users' tokens. A + domain administrator could escalate to full cloud admin by creating + inherited role grants on other domains. The vulnerability has been present + since the Rocky release (14.0.0). +security: + - | + [`bug 2148398 `_] + The RBAC policy enforcer now namespaces JSON request body data under a + ``request_body`` key in the policy dictionary instead of merging it at + the top level. This prevents user-controlled input from overwriting + security-critical keys such as ``target`` (populated from the database + by ``build_target`` or ``target_attr``) and URL path parameters like + ``user_id``. All upstream policy rules are unaffected by this change. + Deployments with custom policy rules that reference JSON body fields + directly via ``%(field_name)s`` substitutions (not under ``target.``) + will need to update those references to ``%(request_body.field_name)s``. +upgrade: + - | + [`bug 2148398 `_] + The ``identity:create_trust`` policy rule now uses + ``%(target.trust.trustor_user_id)s`` instead of + ``%(trust.trustor_user_id)s``. The trust data from the request body is + now passed explicitly via ``target_attr`` rather than relying on the + JSON body merge. This aligns ``create_trust`` with all other trust + policy rules which already use the ``target.trust.*`` prefix. + Deployments that override the ``identity:create_trust`` policy and + reference ``%(trust.trustor_user_id)s`` must update to + ``%(target.trust.trustor_user_id)s``.