Version in base suite: 27.0.0-3+deb13u4 Base version: keystone_27.0.0-3+deb13u4 Target version: keystone_27.0.0-3+deb13u5 Base file: /srv/ftp-master.debian.org/ftp/pool/main/k/keystone/keystone_27.0.0-3+deb13u4.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/k/keystone/keystone_27.0.0-3+deb13u5.dsc changelog | 33 patches/CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch | 140 + patches/CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch | 173 + patches/CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch | 1217 ++++++++++ patches/CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch | 330 ++ patches/CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch | 107 patches/series | 5 7 files changed, 2005 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpsfc5_4is/keystone_27.0.0-3+deb13u4.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpsfc5_4is/keystone_27.0.0-3+deb13u5.dsc: no acceptable signature found diff -Nru keystone-27.0.0/debian/changelog keystone-27.0.0/debian/changelog --- keystone-27.0.0/debian/changelog 2026-05-25 14:39:48.000000000 +0000 +++ keystone-27.0.0/debian/changelog 2026-08-28 07:41:35.000000000 +0000 @@ -1,3 +1,36 @@ +keystone (2:27.0.0-3+deb13u5) trixie-security; urgency=medium + + * CVE-2026-80184: Delegation bypass in trust, OAuth1, and application + credential operations. + * CVE-2026-80182: Tokens obtained via application credential or EC2 + credential authentication can escape their intended project scope through + token-method reauthentication. An application-credential token scoped to + one project can be exchanged via POST /v3/auth/tokens with no explicit + scope, causing Keystone to issue a new token scoped to the owner's default + project. For EC2-derived tokens the bypass is broader: because they carry + no delegation markers, they can rescope to any project where the underlying + user has role assignments. + * Add new patches (Closes: #1145669): + - CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_resco....patch + - CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oa....patch + - CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_d....patch + - CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_fro....patch + * CVE-2026-80183 / OSSN-2026-0XXX: any authenticated user holding role:reader + on any project can list every project-scoped role assignment under any + domain by passing a domain ID as scope.project.id with include_subtree to + the GET /v3/role_assignments endpoint. The domain's project record has + domain_id=null, causing the policy domain_id check to pass for any caller. + With include_names, the response discloses the names and home-domain IDs of + every user, group, project, and role involved. The literal "default" domain + ID works against any deployment created with keystone-manage bootstrap. An + attacker can harvest domain IDs from the response and repeat the query to + map role assignments across the entire cloud. This is caused by misuse of + "None" in list_role_assignments_for_tree. + Applied upstream patch (Closes: #1145816): + - CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch + + -- Thomas Goirand Fri, 28 Aug 2026 09:41:35 +0200 + keystone (2:27.0.0-3+deb13u4) trixie-security; urgency=medium * Multiple vulnerabilities in Keystone's delegated authentication allow an diff -Nru keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch --- keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch 2026-08-28 07:41:35.000000000 +0000 @@ -0,0 +1,140 @@ +From 910ebab06db3b9c119a3139b9443f7bd2369158f Mon Sep 17 00:00:00 2001 +From: Artem Goncharov +Date: Wed, 15 Jul 2026 13:08:10 +0200 +Subject: [PATCH] Ban ec2credential tokens from Keystone API + +EC2 credentials are supported by Keystone only to allow Swift to +implement S3 protocol while using Keystone identities. There are no real +reasons for ec2credential fernet tokens (after authenticating) being +accepted by the Keystone itself. There are many barriers where +Keystone explicitly checks and bans such credentials to prevent +rescoping or accessing resources belonging to other projects. There +have been multiple security issues due to missing guards. Instead of +those individual barriers, simply bar such tokens from any Keystone +operation except validating the token (which Swift needs to do) and +re-authenticating (renewing the token). The latter is questionable on +its own, but is kept for now. + +Solve the problem by simply rejecting the ec2credential issued token +in the auth middleware which runs before any authenticated request. +The /auth/tokens are unauthenticated and as such are not affected. + +Change-Id: I4dd5817d20394369cfadd20ccf476a0660760c52 +Signed-off-by: Artem Goncharov +--- + +Index: keystone/keystone/server/flask/request_processing/middleware/auth_context.py +=================================================================== +--- keystone.orig/keystone/server/flask/request_processing/middleware/auth_context.py ++++ keystone/keystone/server/flask/request_processing/middleware/auth_context.py +@@ -453,6 +453,13 @@ class AuthContextMiddleware( + 'token': self.token, + } + auth_context.update(additional) ++ if 'ec2credential' in self.token.methods: ++ raise exception.Forbidden( ++ _( ++ 'EC2 credential tokens cannot be used for ' ++ 'authorization.' ++ ) ++ ) + + elif self._validate_trusted_issuer(request): + auth_context = self._build_tokenless_auth_context(request) +Index: keystone/keystone/tests/unit/test_contrib_ec2_core.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_contrib_ec2_core.py ++++ keystone/keystone/tests/unit/test_contrib_ec2_core.py +@@ -19,6 +19,7 @@ import http.client + from keystoneclient.contrib.ec2 import utils as ec2_utils + from oslo_utils import timeutils + ++from keystone.common import authorization + from keystone.common import provider_api + from keystone.common import utils + from keystone.tests import unit +@@ -235,3 +236,84 @@ class EC2ContribCoreV3(test_v3.RestfulTe + body={'credentials': credentials}, + expected_status=http.client.UNAUTHORIZED, + ) ++ ++ def test_valid_ec2_token_invalid_for_regular_endpoints(self, **kwargs): ++ signer = ec2_utils.Ec2Signer(self.cred_blob['secret']) ++ timestamp = utils.isotime(timeutils.utcnow()) ++ credentials = { ++ 'access': self.cred_blob['access'], ++ 'secret': self.cred_blob['secret'], ++ 'host': 'localhost', ++ 'verb': 'GET', ++ 'path': '/', ++ 'params': { ++ 'SignatureVersion': '2', ++ 'Action': 'Test', ++ 'Timestamp': timestamp, ++ }, ++ } ++ 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() ++ ++ resp = self.post( ++ '/ec2tokens', ++ body={'credentials': credentials}, ++ expected_status=http.client.OK, ++ token=token, ++ noauth=kwargs.get('noauth'), ++ ) ++ self.assertValidProjectScopedTokenResponse(resp, self.user) ++ ++ # Extract the EC2 credential token from the response - the password ++ # token used for POST /ec2tokens is NOT the EC2 token. ++ ec2_token = resp.headers['X-Subject-Token'] ++ self.assertEqual(['ec2credential'], resp.json['token']['methods']) ++ ++ # The EC2 token should be rejected by regular endpoints (check subset ++ # the user should normally be able to query). ++ self.get( ++ '/users', token=ec2_token, expected_status=http.client.FORBIDDEN ++ ) ++ self.get( ++ f"/users/{self.user_id}", ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.get( ++ '/auth/projects', ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ # The EC2 token should be still validated. ++ self.get( ++ '/auth/tokens', ++ headers={"X-Subject-Token": ec2_token}, ++ token=token, ++ expected_status=http.client.OK, ++ ) ++ # Test reauth is also working ++ resp = self.post( ++ '/auth/tokens', ++ headers={"X-Subject-Token": ec2_token}, ++ body={ ++ "auth": { ++ "identity": { ++ "methods": ["token"], ++ "token": {"id": ec2_token}, ++ } ++ } ++ }, ++ token=token, ++ expected_status=http.client.CREATED, ++ ) ++ ec2_token = resp.headers['X-Subject-Token'] ++ self.assertIn('ec2credential', resp.json['token']['methods']) diff -Nru keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch --- keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch 2026-08-28 07:41:35.000000000 +0000 @@ -0,0 +1,173 @@ +From dbb5c174705153f23f93d143c951a2008c9912b6 Mon Sep 17 00:00:00 2001 +From: Benjamin Pinchon +Date: Tue, 25 Aug 2026 15:20:12 +0200 +Subject: [PATCH] auth: encode ec2credential and oauth2_credential in the method bitmask + +convert_method_list_to_integer() builds its map from CONF.auth.methods, +so a method name absent from that list is silently dropped and the +result is 0, which convert_integer_to_method_list() turns back into +an empty list. + +'ec2credential' (keystone.api._shared.EC2_S3_Resource) and +'oauth2_credential' (keystone.api.os_oauth2) are written into +token.methods by endpoints that are not auth plugins, so they cannot +be listed in CONF.auth.methods: load_auth_methods() would look for a +keystone.auth. entry point that does not exist, and the name +would become client-requestable on POST /v3/auth/tokens. Neither has +ever survived the fernet round-trip. + +Master already ships the EC2 API ban (910ebab06) which rejects +tokens with 'ec2credential' in token.methods. Without this round-trip +fix that check never sees the method on a fernet token, so the ban +is a no-op for issued tokens. + +Reserve fixed high bits for both so they round-trip. High and fixed +means they never collide with the sequential indexes and adding one +never shifts an existing index, so tokens in circulation stay +decodable. + +This does not fix tokens already issued: they carry the integer 0 +and still decode to an empty list. + +Change-Id: I41c2d2dfe6e594ddff8971b2178d278e569f0067 +Assisted-by: Cursor Grok 4.6 +Signed-off-by: Grzegorz Grasza +--- + +diff --git a/keystone/auth/plugins/core.py b/keystone/auth/plugins/core.py +index 475c9cb..f5d133c 100644 +--- a/keystone/auth/plugins/core.py ++++ b/keystone/auth/plugins/core.py +@@ -30,6 +30,16 @@ + _NOTIFY_EVENT = f'{notifications.SERVICE}.{_NOTIFY_OP}' + + ++# Method names written into token.methods by endpoints that are not auth ++# plugins, so they cannot be listed in CONF.auth.methods: 'ec2credential' ++# (keystone.api._shared.EC2_S3_Resource) and 'oauth2_credential' ++# (keystone.api.os_oauth2). Without a bit of their own they encode to 0 and ++# decode back to [], losing the method list on the fernet round-trip. The bits ++# are fixed and high so they never collide with the sequential indexes below, ++# and adding one never shifts an existing index. ++_PSEUDO_METHOD_BITS = {1 << 32: 'ec2credential', 1 << 33: 'oauth2_credential'} ++ ++ + def construct_method_map_from_config(): + """Determine authentication method types for deployment. + +@@ -42,6 +52,8 @@ + method_map[method_index] = method + method_index = method_index * 2 + ++ method_map.update(_PSEUDO_METHOD_BITS) ++ + return method_map + + +diff --git a/keystone/tests/unit/auth/plugins/test_core.py b/keystone/tests/unit/auth/plugins/test_core.py +index 81a4779..09ca6d0 100644 +--- a/keystone/tests/unit/auth/plugins/test_core.py ++++ b/keystone/tests/unit/auth/plugins/test_core.py +@@ -11,6 +11,7 @@ + # under the License. + + from keystone.auth import plugins ++from keystone.auth.plugins import core as plugins_core + from keystone.tests import unit + + +@@ -20,6 +21,7 @@ + self.config_fixture.config(group='auth', methods=auth_methods) + + expected_method_map = {1: 'password'} ++ expected_method_map.update(plugins_core._PSEUDO_METHOD_BITS) + method_map = plugins.construct_method_map_from_config() + self.assertDictEqual(expected_method_map, method_map) + +@@ -28,6 +30,7 @@ + self.config_fixture.config(group='auth', methods=auth_methods) + + expected_method_map = {1: 'password', 2: 'token'} ++ expected_method_map.update(plugins_core._PSEUDO_METHOD_BITS) + method_map = plugins.construct_method_map_from_config() + self.assertDictEqual(expected_method_map, method_map) + +@@ -36,6 +39,7 @@ + self.config_fixture.config(group='auth', methods=auth_methods) + + expected_method_map = {1: 'password', 2: 'token', 4: 'totp'} ++ expected_method_map.update(plugins_core._PSEUDO_METHOD_BITS) + method_map = plugins.construct_method_map_from_config() + self.assertDictEqual(expected_method_map, method_map) + +@@ -99,3 +103,52 @@ + self.assertTrue(len(methods) == 3) + for method in methods: + self.assertIn(method, expected_methods) ++ ++ def test_pseudo_methods_round_trip(self): ++ """ec2credential and oauth2_credential must survive the bitmask. ++ ++ They are written into token.methods by endpoints that are not auth ++ plugins, so they cannot be listed in CONF.auth.methods. Without a ++ reserved bit they encoded to 0 and decoded back to [], silently ++ losing the method list. ++ """ ++ self.config_fixture.config(group='auth', methods=['password', 'token']) ++ for method in ('ec2credential', 'oauth2_credential'): ++ integer = plugins.convert_method_list_to_integer([method]) ++ self.assertNotEqual(0, integer) ++ self.assertEqual( ++ [method], plugins.convert_integer_to_method_list(integer) ++ ) ++ ++ def test_pseudo_method_bits_do_not_collide(self): ++ """Reserved bits must not overlap the configured method indexes.""" ++ self.config_fixture.config( ++ group='auth', methods=['password', 'token', 'totp'] ++ ) ++ method_map = plugins.construct_method_map_from_config() ++ configured = {1: 'password', 2: 'token', 4: 'totp'} ++ self.assertEqual( ++ len(configured) + len(plugins_core._PSEUDO_METHOD_BITS), ++ len(method_map), ++ ) ++ for bit, name in configured.items(): ++ self.assertEqual(name, method_map[bit]) ++ ++ def test_pseudo_method_mixed_with_configured_method(self): ++ """A pseudo-method alongside a real one decodes to both.""" ++ self.config_fixture.config(group='auth', methods=['password', 'token']) ++ integer = plugins.convert_method_list_to_integer( ++ ['password', 'ec2credential'] ++ ) ++ self.assertCountEqual( ++ ['password', 'ec2credential'], ++ plugins.convert_integer_to_method_list(integer), ++ ) ++ ++ def test_unknown_method_still_encodes_to_zero(self): ++ """An unlisted, non-reserved name is still dropped silently.""" ++ self.config_fixture.config(group='auth', methods=['password']) ++ self.assertEqual( ++ 0, plugins.convert_method_list_to_integer(['not_a_method']) ++ ) ++ self.assertEqual([], plugins.convert_integer_to_method_list(0)) +diff --git a/releasenotes/notes/reserve-pseudo-method-bits-404f8c553e033246.yaml b/releasenotes/notes/reserve-pseudo-method-bits-404f8c553e033246.yaml +new file mode 100644 +index 0000000..ae9b12c +--- /dev/null ++++ b/releasenotes/notes/reserve-pseudo-method-bits-404f8c553e033246.yaml +@@ -0,0 +1,12 @@ ++--- ++fixes: ++ - | ++ ``ec2credential`` and ``oauth2_credential`` are written into ++ ``token.methods`` by endpoints that are not auth plugins, so they were ++ never listed in ``[auth] methods`` and had no bit reserved for them in ++ the fernet token method bitmask. They silently encoded to ``0`` and ++ decoded back to an empty method list on every token round-trip ++ (validation, revocation, everything past initial issuance), losing the ++ token's real auth method. Fixed bits are now reserved for both so they ++ survive the round-trip. Tokens already issued before this fix are ++ unaffected and still decode to an empty method list. diff -Nru keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch --- keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch 2026-08-28 07:41:35.000000000 +0000 @@ -0,0 +1,1217 @@ +From 05afc5fa93b392108d069c94482334a7bbc1a491 Mon Sep 17 00:00:00 2001 +From: Grzegorz Grasza +Date: Thu, 23 Jul 2026 09:32:00 +0200 +Subject: [PATCH] trusts, oauth1, app-creds: reject delegated tokens across all + endpoints + +_check_application_credential() in trusts.py only recognized +'application_credential' in token.methods, so OAuth1 access-token-scoped +and ec2credential-derived tokens were never blocked from creating, +listing, reading, or deleting trusts -- unlike application_credential, +which has had this restriction since LP#2148477. + +Two more endpoints that mint a new persistent grant had the same gap: + +- users.py's _block_delegated_token_app_creds (guarding application + credential and access-rule CRUD) only checked trust_id/access_token_id, + so ec2credential-scoped tokens could create, list, read, and delete + application credentials. +- os_oauth1.py's AuthorizeResource.put (PUT /v3/OS-OAUTH1/authorize) only + checked is_delegated_auth (trust/oauth1) and application_credential, so + an ec2credential-scoped token could authorize OAuth1 request tokens. + +All three are extended to the same primary-auth-method allowlist used in +credentials.py, users.py, and token.py: any token whose methods aren't +entirely primary auth methods is rejected outright. Trust-scoped tokens +are deliberately not blocked from trust operations on trust_id alone -- +that's the trust redelegation feature working as designed (a trustee +creating a further, narrower trust from one they were delegated) and +trustee self-service reads of their own trusts. A trust-scoped token +whose underlying method is itself delegated (an EC2 credential's blob +can embed a trust_id, see keystone.api.credentials._assign_unique_id) is +still caught by the method check regardless of trust scoping. +application_credential keeps its existing, documented opt-in escape +hatch for trust management +(allow_insecure_application_credential_trust_escalation) and its +unrestricted/restricted distinction for creating further application +credentials (_check_unrestricted_application_credential); OAuth1 and EC2 +credentials have no such use case and are blocked unconditionally +everywhere. + +This also closes off the underlying role-escalation path in trust +creation: trust role validation checks the trustor's full role +assignments, not the requesting token's own scoped roles, so a +narrowly-scoped oauth1/ec2 token could previously delegate roles it was +never itself authorized for. Blocking those token types from trust +creation removes the path to that gap without needing to touch the +validation itself. + +Neither the app-cred nor the OAuth1-authorize gap is reachable on +current master, where a separate middleware change globally rejects any +caller token with 'ec2credential' in its methods before Flask routing -- +but that change is not backported to stable branches, so both gaps are +live there. Fixed at the source regardless, so the guard doesn't depend +on an unrelated middleware check remaining in place. + +An empty token.methods list (e.g. an ec2credential-derived token that +lost its methods on a fernet cache-miss round-trip -- ec2credential has +no bit in the method bitmask) is also treated as delegated at all three +call sites: _PRIMARY_AUTH_METHODS.issuperset([]) is True, so without this +an empty list would otherwise be accepted as 'all primary'. + +Consolidated the three independent _PRIMARY_AUTH_METHODS copies (this +patch, LP#2158538, LP#2159643) into keystone.api._shared.delegation, +per gtema's review comment #16 -- now that all three land together the +NameError-avoidance reason for keeping them separate no longer applies. +Also replaced the hardcoded method list with an operator-extensible one +([auth] additional_primary_auth_methods): a hardcoded allowlist blocks +any third-party auth plugin (e.g. a site-specific SSO integration) from +reauthenticating/managing its own trusts, app-creds, and OAuth1 tokens, +since it can never appear in a list only keystone maintainers can edit. + +Closes-Bug: #2153453 +Change-Id: Ic8775eb0fdaa2330206023818ce18f76430fa45e +Assisted-by: Claude Sonnet 5 +Signed-off-by: Grzegorz Grasza +Signed-off-by: Artem Goncharov +--- + keystone/api/_shared/delegation.py | 73 +++ + keystone/api/os_oauth1.py | 35 +- + keystone/api/trusts.py | 71 ++- + keystone/api/users.py | 32 +- + keystone/conf/auth.py | 21 + + .../unit/test_v3_application_credential.py | 103 ++++ + keystone/tests/unit/test_v3_oauth1.py | 57 +++ + keystone/tests/unit/test_v3_trust.py | 482 ++++++++++++++++++ + .../notes/bug-2153453-6f2a1d9e0c7b4a83.yaml | 40 ++ + 9 files changed, 858 insertions(+), 56 deletions(-) + create mode 100644 keystone/api/_shared/delegation.py + create mode 100644 releasenotes/notes/bug-2153453-6f2a1d9e0c7b4a83.yaml + +Index: keystone/keystone/api/_shared/delegation.py +=================================================================== +--- /dev/null ++++ keystone/keystone/api/_shared/delegation.py +@@ -0,0 +1,73 @@ ++# 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. ++ ++# Shared primary-vs-delegated auth method classification, used by ++# keystone.api.trusts, keystone.api.os_oauth1, keystone.api.users, ++# keystone.api.credentials, and keystone.auth.plugins.token to guard ++# sensitive actions (managing trusts, OAuth1 access tokens, application ++# credentials, or another user's credentials; exchanging a token for ++# another token) against delegated-token abuse. See LP#2153453, ++# LP#2158538, LP#2159643. ++ ++import keystone.conf ++ ++CONF = keystone.conf.CONF ++ ++# Auth methods that authenticate a user directly. Anything else -- ++# a delegated credential (application_credential, oauth1, ec2credential, ++# oauth2_credential) or a future, not-yet-reviewed delegated method -- is ++# treated as delegated by default and rejected from the sensitive actions ++# this module's callers guard. This is a deny-by-default allowlist, not a ++# denylist of specific known-bad methods: a new delegated method nobody ++# has reviewed yet must be blocked automatically, not silently allowed ++# through until someone remembers to add it to a denylist. ++_BUILTIN_PRIMARY_AUTH_METHODS = frozenset( ++ { ++ 'external', ++ 'kerberos', ++ 'mapped', ++ 'openid', ++ 'password', ++ 'saml2', ++ 'token', ++ 'totp', ++ 'x509', ++ } ++) ++ ++ ++def primary_auth_methods(): ++ """The effective set of primary (non-delegated) auth methods. ++ ++ Operators running a custom, third-party auth plugin (e.g. a ++ site-specific SSO integration) can list its method name in ++ [auth] additional_primary_auth_methods so tokens issued through it ++ are not mistaken for a delegated credential by the guards in this ++ module's callers. ++ """ ++ return _BUILTIN_PRIMARY_AUTH_METHODS | frozenset( ++ CONF.auth.additional_primary_auth_methods ++ ) ++ ++ ++def is_delegated_method(token): ++ """Return True if token.methods indicates a delegated credential. ++ ++ An empty methods list is treated as delegated, not allowed: a token ++ whose methods were lost on a fernet round-trip (e.g. ++ ec2credential/oauth2_credential decoding to []) must not be treated ++ as if it had no delegated methods at all -- issuperset([]) is True. ++ """ ++ return bool(token) and ( ++ not token.methods ++ or not primary_auth_methods().issuperset(token.methods) ++ ) +Index: keystone/keystone/api/os_oauth1.py +=================================================================== +--- keystone.orig/keystone/api/os_oauth1.py ++++ keystone/keystone/api/os_oauth1.py +@@ -21,6 +21,7 @@ from oslo_log import log + from oslo_utils import timeutils + from werkzeug import exceptions + ++from keystone.api._shared import delegation + from keystone.api._shared import json_home_relations + from keystone.common import authorization + from keystone.common import context +@@ -42,6 +43,25 @@ ENFORCER = rbac_enforcer.RBACEnforcer + CONF = keystone.conf.CONF + + ++def _check_can_authorize_request_token(ctx, token): ++ """Reject delegated tokens from authorizing OAuth1 request tokens. ++ ++ Authorizing a request token mints a new, independent OAuth1 access ++ token delegation. Allowing a delegated token (application credential, ++ EC2 credential, another OAuth1 access token, or trust-scoped token) to ++ do this would let a narrower-scope grant bootstrap a broader, ++ independently-lived one. ++ """ ++ trust_id = getattr(ctx, 'trust_id', None) ++ if trust_id or delegation.is_delegated_method(token): ++ raise exception.Forbidden( ++ _( ++ 'Cannot authorize a request token with a token issued via ' ++ 'delegation.' ++ ) ++ ) ++ ++ + _build_resource_relation = json_home_relations.os_oauth1_resource_rel_func + _build_parameter_relation = json_home_relations.os_oauth1_parameter_rel_func + +@@ -313,24 +333,11 @@ class AuthorizeResource(_OAuth1ResourceB + ) + validation.lazy_validate(schema.request_token_authorize, roles) + ctx = flask.request.environ[context.REQUEST_CONTEXT_ENV] +- if ctx.is_delegated_auth: +- 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.' +- ) +- ) ++ _check_can_authorize_request_token(ctx, token) + + req_token = PROVIDERS.oauth_api.get_request_token(request_token_id) + +Index: keystone/keystone/api/trusts.py +=================================================================== +--- keystone.orig/keystone/api/trusts.py ++++ keystone/keystone/api/trusts.py +@@ -22,6 +22,7 @@ import flask_restful + from oslo_log import log + from oslo_policy import _checks as op_checks + ++from keystone.api._shared import delegation + from keystone.api._shared import json_home_relations + from keystone.api import validation + from keystone.common import authorization +@@ -50,28 +51,50 @@ TRUST_ID_PARAMETER_RELATION = _build_par + ) + + +-def _check_application_credential(): +- """Block application credential tokens from all trust operations. ++def _check_delegated_token(): ++ """Block delegated 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. ++ Delegated tokens (application credentials, OAuth1 access tokens, EC2 ++ credentials) are narrower-scope grants than a normal user session. ++ Allowing them to read or manage trusts would permit a compromised ++ delegation to enumerate or manipulate the trust delegation chain -- or, ++ for creation, mint a new trust delegating roles the trustor holds but ++ the delegation itself was never scoped to, since trust role validation ++ checks the trustor's full role assignments, not the requesting token's ++ own scoped roles -- expanding its effective scope well beyond what it ++ was issued for. ++ ++ Trust-scoped tokens are deliberately NOT blocked here: managing trusts ++ with a trust-scoped token is the redelegation feature working as ++ designed (a trustee creating a further, narrower trust from one they ++ were delegated), and trustee self-service reads of their own trusts. ++ A trust-scoped token whose underlying auth method is itself delegated ++ (e.g. an EC2 credential's blob embeds a trust_id -- see ++ keystone.api.credentials._assign_unique_id) is still caught below, ++ since token.methods reflects that underlying method regardless of the ++ trust scoping. ++ ++ application_credential tokens have a documented, opt-in escape hatch ++ (allow_insecure_application_credential_trust_escalation, LP#2150089) ++ for workflows such as Heat that need it. OAuth1 and EC2 credentials ++ have no such use case and are blocked unconditionally, regardless of ++ that option. + """ +- if CONF.security_compliance.allow_insecure_application_credential_trust_escalation: ++ token = flask.request.environ.get(authorization.AUTH_CONTEXT_ENV, {}).get( ++ 'token' ++ ) ++ if not token: ++ return ++ if not delegation.is_delegated_method(token): ++ return ++ if ( ++ 'application_credential' in token.methods ++ and CONF.security_compliance.allow_insecure_application_credential_trust_escalation ++ ): + return +- auth_context = flask.request.environ.get( +- authorization.AUTH_CONTEXT_ENV, {} ++ raise exception.ForbiddenAction( ++ action=_('Delegated tokens cannot manage trusts.') + ) +- 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(): +@@ -129,7 +152,7 @@ def _normalize_trust_roles(trust): + + class TrustResourceBase(ks_flask.ResourceBase): + def _check_unrestricted(self): +- _check_application_credential() ++ _check_delegated_token() + + + class TrustsResource(TrustResourceBase): +@@ -218,7 +241,7 @@ class TrustsResource(TrustResourceBase): + ) + else: + ENFORCER.enforce_call(action='identity:list_trusts') +- _check_application_credential() ++ _check_delegated_token() + + trusts = [] + +@@ -334,7 +357,7 @@ class TrustResource(TrustResourceBase): + action='identity:get_trust', + build_target=_build_trust_target_enforcement, + ) +- _check_application_credential() ++ _check_delegated_token() + + # NOTE(cmurphy) look up trust before doing is_admin authorization - to + # maintain the API contract, we expect a missing trust to raise a 404 +@@ -438,7 +461,7 @@ class RolesForTrustListResource(flask_re + raise exception.ForbiddenAction( + action=_('Requested user has no relation to this trust') + ) +- _check_application_credential() ++ _check_delegated_token() + + trust = PROVIDERS.trust_api.get_trust(trust_id) + +@@ -490,7 +513,7 @@ class RoleForTrustResource(flask_restful + raise exception.ForbiddenAction( + action=_('Requested user has no relation to this trust') + ) +- _check_application_credential() ++ _check_delegated_token() + + trust = PROVIDERS.trust_api.get_trust(trust_id) + +Index: keystone/keystone/api/users.py +=================================================================== +--- keystone.orig/keystone/api/users.py ++++ keystone/keystone/api/users.py +@@ -21,6 +21,7 @@ import flask + from oslo_serialization import jsonutils + from werkzeug import exceptions + ++from keystone.api._shared import delegation + from keystone.api._shared import json_home_relations + from keystone.api import validation + from keystone.application_credential import schema as app_cred_schema +@@ -123,14 +124,8 @@ def _check_delegation_for_ec2(oslo_conte + + 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: ++ trust_id = getattr(oslo_context, 'trust_id', None) ++ if trust_id or delegation.is_delegated_method(token): + raise ks_exception.Forbidden( + _( + 'Cannot manage OAuth access tokens with a token ' +@@ -140,24 +135,25 @@ def _block_delegated_token(oslo_context, + + + def _block_delegated_token_app_creds(oslo_context, token): +- """Raise Forbidden if the token is a trust or OAuth1 delegation. ++ """Raise Forbidden if the token is a trust, OAuth1, or EC2 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. ++ Trust-scoped, OAuth1 access token-scoped, and EC2-derived 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. + """ ++ if 'application_credential' in token.methods: ++ return + trust_id = getattr(oslo_context, 'trust_id', None) +- access_token_id = getattr(token, 'access_token_id', None) +- if trust_id or access_token_id: ++ if trust_id or delegation.is_delegated_method(token): + raise ks_exception.Forbidden( + _( + 'Cannot manage application credentials with a token ' +Index: keystone/keystone/conf/auth.py +=================================================================== +--- keystone.orig/keystone/conf/auth.py ++++ keystone/keystone/conf/auth.py +@@ -105,6 +105,26 @@ authentication plugin. + ), + ) + ++additional_primary_auth_methods = cfg.ListOpt( ++ 'additional_primary_auth_methods', ++ default=[], ++ help=utils.fmt( ++ """ ++Auth method names, beyond keystone's own built-in primary methods ++(password, totp, mapped, saml2, openid, external, kerberos, x509, token), ++that authenticate a user directly rather than via a delegated credential. ++Set this if you run a custom, third-party auth plugin (for example a ++site-specific SSO integration) so that tokens issued through it are not ++mistaken for a delegated credential (application credential, OAuth1 ++access token, EC2 credential) by the guards that block delegated tokens ++from managing trusts, application credentials, OAuth1 access tokens, ++credentials, or exchanging a token for another token. Without this, a ++token issued via an unlisted custom method is treated as delegated and ++rejected from those actions by default. ++""" ++ ), ++) ++ + + GROUP_NAME = __name__.split('.')[-1] + ALL_OPTS = [ +@@ -115,6 +135,7 @@ ALL_OPTS = [ + oauth1, + mapped, + application_credential, ++ additional_primary_auth_methods, + ] + + +Index: keystone/keystone/tests/unit/test_v3_application_credential.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_v3_application_credential.py ++++ keystone/keystone/tests/unit/test_v3_application_credential.py +@@ -14,12 +14,15 @@ import datetime + import http.client + import uuid + ++from keystoneclient.contrib.ec2 import utils as ec2_utils + from oslo_utils import timeutils + from testtools import matchers + + from keystone.common import provider_api + import keystone.conf ++from keystone.credential.providers import fernet as credential_fernet + from keystone.tests import unit ++from keystone.tests.unit import ksfixtures + from keystone.tests.unit import test_v3 + + CONF = keystone.conf.CONF +@@ -1002,3 +1005,103 @@ class ApplicationCredentialTestCase(test + expected_status_code=http.client.NO_CONTENT, + headers={"X-Auth-Token": token}, + ) ++ ++ ++class AppCredEc2GuardTests(ApplicationCredentialTestCase): ++ """EC2-derived tokens must not manage application credentials. ++ ++ An ec2credential-scoped token has no delegation markers ++ (trust_id/access_token_id) that the pre-existing ++ _block_delegated_token_app_creds check looked for, so it slipped ++ through unblocked. See LP#2153453. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.useFixture( ++ ksfixtures.KeyRepository( ++ self.config_fixture, ++ 'credential', ++ credential_fernet.MAX_ACTIVE_KEYS, ++ ) ++ ) ++ ++ def _get_ec2_token_id(self): ++ blob, ref = unit.new_ec2_credential( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ self.post('/credentials', body={'credential': 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, ++ } ++ r = self.post( ++ '/ec2tokens', ++ body={'ec2Credentials': sig_ref}, ++ expected_status=http.client.OK, ++ ) ++ return r.headers.get('X-Subject-Token') ++ ++ def test_ec2_token_cannot_create_application_credential(self): ++ ec2_token = self._get_ec2_token_id() ++ app_cred_body = self._app_cred_body(roles=[{'id': self.role_id}]) ++ self.post( ++ f'/users/{self.user_id}/application_credentials', ++ body=app_cred_body, ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_ec2_token_cannot_list_application_credentials(self): ++ ec2_token = self._get_ec2_token_id() ++ self.get( ++ f'/users/{self.user_id}/application_credentials', ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_ec2_token_cannot_get_application_credential(self): ++ ec2_token = self._get_ec2_token_id() ++ app_cred_body = self._app_cred_body(roles=[{'id': self.role_id}]) ++ r = self.post( ++ f'/users/{self.user_id}/application_credentials', ++ body=app_cred_body, ++ ) ++ app_cred_id = r.result['application_credential']['id'] ++ self.get( ++ MEMBER_PATH_FMT ++ % {'user_id': self.user_id, 'app_cred_id': app_cred_id}, ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_ec2_token_cannot_delete_application_credential(self): ++ ec2_token = self._get_ec2_token_id() ++ app_cred_body = self._app_cred_body(roles=[{'id': self.role_id}]) ++ r = self.post( ++ f'/users/{self.user_id}/application_credentials', ++ body=app_cred_body, ++ ) ++ app_cred_id = r.result['application_credential']['id'] ++ self.delete( ++ MEMBER_PATH_FMT ++ % {'user_id': self.user_id, 'app_cred_id': app_cred_id}, ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) +Index: keystone/keystone/tests/unit/test_v3_oauth1.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_v3_oauth1.py ++++ keystone/keystone/tests/unit/test_v3_oauth1.py +@@ -22,12 +22,14 @@ from urllib import parse as urlparse + import uuid + + import freezegun ++from keystoneclient.contrib.ec2 import utils as ec2_utils + from oslo_serialization import jsonutils + from oslo_utils import timeutils + from pycadf import cadftaxonomy + + from keystone.common import provider_api + import keystone.conf ++from keystone.credential.providers import fernet as credential_fernet + from keystone import exception + from keystone import oauth1 + from keystone.oauth1.backends import base +@@ -835,6 +837,61 @@ class AuthTokenTests: + expected_status=http.client.FORBIDDEN, + ) + ++ def _get_ec2_token_id(self): ++ self.useFixture( ++ ksfixtures.KeyRepository( ++ self.config_fixture, ++ 'credential', ++ credential_fernet.MAX_ACTIVE_KEYS, ++ ) ++ ) ++ blob, ref = unit.new_ec2_credential( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ self.post('/credentials', body={'credential': 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, ++ } ++ r = self.post( ++ '/ec2tokens', ++ body={'ec2Credentials': sig_ref}, ++ expected_status=http.client.OK, ++ ) ++ return r.headers.get('X-Subject-Token') ++ ++ def test_ec2_token_cannot_authorize_request_token(self): ++ ec2_token = self._get_ec2_token_id() ++ url = self._approve_request_token_url() ++ body = {'roles': [{'id': self.role_id}]} ++ self.put( ++ url, ++ body=body, ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_ec2_token_cannot_list_access_tokens(self): ++ ec2_token = self._get_ec2_token_id() ++ url = f'/users/{self.user_id}/OS-OAUTH1/access_tokens' ++ self.get(url, token=ec2_token, expected_status=http.client.FORBIDDEN) ++ + + class FernetAuthTokenTests(AuthTokenTests, OAuthFlowTests): + def config_overrides(self): +Index: keystone/keystone/tests/unit/test_v3_trust.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_v3_trust.py ++++ keystone/keystone/tests/unit/test_v3_trust.py +@@ -11,14 +11,26 @@ + # under the License. + + import http.client ++from unittest import mock ++import urllib + import uuid + ++import flask ++ ++from keystoneclient.contrib.ec2 import utils as ec2_utils + from oslo_utils import timeutils + ++from keystone.api._shared import delegation ++from keystone.api import trusts as trusts_api ++from keystone.common import authorization ++from keystone.common import context + from keystone.common import provider_api + import keystone.conf ++from keystone.credential.providers import fernet as credential_fernet + from keystone import exception ++from keystone import oauth1 + from keystone.tests import unit ++from keystone.tests.unit import ksfixtures + from keystone.tests.unit import test_v3 + + CONF = keystone.conf.CONF +@@ -775,3 +787,473 @@ class TrustsWithApplicationCredentials(t + token=token_data.headers['x-subject-token'], + expected_status=http.client.FORBIDDEN, + ) ++ ++ ++class TrustsWithOtherDelegatedTokens(test_v3.RestfulTestCase): ++ """OAuth1 access-token and ec2credential tokens must not manage trusts. ++ ++ Mirrors TrustsWithApplicationCredentials, closing the gap identified ++ in LP#2153453 comments #9-10 ("EC2 tokens can still create trusts and ++ authorize OAuth1") that was never actually fixed for either token ++ type in this file. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.trustee_user = unit.create_user( ++ PROVIDERS.identity_api, domain_id=self.domain_id ++ ) ++ self.trustee_user_id = self.trustee_user['id'] ++ self.base_url = 'http://localhost/v3' ++ self.useFixture( ++ ksfixtures.KeyRepository( ++ self.config_fixture, ++ 'credential', ++ credential_fernet.MAX_ACTIVE_KEYS, ++ ) ++ ) ++ ++ def _make_trust_ref(self): ++ return 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], ++ ) ++ ++ def _get_ec2_token(self): ++ blob, ref = unit.new_ec2_credential( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ self.post('/credentials', body={'credential': 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, ++ } ++ r = self.post( ++ '/ec2tokens', ++ body={'ec2Credentials': sig_ref}, ++ expected_status=http.client.OK, ++ ) ++ return r.headers.get('X-Subject-Token') ++ ++ def _urllib_parse_qs_text_keys(self, content): ++ results = urllib.parse.parse_qs(content) ++ return {key.decode('utf-8'): value for key, value in results.items()} ++ ++ def _create_single_consumer(self): ++ ref = {'description': uuid.uuid4().hex} ++ resp = self.post('/OS-OAUTH1/consumers', body={'consumer': ref}) ++ return resp.result['consumer'] ++ ++ def _create_request_token(self, consumer, project_id): ++ endpoint = '/OS-OAUTH1/request_token' ++ client = oauth1.Client( ++ consumer['key'], ++ client_secret=consumer['secret'], ++ signature_method=oauth1.SIG_HMAC, ++ callback_uri='oob', ++ ) ++ headers = {'requested_project_id': project_id} ++ url, headers, body = client.sign( ++ self.base_url + endpoint, http_method='POST', headers=headers ++ ) ++ return endpoint, headers ++ ++ def _create_access_token(self, consumer, token): ++ endpoint = '/OS-OAUTH1/access_token' ++ client = oauth1.Client( ++ consumer['key'], ++ client_secret=consumer['secret'], ++ resource_owner_key=token.key, ++ resource_owner_secret=token.secret, ++ signature_method=oauth1.SIG_HMAC, ++ verifier=token.verifier, ++ ) ++ url, headers, body = client.sign( ++ self.base_url + endpoint, http_method='POST' ++ ) ++ headers.update({'Content-Type': 'application/json'}) ++ return endpoint, headers ++ ++ def _get_oauth_token_request(self, consumer, token): ++ client = oauth1.Client( ++ consumer['key'], ++ client_secret=consumer['secret'], ++ resource_owner_key=token.key, ++ resource_owner_secret=token.secret, ++ signature_method=oauth1.SIG_HMAC, ++ ) ++ endpoint = '/auth/tokens' ++ url, headers, body = client.sign( ++ self.base_url + endpoint, http_method='POST' ++ ) ++ headers.update({'Content-Type': 'application/json'}) ++ ref = {'auth': {'identity': {'oauth1': {}, 'methods': ['oauth1']}}} ++ return endpoint, headers, ref ++ ++ def _authorize_request_token(self, request_id): ++ if isinstance(request_id, bytes): ++ request_id = request_id.decode() ++ return f'/OS-OAUTH1/authorize/{request_id}' ++ ++ def _get_oauth1_token(self): ++ consumer = self._create_single_consumer() ++ consumer = {'key': consumer['id'], 'secret': consumer['secret']} ++ ++ url, headers = self._create_request_token(consumer, self.project_id) ++ content = self.post( ++ url, ++ headers=headers, ++ response_content_type='application/x-www-form-urlencoded', ++ ) ++ credentials = self._urllib_parse_qs_text_keys(content.result) ++ request_key = credentials['oauth_token'][0] ++ request_secret = credentials['oauth_token_secret'][0] ++ request_token = oauth1.Token(request_key, request_secret) ++ ++ url = self._authorize_request_token(request_key) ++ body = {'roles': [{'id': self.role_id}]} ++ resp = self.put(url, body=body, expected_status=http.client.OK) ++ verifier = resp.result['token']['oauth_verifier'] ++ ++ request_token.set_verifier(verifier) ++ url, headers = self._create_access_token(consumer, request_token) ++ content = self.post( ++ url, ++ headers=headers, ++ response_content_type='application/x-www-form-urlencoded', ++ ) ++ credentials = self._urllib_parse_qs_text_keys(content.result) ++ access_key = credentials['oauth_token'][0] ++ access_secret = credentials['oauth_token_secret'][0] ++ access_token = oauth1.Token(access_key, access_secret) ++ ++ url, headers, body = self._get_oauth_token_request( ++ consumer, access_token ++ ) ++ content = self.post(url, headers=headers, body=body) ++ return content.headers['X-Subject-Token'] ++ ++ def test_create_trust_with_ec2_token(self): ++ """An ec2credential token must not be able to create a trust.""" ++ self.post( ++ '/OS-TRUST/trusts', ++ body={'trust': self._make_trust_ref()}, ++ token=self._get_ec2_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_list_trusts_with_ec2_token(self): ++ """An ec2credential token must not be able to list trusts.""" ++ self.get( ++ '/OS-TRUST/trusts', ++ token=self._get_ec2_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_get_trust_with_ec2_token(self): ++ """An ec2credential token must not be able to read a trust.""" ++ r = self.post( ++ '/OS-TRUST/trusts', body={'trust': self._make_trust_ref()} ++ ) ++ trust_id = r.result['trust']['id'] ++ self.get( ++ f'/OS-TRUST/trusts/{trust_id}', ++ token=self._get_ec2_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_delete_trust_with_ec2_token(self): ++ """An ec2credential token must not be able to delete a trust.""" ++ r = self.post( ++ '/OS-TRUST/trusts', body={'trust': self._make_trust_ref()} ++ ) ++ trust_id = r.result['trust']['id'] ++ self.delete( ++ f'/OS-TRUST/trusts/{trust_id}', ++ token=self._get_ec2_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.get( ++ f'/OS-TRUST/trusts/{trust_id}', expected_status=http.client.OK ++ ) ++ ++ def test_create_trust_with_oauth1_token(self): ++ """An OAuth1 access-token-scoped token must not create a trust. ++ ++ Without the delegation-boundary check, this still ends up 403 ++ today -- but only by accident, via _find_redelegated_trust()'s ++ unrelated "delegated by trust only" check (OAuth-scoped tokens are ++ also flagged is_delegated_auth). Assert the specific message so ++ this test actually exercises the delegation-boundary check. ++ """ ++ r = self.post( ++ '/OS-TRUST/trusts', ++ body={'trust': self._make_trust_ref()}, ++ token=self._get_oauth1_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.assertIn( ++ 'Delegated tokens cannot manage trusts', ++ r.result['error']['message'], ++ ) ++ ++ def test_list_trusts_with_oauth1_token(self): ++ """An OAuth1 access-token-scoped token must not list trusts.""" ++ self.get( ++ '/OS-TRUST/trusts', ++ token=self._get_oauth1_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_get_trust_with_oauth1_token(self): ++ """An OAuth1 access-token-scoped token must not read a trust.""" ++ r = self.post( ++ '/OS-TRUST/trusts', body={'trust': self._make_trust_ref()} ++ ) ++ trust_id = r.result['trust']['id'] ++ self.get( ++ f'/OS-TRUST/trusts/{trust_id}', ++ token=self._get_oauth1_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_delete_trust_with_oauth1_token(self): ++ """An OAuth1 access-token-scoped token must not delete a trust.""" ++ r = self.post( ++ '/OS-TRUST/trusts', body={'trust': self._make_trust_ref()} ++ ) ++ trust_id = r.result['trust']['id'] ++ self.delete( ++ f'/OS-TRUST/trusts/{trust_id}', ++ token=self._get_oauth1_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.get( ++ f'/OS-TRUST/trusts/{trust_id}', expected_status=http.client.OK ++ ) ++ ++ def test_escape_hatch_does_not_extend_to_oauth1_or_ec2(self): ++ """allow_insecure_application_credential_trust_escalation is app-cred-only. ++ ++ Enabling it must not exempt oauth1 or ec2credential tokens from ++ the trust-management block -- only application_credential has a ++ documented use case for this escape hatch. ++ """ ++ self.config_fixture.config( ++ group='security_compliance', ++ allow_insecure_application_credential_trust_escalation=True, ++ ) ++ self.post( ++ '/OS-TRUST/trusts', ++ body={'trust': self._make_trust_ref()}, ++ token=self._get_ec2_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.post( ++ '/OS-TRUST/trusts', ++ body={'trust': self._make_trust_ref()}, ++ token=self._get_oauth1_token(), ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ ++class TestTrustGuardUnit(unit.BaseTestCase): ++ """Unit-level tests for _check_delegated_token (LP#2153453). ++ ++ Calls the guard directly with a stub Flask request context, ++ independent of HTTP request handling or the auth middleware. Matters ++ for ec2credential specifically: an unrelated, already-landed fix bans ++ ec2credential-method tokens at the auth middleware layer before this ++ guard ever runs, so an HTTP-level test would pass regardless of ++ whether the guard itself recognizes ec2credential as delegated. It ++ also matters for oauth1, since _find_redelegated_trust()'s unrelated ++ "delegated by trust only" check happens to also reject oauth1-scoped ++ trust creation, masking whether this guard's own check fires. ++ """ ++ ++ def _check(self, methods, trust_id=None, escape_hatch=False): ++ CONF.set_override( ++ 'allow_insecure_application_credential_trust_escalation', ++ escape_hatch, ++ group='security_compliance', ++ ) ++ self.addCleanup( ++ CONF.clear_override, ++ 'allow_insecure_application_credential_trust_escalation', ++ group='security_compliance', ++ ) ++ app = flask.Flask('test-trusts-guard') ++ with app.test_request_context('/'): ++ token = mock.Mock() ++ token.methods = methods ++ oslo_context = mock.Mock() ++ oslo_context.trust_id = trust_id ++ flask.request.environ[authorization.AUTH_CONTEXT_ENV] = { ++ 'token': token ++ } ++ flask.request.environ[context.REQUEST_CONTEXT_ENV] = oslo_context ++ trusts_api._check_delegated_token() ++ ++ def test_rejects_ec2credential_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, self._check, ['ec2credential'] ++ ) ++ ++ def test_rejects_oauth1_token(self): ++ self.assertRaises(exception.ForbiddenAction, self._check, ['oauth1']) ++ ++ def test_allows_trust_scoped_token_with_primary_method(self): ++ """A trust-scoped token from a primary auth method is legitimate. ++ ++ This is the trust redelegation feature working as designed: a ++ trustee authenticates (e.g. with a password) to get a trust-scoped ++ token, then uses it to create a further, narrower trust, or to ++ read/list their own trust. Blocking on trust_id alone would break ++ that feature; only the underlying auth method matters here. ++ """ ++ self._check(['password'], trust_id=uuid.uuid4().hex) ++ ++ def test_rejects_ec2_derived_trust_scoped_token(self): ++ """An EC2 credential's blob can embed a trust_id. ++ ++ See keystone.api.credentials._assign_unique_id -- this produces a ++ token that is both trust-scoped and ec2credential-derived. The ++ method check alone must still catch this; trust_id doesn't need ++ its own check. ++ """ ++ self.assertRaises( ++ exception.ForbiddenAction, ++ self._check, ++ ['ec2credential'], ++ trust_id=uuid.uuid4().hex, ++ ) ++ ++ def test_rejects_empty_methods(self): ++ """An empty method list must be treated as delegated, not allowed. ++ ++ This is the fernet round-trip case: methods is serialised as a ++ bitmask over CONF.auth.methods, and a name absent from that list ++ encodes to 0 and decodes back to []. Since issuperset([]) is True, ++ an EC2- or OAuth2-derived token read back from its payload used to ++ pass straight through. ++ """ ++ self.assertRaises(exception.ForbiddenAction, self._check, []) ++ ++ def test_rejects_empty_methods_with_trust_scope(self): ++ """Same, for a token that is additionally trust-scoped.""" ++ self.assertRaises( ++ exception.ForbiddenAction, ++ self._check, ++ [], ++ trust_id=uuid.uuid4().hex, ++ ) ++ ++ def test_allows_password_token(self): ++ self._check(['password']) ++ ++ def test_escape_hatch_allows_application_credential(self): ++ self._check(['application_credential'], escape_hatch=True) ++ ++ def test_escape_hatch_does_not_allow_ec2credential(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ self._check, ++ ['ec2credential'], ++ escape_hatch=True, ++ ) ++ ++ def test_escape_hatch_does_not_allow_oauth1(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ self._check, ++ ['oauth1'], ++ escape_hatch=True, ++ ) ++ ++ ++class TestSharedDelegationGuardUnit(unit.BaseTestCase): ++ """Unit tests for keystone.api._shared.delegation. ++ ++ LP#2153453, LP#2158538, LP#2159643. Used by trusts.py, os_oauth1.py, ++ users.py, credentials.py, and ++ auth/plugins/token.py to classify a token's methods as primary ++ (allowed) or delegated (blocked from the sensitive actions each of ++ those callers guards). ++ """ ++ ++ def _token(self, methods): ++ token = mock.Mock() ++ token.methods = methods ++ return token ++ ++ def test_rejects_empty_methods(self): ++ """The fernet round-trip gap: ec2credential/oauth2_credential have ++ no bit in the method bitmask, so a token carrying only one of ++ those decodes back to an empty list on any cache miss. ++ """ ++ self.assertTrue(delegation.is_delegated_method(self._token([]))) ++ ++ def test_allows_builtin_primary_method(self): ++ self.assertFalse( ++ delegation.is_delegated_method(self._token(['password'])) ++ ) ++ ++ def test_rejects_known_delegated_method(self): ++ self.assertTrue( ++ delegation.is_delegated_method(self._token(['ec2credential'])) ++ ) ++ ++ def test_rejects_unknown_future_method_by_default(self): ++ """Deny-by-default: a brand new, unreviewed method name must be ++ treated as delegated until an operator or a future patch ++ explicitly allowlists it -- this is the property an ++ allow-by-default denylist design would not have. ++ """ ++ self.assertTrue( ++ delegation.is_delegated_method( ++ self._token(['some_future_delegated_method']) ++ ) ++ ) ++ ++ def test_additional_primary_auth_methods_allows_custom_plugin(self): ++ """An operator-registered custom auth plugin (e.g. a site-specific ++ SSO integration) is not mistaken for a delegated credential once ++ listed in [auth] additional_primary_auth_methods. ++ """ ++ CONF.set_override( ++ 'additional_primary_auth_methods', ['sso'], group='auth' ++ ) ++ self.addCleanup( ++ CONF.clear_override, 'additional_primary_auth_methods', ++ group='auth', ++ ) ++ self.assertFalse( ++ delegation.is_delegated_method(self._token(['sso'])) ++ ) ++ ++ def test_unlisted_custom_plugin_still_denied_by_default(self): ++ """Without the config option set, the same custom method is still ++ denied -- adding the extension point does not weaken the default ++ posture for anyone who hasn't opted in. ++ """ ++ self.assertTrue( ++ delegation.is_delegated_method(self._token(['sso'])) ++ ) +Index: keystone/releasenotes/notes/bug-2153453-6f2a1d9e0c7b4a83.yaml +=================================================================== +--- /dev/null ++++ keystone/releasenotes/notes/bug-2153453-6f2a1d9e0c7b4a83.yaml +@@ -0,0 +1,40 @@ ++--- ++security: ++ - | ++ [`bug 2153453 `_] ++ EC2-derived tokens (``methods: ['ec2credential']``) could still create, ++ list, read, or delete trusts (``/v3/OS-TRUST/trusts``), create, list, ++ read, or delete application credentials ++ (``/v3/users/{user_id}/application_credentials``), and authorize OAuth1 ++ request tokens (``PUT ++ /v3/OS-OAUTH1/authorize/{request_token_id}``), because none of the ++ delegation guards on those endpoints recognized ``ec2credential`` as a ++ delegated method. OAuth1 access-token-scoped tokens had the same gap ++ for trust management. A stolen EC2 access/secret key pair or OAuth1 ++ access token could therefore bootstrap a trust, application credential, ++ or OAuth1 access token delegation that outlives revocation of the ++ original credential. ++ ++ These endpoints now use the same primary-auth-method allowlist already ++ used by ``/v3/credentials`` and the ``OS-EC2`` compat endpoints: any ++ token whose methods aren't entirely primary (interactive) auth methods, ++ or that carries a ``trust_id``, is rejected. Application credential ++ tokens keep their existing, documented behavior on each endpoint ++ (the opt-in trust-escalation escape hatch, and the ++ unrestricted/restricted distinction for creating further application ++ credentials); OAuth1 and EC2 credentials have no such use case and are ++ blocked unconditionally. ++ ++upgrade: ++ - | ++ `bug 2153453 `_: A token issued via ++ a custom, third-party auth plugin (for example a site-specific SSO ++ integration) is now treated as a delegated credential by default and ++ rejected from managing trusts, application credentials, and OAuth1 ++ access tokens, unless its method name is listed in the new ++ ``[auth] additional_primary_auth_methods`` option. Deployments that ++ run a custom auth plugin beyond keystone's own built-in methods ++ (``password``, ``totp``, ``mapped``, ``saml2``, ``openid``, ++ ``external``, ``kerberos``, ``x509``, ``token``) must add its method ++ name to this option to avoid regressing self-service workflows for ++ users authenticated through it, such as switching active project. diff -Nru keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch --- keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch 2026-08-28 07:41:35.000000000 +0000 @@ -0,0 +1,330 @@ +From f612d12cccfb7e34b94968cf94967100c689777a Mon Sep 17 00:00:00 2001 +From: Grzegorz Grasza +Date: Mon, 24 Aug 2026 13:13:51 +0200 +Subject: [PATCH] auth: reject delegated tokens from token-method + reauthentication + +token_authenticate() unconditionally blocks trust-scoped and +OAuth1-scoped tokens from creating another token via the token +method, but application_credential tokens were only blocked from +requesting an explicit scope -- an omitted scope fell through to +the user's default-project scoping, letting an app-cred token +bound to project A come back scoped to the user's default project +B. ec2credential tokens matched no check at all, so they could +rescope via an explicit scope to any project the underlying user +has a role on, not just a default-project fallback. + +Both are the same root cause: application credentials and EC2 +credentials are deliberately narrow, single-project grants, and the +"token" method's rescoping logic never accounted for that, treating +them like a normal user session that's free to move between any of +its own role assignments. + +Block application_credential tokens from this path entirely, +matching trust/OAuth1, using the same shared +keystone.api._shared.delegation classification introduced by +LP#2153453: any token whose methods aren't entirely primary auth +methods (built-in, or operator-registered via +[auth] additional_primary_auth_methods) is rejected outright, +including ec2credential and any future delegated method. An empty +token.methods list (e.g. an ec2credential-derived token that lost its +methods on a fernet cache-miss round-trip) is also treated as +delegated, since issuperset([]) is True and would otherwise be +accepted as 'all primary'. + +Closes-Bug: #2158538 +Change-Id: Iadc104ae95d6c59484f29f62d6afe4a935bd8593 +Assisted-by: Claude Sonnet 5 +Co-developed-by: Artem Goncharov +Signed-off-by: Grzegorz Grasza +Signed-off-by: Artem Goncharov +--- + keystone/auth/plugins/token.py | 25 ++- + keystone/tests/unit/test_v3_auth.py | 178 ++++++++++++++++++ + ...-delegated-token-rescope-a1b2c3d4e5f6.yaml | 25 +++ + 3 files changed, 215 insertions(+), 13 deletions(-) + create mode 100644 releasenotes/notes/bug-2158538-delegated-token-rescope-a1b2c3d4e5f6.yaml + +Index: keystone/keystone/auth/plugins/token.py +=================================================================== +--- keystone.orig/keystone/auth/plugins/token.py ++++ keystone/keystone/auth/plugins/token.py +@@ -15,6 +15,7 @@ + import flask + from oslo_log import log + ++from keystone.api._shared import delegation + from keystone.auth.plugins import base + from keystone.auth.plugins import mapped + from keystone.common import provider_api +@@ -88,7 +89,18 @@ def token_authenticate(token): + 'or domain-scoped token is not allowed.' + ) + ) +- ++ elif delegation.is_delegated_method(token): ++ # A token derived from a deliberately narrow-scope credential ++ # (application_credential, OAuth1 access token, ec2credential, ++ # or any other non-primary method, including a future one) ++ # must not be exchanged for a token with a different, ++ # potentially broader, scope. ++ raise exception.ForbiddenAction( ++ action=_( ++ 'Using a delegated token to create another token is ' ++ 'not allowed.' ++ ) ++ ) + if not CONF.token.allow_rescope_scoped_token: + # Do not allow conversion from scoped tokens. + if token.project_scoped or token.domain_scoped: +Index: keystone/keystone/tests/unit/test_v3_auth.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_v3_auth.py ++++ keystone/keystone/tests/unit/test_v3_auth.py +@@ -24,7 +24,9 @@ import uuid + + from cryptography.hazmat.primitives.serialization import Encoding + import fixtures ++import flask + import freezegun ++from keystoneclient.contrib.ec2 import utils as ec2_utils + from oslo_serialization import jsonutils as json + from oslo_utils import fixture + from oslo_utils import timeutils +@@ -32,6 +34,7 @@ from testtools import matchers + from testtools import testcase + + from keystone import auth ++from keystone.auth.plugins import token as token_auth_plugin + from keystone.auth.plugins import totp + from keystone.common import authorization + from keystone.common import provider_api +@@ -6482,6 +6485,46 @@ class ApplicationCredentialAuth(test_v3. + app_cred_auth, expected_status=http.client.UNAUTHORIZED + ) + ++ def test_application_credential_token_cannot_rescope_via_token_method( ++ self, ++ ): ++ """An app-cred token must not be exchanged for any other token (LP#2158538). ++ ++ Previously, omitting `scope` on a token-method reauth request let ++ the new token fall through to the user's default-project scoping, ++ escaping the application credential's own project binding entirely. ++ """ ++ other_project_ref = unit.new_project_ref(domain_id=self.domain_id) ++ other_project = PROVIDERS.resource_api.create_project( ++ other_project_ref['id'], other_project_ref ++ ) ++ PROVIDERS.assignment_api.add_role_to_user_and_project( ++ self.user['id'], other_project['id'], self.role_id ++ ) ++ self.patch( ++ f'/users/{self.user["id"]}', ++ body={'user': {'default_project_id': other_project['id']}}, ++ ) ++ ++ app_cred = self._make_app_cred() ++ app_cred_ref = self.app_cred_api.create_application_credential( ++ app_cred ++ ) ++ auth_data = self.build_authentication_request( ++ app_cred_id=app_cred_ref['id'], secret=app_cred_ref['secret'] ++ ) ++ resp = self.v3_create_token( ++ auth_data, expected_status=http.client.CREATED ++ ) ++ app_cred_token = resp.headers.get('X-Subject-Token') ++ ++ # No explicit scope requested -- this must not silently default to ++ # the user's default project. ++ rescope_auth = self.build_authentication_request(token=app_cred_token) ++ self.v3_create_token( ++ rescope_auth, expected_status=http.client.FORBIDDEN ++ ) ++ + def test_application_credential_with_access_rules(self): + access_rules = [ + { +@@ -6601,3 +6644,138 @@ class ApplicationCredentialAuth(test_v3. + token_data = r.result['token'] + self.assertEqual(self.user['id'], token_data['user']['id']) + self.assertNotEqual(victim['id'], token_data['user']['id']) ++ ++ ++class Ec2CredentialTokenRescopeAuth(test_v3.RestfulTestCase): ++ """Tests for ec2credential tokens rescoping via token method (LP#2158538).""" ++ ++ def setUp(self): ++ super().setUp() ++ self.useFixture( ++ ksfixtures.KeyRepository( ++ self.config_fixture, ++ 'credential', ++ credential_fernet.MAX_ACTIVE_KEYS, ++ ) ++ ) ++ ++ def _get_ec2_sig_ref(self, blob): ++ 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_ec2credential_token_cannot_rescope_to_arbitrary_project(self): ++ """An ec2credential token must not be exchanged for another token. ++ ++ Unlike application_credential and trust/OAuth1, a plain ++ ec2credential-derived token carries no delegation marker that ++ token_authenticate() recognized, so it could previously be ++ exchanged via the token method for a token scoped to any project ++ the underlying user has a role on -- not just the project the EC2 ++ credential itself was bound to. ++ """ ++ other_project_ref = unit.new_project_ref(domain_id=self.domain_id) ++ other_project = PROVIDERS.resource_api.create_project( ++ other_project_ref['id'], other_project_ref ++ ) ++ PROVIDERS.assignment_api.add_role_to_user_and_project( ++ self.user_id, other_project['id'], self.role_id ++ ) ++ ++ r = self.post( ++ f'/users/{self.user_id}/credentials/OS-EC2', ++ body={'tenant_id': self.project_id}, ++ ) ++ ec2_cred = r.result['credential'] ++ blob = {'access': ec2_cred['access'], 'secret': ec2_cred['secret']} ++ r = self.post( ++ '/ec2tokens', ++ body={'ec2Credentials': self._get_ec2_sig_ref(blob)}, ++ expected_status=http.client.OK, ++ ) ++ ec2_token = r.headers.get('X-Subject-Token') ++ ++ rescope_auth = self.build_authentication_request( ++ token=ec2_token, project_id=other_project['id'] ++ ) ++ self.v3_create_token( ++ rescope_auth, expected_status=http.client.FORBIDDEN ++ ) ++ ++ ++class TokenAuthenticateGuardUnit(unit.BaseTestCase): ++ """Unit-level tests for token_authenticate's delegation guard. ++ ++ Calls token_authenticate directly with a mocked token, independent of ++ HTTP request handling. Mirrors ++ keystone.tests.unit.test_v3_trust.TestTrustGuardUnit -- same ++ _PRIMARY_AUTH_METHODS.issuperset([]) == True gap, different call site. ++ """ ++ ++ def _check(self, methods): ++ token = mock.Mock() ++ token.oauth_scoped = False ++ token.trust_scoped = False ++ token.system_scoped = False ++ token.application_credential = None ++ token.methods = methods ++ app = flask.Flask('test-token-authenticate-guard') ++ with app.test_request_context('/', json={'auth': {}}): ++ token_auth_plugin.token_authenticate(token) ++ ++ def test_rejects_empty_methods(self): ++ """An empty method list must be treated as delegated, not allowed. ++ ++ The fernet round-trip case: ec2credential/oauth2_credential have no ++ bit in the method bitmask, so a token carrying only one of those ++ decodes back to an empty list on any cache miss. Since ++ issuperset([]) is True, such a token used to pass this guard ++ entirely, allowing it to rescope via the token method. ++ """ ++ self.assertRaises(exception.ForbiddenAction, self._check, []) ++ ++ def test_rejects_ec2credential_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, self._check, ['ec2credential'] ++ ) ++ ++ def test_allows_password_token(self): ++ self._check(['password']) ++ ++ def test_allows_custom_primary_method_via_config(self): ++ """An operator-registered custom auth plugin (e.g. a site-specific ++ SSO integration) can rescope via the token method once listed in ++ [auth] additional_primary_auth_methods -- it is not permanently ++ treated as delegated just because it isn't a keystone built-in. ++ """ ++ CONF.set_override( ++ 'additional_primary_auth_methods', ['sso'], group='auth' ++ ) ++ self.addCleanup( ++ CONF.clear_override, 'additional_primary_auth_methods', ++ group='auth', ++ ) ++ self._check(['sso']) ++ ++ def test_unlisted_custom_method_still_rejected_by_default(self): ++ self.assertRaises( ++ exception.ForbiddenAction, self._check, ['sso'] ++ ) +Index: keystone/releasenotes/notes/bug-2158538-delegated-token-rescope-a1b2c3d4e5f6.yaml +=================================================================== +--- /dev/null ++++ keystone/releasenotes/notes/bug-2158538-delegated-token-rescope-a1b2c3d4e5f6.yaml +@@ -0,0 +1,25 @@ ++--- ++security: ++ - | ++ `LP#2158538 `_: ++ Tokens issued via delegated credentials (application credentials and ++ EC2 credentials) could be exchanged for a broader-scoped token using ++ the token authentication method. ++ ++ An application credential token could omit the ``scope`` parameter ++ during token-method reauthentication, causing the new token to fall ++ through to the user's default project, escaping the application ++ credential's project binding. EC2 credential tokens had no such guard ++ and could be re-scoped to any project the underlying user has a role ++ on, not just the project the EC2 credential was bound to. ++ ++upgrade: ++ - | ++ `LP#2158538 `_: ++ Authenticating with the token method using an application credential ++ or EC2 credential token is now rejected with HTTP 403 (Forbidden). ++ Previously, application credential tokens without an explicit scope ++ would fall through to the user's default project, and EC2 credential ++ tokens could be re-scoped to any project. Callers that relied on ++ exchanging an application credential token via the token method must ++ authenticate with the application credential directly instead. +Index: keystone/keystone/tests/unit/test_contrib_ec2_core.py +=================================================================== +--- keystone.orig/keystone/tests/unit/test_contrib_ec2_core.py ++++ keystone/keystone/tests/unit/test_contrib_ec2_core.py +@@ -313,7 +313,5 @@ class EC2ContribCoreV3(test_v3.RestfulTe + } + }, + token=token, +- expected_status=http.client.CREATED, ++ expected_status=http.client.FORBIDDEN, + ) +- ec2_token = resp.headers['X-Subject-Token'] +- self.assertIn('ec2credential', resp.json['token']['methods']) diff -Nru keystone-27.0.0/debian/patches/CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch keystone-27.0.0/debian/patches/CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch --- keystone-27.0.0/debian/patches/CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch 2026-08-28 07:41:35.000000000 +0000 @@ -0,0 +1,107 @@ +From 27b649b1155bc7e7e518e4e606057e30c3ace4b2 Mon Sep 17 00:00:00 2001 +From: Artem Goncharov +Date: Tue, 04 Aug 2026 13:51:03 +0200 +Subject: [PATCH] Prevent unauthorized project-scoped assignment list + +Any user holding `role:reader` on any project could list all role +assignments under any domain by passing a domain ID as +`scope.project.id` to `GET /v3/role_assignments?include_subtree`. +Domain projects store `domain_id=null`, which matched the `null` +`domain_id` of any project-scoped token in the oslo.policy string +comparison, bypassing the domain-reader restriction. An explicit `not +None:%(target.domain_id)s` guard has been added to the affected policy +rules. + +Closes-Bug: #2154645 +Change-Id: Ice4a656e8deefd8538e10ad8d8d1fb12a3e3d12d +Co-Authored-By: Grzegorz Grasza +Signed-off-by: Artem Goncharov +(cherry picked from commit eb27f4e309708746a8e7a5f0ad7e9a510e47ff0b) +--- + +diff --git a/keystone/common/policies/role_assignment.py b/keystone/common/policies/role_assignment.py +index 76c9182..10137e5 100644 +--- a/keystone/common/policies/role_assignment.py ++++ b/keystone/common/policies/role_assignment.py +@@ -17,7 +17,8 @@ + + SYSTEM_READER_OR_DOMAIN_READER = ( + '(' + base.SYSTEM_READER + ') or ' +- '(role:reader and domain_id:%(target.domain_id)s)' ++ '(role:reader and domain_id:%(target.domain_id)s and ' ++ 'not None:%(target.domain_id)s)' + ) + ADMIN_OR_SYSTEM_READER_OR_DOMAIN_READER = ( + '(' + base.RULE_ADMIN_REQUIRED + ') or ' + SYSTEM_READER_OR_DOMAIN_READER +diff --git a/keystone/tests/protection/v3/test_assignment.py b/keystone/tests/protection/v3/test_assignment.py +index 8c5fdc4..9345d4c 100644 +--- a/keystone/tests/protection/v3/test_assignment.py ++++ b/keystone/tests/protection/v3/test_assignment.py +@@ -1207,6 +1207,34 @@ + + + class _ProjectReaderMemberTests: ++ def test_user_cannot_list_assignments_for_tree_using_domain_id(self): ++ # Regression test for LP#2154645: a project-scoped user must not be ++ # able to bypass domain isolation by passing a domain ID in the ++ # scope.project.id parameter of the include_subtree API. The policy ++ # rule for list_role_assignments_for_tree uses ++ # domain_id:%(target.domain_id)s where target.domain_id is derived ++ # from the referenced project's domain_id field. For a domain project ++ # (is_domain=True) that field is NULL, which previously matched the ++ # NULL domain_id of any project-scoped token, granting unintended ++ # access to the entire domain's role assignments. ++ other_domain = PROVIDERS.resource_api.create_domain( ++ uuid.uuid4().hex, unit.new_domain_ref() ++ ) ++ # Confirm the domain's "project" record has a null domain_id, which ++ # is the prerequisite for the bypass. ++ domain_project = PROVIDERS.resource_api.get_project(other_domain['id']) ++ self.assertIsNone(domain_project['domain_id']) ++ ++ with self.test_client() as c: ++ c.get( ++ '/v3/role_assignments' ++ '?scope.project.id={}&include_subtree'.format( ++ other_domain['id'] ++ ), ++ headers=self.headers, ++ expected_status_code=http.client.FORBIDDEN, ++ ) ++ + def test_user_cannot_list_assignments_for_subtree(self): + user = PROVIDERS.identity_api.create_user( + unit.new_user_ref(domain_id=self.domain_id) +diff --git a/releasenotes/notes/bug-2154645-role-assignment-tree-domain-bypass-f126c413bd62c375.yaml b/releasenotes/notes/bug-2154645-role-assignment-tree-domain-bypass-f126c413bd62c375.yaml +new file mode 100644 +index 0000000..5b1692b +--- /dev/null ++++ b/releasenotes/notes/bug-2154645-role-assignment-tree-domain-bypass-f126c413bd62c375.yaml +@@ -0,0 +1,27 @@ ++--- ++security: ++ - | ++ `LP#2154645 `_: ++ Any user holding ``role:reader`` on any project could list all role ++ assignments under any domain by passing a domain ID as ++ ``scope.project.id`` to ``GET /v3/role_assignments?include_subtree``. ++ Domain projects store ``domain_id=null``, which matched the ``null`` ++ ``domain_id`` of any project-scoped token in the oslo.policy string ++ comparison, bypassing the domain-reader restriction. ++ An explicit ``not None:%(target.domain_id)s`` guard has been added ++ to the affected policy rules. ++upgrade: ++ - | ++ `LP#2154645 `_: ++ If you have overridden ``identity:list_role_assignments`` or ++ ``identity:list_role_assignments_for_tree`` in ``policy.yaml``, ++ add ``not None:%(target.domain_id)s`` to any branch that contains ++ ``domain_id:%(target.domain_id)s``, for example:: ++ ++ identity:list_role_assignments_for_tree: >- ++ (rule:admin_required) or ++ (role:reader and system_scope:all) or ++ (role:reader and domain_id:%(target.domain_id)s and ++ not None:%(target.domain_id)s) ++ ++ Deployments using the default policies are protected automatically. diff -Nru keystone-27.0.0/debian/patches/series keystone-27.0.0/debian/patches/series --- keystone-27.0.0/debian/patches/series 2026-05-25 14:39:48.000000000 +0000 +++ keystone-27.0.0/debian/patches/series 2026-08-28 07:41:35.000000000 +0000 @@ -11,3 +11,8 @@ 0004-Enforce-app-cred-project-boundary-on-EC2-credential-.patch 0005-Use-branch-constraints-for-tempest-venv-on-stable-20.patch CVE-2026-43001-2025.1.v4.patch +CVE-2026-80182_CVE-2026-80184_1_Block_app_credential_token_rescoping.patch +CVE-2026-80182_CVE-2026-80184_2_auth_encode_ec2credential_and_oauth2_credential_in_the_method_bitmask.patch +CVE-2026-80182_CVE-2026-80184_3_trusts_oauth1_app-creds_reject_delegated_tokens_across_all_endpoints.patch +CVE-2026-80182_CVE-2026-80184_4_auth_reject_delegated_tokens_from_token-method_reauthentication.patch +CVE-2026-80183_Prevent_unauthorized_project-scoped_assignment_list.patch