Version in base suite: 27.0.0-3+deb13u5 Base version: keystone_27.0.0-3+deb13u5 Target version: keystone_27.0.0-3+deb13u6 Base file: /srv/ftp-master.debian.org/ftp/pool/main/k/keystone/keystone_27.0.0-3+deb13u5.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/k/keystone/keystone_27.0.0-3+deb13u6.dsc changelog | 12 patches/CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch | 1525 ++++++++++ patches/series | 1 3 files changed, 1538 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpy08rku5q/keystone_27.0.0-3+deb13u5.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpy08rku5q/keystone_27.0.0-3+deb13u6.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-08-28 07:41:35.000000000 +0000 +++ keystone-27.0.0/debian/changelog 2026-10-06 18:06:28.000000000 +0000 @@ -1,3 +1,15 @@ +keystone (2:27.0.0-3+deb13u6) trixie-security; urgency=medium + + * CVE-2026-90460: Delegated tokens (like ec2credential, + application_credential, ...) can GET/PATCH/DELETE any credential including + TOTP seeds. Delegation project boundary not enforced on PATCH + /v3/credentials/{id}. Applied upstream patch: "credentials: reject + delegated tokens, restrict PATCH to blob only" (Closes: #1149303, + #1149956): + - CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_re....patch + + -- Thomas Goirand Tue, 06 Oct 2026 20:06:28 +0200 + keystone (2:27.0.0-3+deb13u5) trixie-security; urgency=medium * CVE-2026-80184: Delegation bypass in trust, OAuth1, and application diff -Nru keystone-27.0.0/debian/patches/CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch keystone-27.0.0/debian/patches/CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch --- keystone-27.0.0/debian/patches/CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch 1970-01-01 00:00:00.000000000 +0000 +++ keystone-27.0.0/debian/patches/CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch 2026-10-06 18:06:28.000000000 +0000 @@ -0,0 +1,1525 @@ +Description: credentials: reject delegated tokens, restrict PATCH to blob only + Delegated tokens (application_credential, OAuth1, trust, and + ec2credential) could read, create, modify, or delete credentials + via /v3/credentials and the OS-EC2 compat endpoint. The existing + guards only compared project_id and never treated ec2credential + as delegated. + . + Replace those guards with a primary-auth-only gate: any token + whose methods aren't entirely primary auth methods, or that + carries a trust_id, is rejected from GET/POST/PATCH/DELETE on + those endpoints. This also removes trust/OAuth1 EC2 credential + creation, never a deliberate feature (bug 1242597 / + OSSA-2013-032). + . + PATCH now only accepts `blob`; `type`, `user_id`, and + `project_id` are immutable after creation. Delete and recreate + the credential to change them. + . + Narrow the existing, deprecated + allow_insecure_admin_trust_cross_project_credentials_access + option (LP#2150089) to ec2-type credentials only, matching its + documented use case: an admin-role trust used to list/sync EC2 + credentials. + . + An empty token.methods list is treated as delegated. + . + Uses keystone.api._shared.delegation from the LP#2153453 change. + . +Author: Grzegorz Grasza +Date: Tue, 21 Jul 2026 13:06:22 +0200 +Bug: https://launchpad.net/bugs/2159643 +Bug: https://launchpad.net/bugs/2158931 +Bug-Debian: https://bugs.debian.org/1149303 +Bug-Debian: https://bugs.debian.org/1149956 +Origin: upstream, https://review.opendev.org/c/openstack/keystone/+/1002335 +Depends-On: https://review.opendev.org/c/openstack/tempest/+/1002295 +Depends-On: https://review.opendev.org/c/openstack/keystone-tempest-plugin/+/1002296 +Change-Id: I0d0c0e1a1d0728655e559fbe12ed3fd117d17d33 +Assisted-by: Cursor Grok 4.6 +Signed-off-by: Grzegorz Grasza +Last-Update: 2026-10-09 +--- + +diff --git a/api-ref/source/v3/credentials.inc b/api-ref/source/v3/credentials.inc +index 46d282b..b932c7f 100644 +--- a/api-ref/source/v3/credentials.inc ++++ b/api-ref/source/v3/credentials.inc +@@ -220,6 +220,10 @@ + + Updates a credential. + ++Only ``blob`` may be updated. ``project_id``, ``type``, and ``user_id`` are ++immutable after creation; to change any of those, delete the credential and ++create a new one. ++ + Relationship: ``https://docs.openstack.org/api/openstack-identity/3/rel/credential`` + + Request +@@ -232,10 +236,7 @@ + + - credential_id: credential_id_path + - credential: credential +- - project_id: project_id +- - type: credential_type_not_required + - blob: credential_blob_not_required +- - user_id: credential_user_id_not_required + + Example + ~~~~~~~ +diff --git a/api-ref/source/v3/samples/admin/credential-update-request.json b/api-ref/source/v3/samples/admin/credential-update-request.json +index 50c9e77..76cd56e 100644 +--- a/api-ref/source/v3/samples/admin/credential-update-request.json ++++ b/api-ref/source/v3/samples/admin/credential-update-request.json +@@ -1,8 +1,5 @@ + { + "credential": { +- "blob": "{\"access\":\"181920\",\"secret\":\"secretKey\"}", +- "project_id": "731fc6f265cd486d900f16e84c5cb594", +- "type": "ec2", +- "user_id": "bb5476fd12884539b41d5a88f838d773" ++ "blob": "{\"access\":\"181920\",\"secret\":\"secretKey\"}" + } + } +diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py +index 2844053..f789234 100644 +--- a/keystone/api/credentials.py ++++ b/keystone/api/credentials.py +@@ -18,6 +18,7 @@ + import flask + from oslo_serialization import jsonutils + ++from keystone.api._shared import delegation + from keystone.api import validation + from keystone.common import json_home + from keystone.common import provider_api +@@ -33,56 +34,37 @@ + 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 _require_primary_auth(token, oslo_context, credential_type): ++ """Reject delegated tokens from touching another credential at all. + ++ Trust delegation is a scope, not a method -- a trust-scoped token's ++ methods list reflects whatever primary method was used to obtain it, ++ so it's checked separately via oslo_context.trust_id. This also drops ++ trust-scoped EC2 credential creation, never a deliberate feature (bug ++ 1242597 / OSSA-2013-032). + +-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. ++ allow_insecure_admin_trust_cross_project_credentials_access exempts ++ admin-role delegated tokens, but only for ec2-type credentials, ++ matching its one use case (LP#2150089 comment #57). See LP#2159643. + """ + 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): ++ if not (trust_id or delegation.is_delegated_method(token)): + 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: +- # 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: +- pass +- raise exception.ForbiddenAction( +- action=_( +- 'Credential project does not match the ' +- 'project scope of the delegated token' +- ) +- ) ++ insecure_cross_project = getattr( ++ CONF.security_compliance, ++ 'allow_insecure_admin_trust_cross_project_credentials_access', ++ ) ++ if insecure_cross_project and (credential_type or '').lower() == 'ec2': ++ # 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=_('Delegated tokens cannot access credentials directly') ++ ) + + + def _build_target_enforcement(): +@@ -210,17 +192,18 @@ + token = self.auth_context['token'] + filtered_refs = [] + for ref in refs: +- # Check each credential again to make sure the user has access to +- # it, either by owning it, being a project admin with ++ # Check each credential to make sure the user has access to it, ++ # either by owning it, being a project admin with + # enforce_scope=false, being a system user, or having some other + # custom policy that allows access. + try: +- cred = PROVIDERS.credential_api.get_credential(ref['id']) + ENFORCER.enforce_call( + action='identity:get_credential', +- target_attr={'credential': cred}, ++ target_attr={'credential': ref}, + ) +- _check_credential_project_scope(token, self.oslo_context, cred) ++ _require_primary_auth( ++ token, self.oslo_context, ref.get('type') ++ ) + filtered_refs.append(ref) + except (exception.Forbidden, exception.ForbiddenAction): + pass +@@ -242,12 +225,10 @@ + action='identity:create_credential', target_attr=target + ) + 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(token, 'application_credential_id', None) + access_token_id = getattr(token, 'access_token_id', None) +- _check_credential_project_scope(token, self.oslo_context, credential) ++ _require_primary_auth(token, self.oslo_context, credential.get('type')) + ref = self._assign_unique_id( + self._normalize_dict(credential), + trust_id=trust_id, +@@ -293,8 +274,10 @@ + 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 ++ _require_primary_auth( ++ self.auth_context['token'], ++ self.oslo_context, ++ credential.get('type'), + ) + return self.wrap_member(_blob_to_json(credential)) + +@@ -310,8 +293,8 @@ + 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 ++ _require_primary_auth( ++ self.auth_context['token'], self.oslo_context, current.get('type') + ) + credential = self.request_body_json.get('credential', {}) + self._validate_blob_update_keys(current.copy(), credential.copy()) +@@ -336,8 +319,10 @@ + 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 ++ _require_primary_auth( ++ self.auth_context['token'], ++ self.oslo_context, ++ credential.get('type'), + ) + + return ( +diff --git a/keystone/api/users.py b/keystone/api/users.py +index 2661402..fbb7c23 100644 +--- a/keystone/api/users.py ++++ b/keystone/api/users.py +@@ -97,28 +97,17 @@ + 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 _require_primary_auth_for_ec2(oslo_context, token): ++ """Reject delegated tokens from the OS-EC2 compat endpoints entirely. + +- +-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. ++ Matches keystone.api.credentials._require_primary_auth, without its ++ ec2-only escape hatch: no legitimate use case needs delegated access to ++ these endpoints. See LP#2159643. + """ +- if not _is_delegated_token(oslo_context, token): +- return +- if project_id != oslo_context.project_id: ++ trust_id = getattr(oslo_context, 'trust_id', None) ++ if trust_id or delegation.is_delegated_method(token): + raise ks_exception.ForbiddenAction( +- action=_( +- 'EC2 credential project does not match the ' +- 'project scope of the delegated token' +- ) ++ action=_('Delegated tokens cannot access credentials directly') + ) + + +@@ -472,20 +461,15 @@ + GET/HEAD /v3/users/{user_id}/credentials/OS-EC2 + """ + ENFORCER.enforce_call(action='identity:ec2_list_credentials') ++ token = self.auth_context['token'] ++ _require_primary_auth_for_ec2(self.oslo_context, token) + PROVIDERS.identity_api.get_user(user_id) + credential_refs = PROVIDERS.credential_api.list_credentials_for_user( + 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)) ++ collection_refs = [ ++ _convert_v3_to_ec2_credential(cred) for cred in credential_refs ++ ] + return self.wrap_collection(collection_refs) + + def post(self, user_id): +@@ -499,11 +483,10 @@ + 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) +- _check_delegation_for_ec2(self.oslo_context, token, tenant_id) ++ _require_primary_auth_for_ec2(self.oslo_context, token) + blob = { + 'access': uuid.uuid4().hex, + 'secret': uuid.uuid4().hex, +@@ -544,10 +527,8 @@ + PROVIDERS.identity_api.get_user(user_id) + ec2_cred_id = utils.hash_access_key(credential_id) + cred = self._get_raw_cred(ec2_cred_id) +- _check_delegation_for_ec2( +- self.oslo_context, +- self.auth_context['token'], +- cred.get('project_id'), ++ _require_primary_auth_for_ec2( ++ self.oslo_context, self.auth_context['token'] + ) + return self.wrap_member(_convert_v3_to_ec2_credential(cred)) + +@@ -562,11 +543,9 @@ + ) + PROVIDERS.identity_api.get_user(user_id) + ec2_cred_id = utils.hash_access_key(credential_id) +- cred = self._get_raw_cred(ec2_cred_id) +- _check_delegation_for_ec2( +- self.oslo_context, +- self.auth_context['token'], +- cred.get('project_id'), ++ self._get_raw_cred(ec2_cred_id) ++ _require_primary_auth_for_ec2( ++ self.oslo_context, self.auth_context['token'] + ) + PROVIDERS.credential_api.delete_credential(ec2_cred_id) + return None, http.client.NO_CONTENT +diff --git a/keystone/conf/security_compliance.py b/keystone/conf/security_compliance.py +index 251629a..0465155 100644 +--- a/keystone/conf/security_compliance.py ++++ b/keystone/conf/security_compliance.py +@@ -165,18 +165,17 @@ + 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. ++INSECURE: By default (False), delegated tokens (trusts, application ++credentials, OAuth1 access tokens, EC2 credentials) cannot access ++/v3/credentials or the OS-EC2 compat endpoints at all -- see LP#2159643. ++When enabled, admin-role delegated tokens are exempted for ec2-type ++credentials only, including across projects; every other type stays ++blocked regardless of role. + +-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. ++Enable this only for automated workflows (e.g. Mistral cron triggers) that ++use admin-role trusts to list/sync EC2 credentials across projects and ++cannot migrate to non-delegated service account credentials. Weakens the ++isolation from LP#2150089 and LP#2159643. Deprecated for removal. + """ + ), + ) +diff --git a/keystone/credential/core.py b/keystone/credential/core.py +index 82b5220..b92fa86 100644 +--- a/keystone/credential/core.py ++++ b/keystone/credential/core.py +@@ -139,23 +139,19 @@ + notifications.Audit.created(self._CRED, credential_id, initiator) + return ref + +- def _validate_credential_update(self, credential_id, credential): +- # ec2 credentials require a "project_id" to be functional. Before we +- # update, check the case where a non-ec2 credential changes its type +- # to be "ec2", but has no associated "project_id", either in the +- # request or already set in the database +- if credential.get('type', '').lower() == 'ec2' and not credential.get( +- 'project_id' +- ): +- existing_cred = self.get_credential(credential_id) +- if not existing_cred['project_id']: +- raise exception.ValidationError( +- attribute='project_id', target='credential' +- ) +- + def update_credential(self, credential_id, credential): +- """Update an existing credential.""" +- self._validate_credential_update(credential_id, credential) ++ """Update an existing credential. ++ ++ Only `blob` is accepted here: the API layer's PATCH schema enforces ++ this (see LP#2159643 -- retyping, rescoping, or reassigning a ++ credential in place was a repeated source of security issues), so ++ `type`, `user_id`, and `project_id` are never present in ++ `credential`. There is currently no other caller of this method ++ that passes those fields either (keystone-manage credential_migrate ++ only ever passes `blob`). Do not reintroduce type/project_id/ ++ user_id-specific validation here without also reintroducing a way ++ for a caller to actually change them. ++ """ + if 'blob' in credential: + credential_copy = self._encrypt_credential(credential) + else: +diff --git a/keystone/credential/schema.py b/keystone/credential/schema.py +index 75ffd63..c6bc0a2 100644 +--- a/keystone/credential/schema.py ++++ b/keystone/credential/schema.py +@@ -127,15 +127,19 @@ + } + + # Request body of the `PATCH /credentials/{credential_id}` operation ++# ++# Only `blob` may be updated in place. `id`, `type`, `user_id`, and ++# `project_id` are immutable after creation. To change any of those, ++# delete the credential and create a new one. + update_request_body: dict[str, Any] = { + "type": "object", + "description": "A credential object.", + "properties": { + "credential": { + "type": "object", +- "properties": _credential_properties, +- "additionalProperties": True, +- "minProperties": 1, ++ "properties": {"blob": _credential_properties["blob"]}, ++ "additionalProperties": False, ++ "required": ["blob"], + } + }, + "required": ["credential"], +diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py +index 28b43bb..bf321f8 100644 +--- a/keystone/tests/unit/test_v3_credential.py ++++ b/keystone/tests/unit/test_v3_credential.py +@@ -23,7 +23,9 @@ + from oslo_db import exception as oslo_db_exception + from testtools import matchers + ++from keystone.api import credentials as credentials_api + from keystone.api import ec2tokens ++from keystone.api import users as users_api + from keystone.common import provider_api + from keystone.common import utils + from keystone.credential.providers import fernet as credential_fernet +@@ -267,52 +269,23 @@ + + def test_update_credential(self): + """Call ``PATCH /credentials/{credential_id}``.""" +- ref = unit.new_credential_ref( +- user_id=self.user['id'], project_id=self.project_id +- ) +- del ref['id'] ++ update_ref = {'blob': uuid.uuid4().hex} + r = self.patch( + '/credentials/{credential_id}'.format( + credential_id=self.credential['id'] + ), +- body={'credential': ref}, +- ) +- self.assertValidCredentialResponse(r, ref) +- +- def test_update_credential_to_ec2_type(self): +- """Call ``PATCH /credentials/{credential_id}``.""" +- # Create a credential without providing a project_id +- ref = unit.new_credential_ref(user_id=self.user['id']) +- r = self.post('/credentials', body={'credential': ref}) +- self.assertValidCredentialResponse(r, ref) +- credential_id = r.result.get('credential')['id'] +- +- # Updating the credential to ec2 requires a project_id +- update_ref = {'type': 'ec2', 'project_id': self.project_id} +- self.patch( +- f'/credentials/{credential_id}', body={'credential': update_ref} +- ) +- +- def test_update_credential_to_ec2_missing_project_id(self): +- """Call ``PATCH /credentials/{credential_id}``.""" +- # Create a credential without providing a project_id +- ref = unit.new_credential_ref(user_id=self.user['id']) +- r = self.post('/credentials', body={'credential': ref}) +- self.assertValidCredentialResponse(r, ref) +- credential_id = r.result.get('credential')['id'] +- +- # Updating such credential to ec2 type without providing a project_id +- # will fail +- update_ref = {'type': 'ec2'} +- self.patch( +- f'/credentials/{credential_id}', + body={'credential': update_ref}, +- expected_status=http.client.BAD_REQUEST, + ) ++ expected = dict(self.credential, **update_ref) ++ self.assertValidCredentialResponse(r, expected) + +- def test_update_credential_to_ec2_with_previously_set_project_id(self): +- """Call ``PATCH /credentials/{credential_id}``.""" +- # Create a credential providing a project_id ++ def test_update_credential_rejects_non_blob_fields(self): ++ """PATCH only accepts `blob` (LP#2159643). ++ ++ `type`, `project_id`, and `user_id` are all immutable after ++ creation. The only way to change any of those is to delete the ++ credential and create a new one. ++ """ + ref = unit.new_credential_ref( + user_id=self.user['id'], project_id=self.project_id + ) +@@ -320,12 +293,20 @@ + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + +- # Since the created credential above already has a project_id, the +- # update request will not fail +- update_ref = {'type': 'ec2'} +- self.patch( +- f'/credentials/{credential_id}', body={'credential': update_ref} +- ) ++ for update_ref in ( ++ {'type': 'ec2', 'project_id': self.project_id}, ++ {'project_id': self.project_id}, ++ {'user_id': uuid.uuid4().hex}, ++ ): ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': update_ref}, ++ expected_status=http.client.BAD_REQUEST, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual(ref['type'], stored['type']) ++ self.assertEqual(self.project_id, stored['project_id']) ++ self.assertEqual(self.user['id'], stored['user_id']) + + def test_update_credential_non_owner(self): + """Call ``PATCH /credentials/{credential_id}``.""" +@@ -354,11 +335,12 @@ + self.assertValidCredentialResponse(r, ref) + credential_id = r.result.get('credential')['id'] + +- # Cannot change the credential to be owned by another user ++ # Cannot change the credential to be owned by another user -- PATCH ++ # no longer accepts user_id/project_id at all. See LP#2159643. + update_ref = {'user_id': self.user_id, 'project_id': self.project_id} + self.patch( + f'/credentials/{credential_id}', +- expected_status=403, ++ expected_status=http.client.BAD_REQUEST, + auth=auth, + body={'credential': update_ref}, + ) +@@ -471,6 +453,21 @@ + expected_status=http.client.BAD_REQUEST, + ) + ++ def test_cannot_change_credential_type_via_patch(self): ++ """PATCH rejects a type change; only blob is mutable.""" ++ ref = unit.new_credential_ref( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ credential_id = r.result['credential']['id'] ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': {'type': 'ec2'}}, ++ expected_status=http.client.BAD_REQUEST, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual(ref['type'], stored['type']) ++ + def test_delete_credential(self): + """Call ``DELETE /credentials/{credential_id}``.""" + self.delete( +@@ -649,12 +646,19 @@ + super().config_overrides() + self.config_fixture.config(group='trust') + +- def test_trust_scoped_ec2_credential(self): +- """Test creating trust scoped ec2 credential. ++ def test_trust_scoped_token_cannot_create_ec2_credential(self): ++ """A trust-scoped token cannot create any credential (LP#2159643). + +- Call ``POST /credentials``. ++ Trust-scoped tokens creating EC2 credentials was never a ++ deliberately-designed feature: it dates back to bug 1242597 / ++ OSSA-2013-032, where trust tokens creating EC2 credentials already ++ existed generically and the ec2tokens exchange did not preserve ++ trust scoping, allowing privilege escalation to the trustor's ++ full role set. That bug's fix embedded trust_id in the blob ++ rather than removing the underlying capability. Delegated tokens ++ (trust included) are now rejected from /v3/credentials outright, ++ which removes this path rather than carving it out. + """ +- # Create the trust + ref = unit.new_trust_ref( + trustor_user_id=self.user_id, + trustee_user_id=self.trustee_user_id, +@@ -667,7 +671,6 @@ + r = self.post('/OS-TRUST/trusts', body={'trust': ref}) + trust = self.assertValidTrustResponse(r) + +- # Get a trust scoped token + auth_data = self.build_authentication_request( + user_id=self.trustee_user['id'], + password=self.trustee_user['password'], +@@ -675,53 +678,16 @@ + ) + r = self.v3_create_token(auth_data) + self.assertValidProjectScopedTokenResponse(r, self.user) +- trust_id = r.result['token']['OS-TRUST:trust']['id'] + token_id = r.headers.get('X-Subject-Token') + +- # Create the credential with the trust scoped token +- blob, ref = unit.new_ec2_credential( ++ _, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=self.project_id + ) +- r = self.post('/credentials', body={'credential': ref}, token=token_id) +- +- # We expect the response blob to contain the trust_id +- ret_ref = ref.copy() +- ret_blob = blob.copy() +- ret_blob['trust_id'] = trust_id +- ret_ref['blob'] = json.dumps(ret_blob) +- self.assertValidCredentialResponse(r, ref=ret_ref) +- +- # Assert credential id is same as hash of access key id for +- # ec2 credentials +- access = blob['access'].encode('utf-8') +- self.assertEqual( +- hashlib.sha256(access).hexdigest(), r.result['credential']['id'] +- ) +- +- # Create a role assignment to ensure that it is ignored and only the +- # trust-delegated roles are used +- role = unit.new_role_ref(name='reader') +- role_id = role['id'] +- PROVIDERS.role_api.create_role(role_id, role) +- PROVIDERS.assignment_api.add_role_to_user_and_project( +- self.user_id, self.project_id, role_id +- ) +- +- ret_blob = json.loads(r.result['credential']['blob']) +- ec2token = self._test_get_token( +- access=ret_blob['access'], secret=ret_blob['secret'] +- ) +- ec2_roles = [role['id'] for role in ec2token['roles']] +- self.assertIn(self.role_id, ec2_roles) +- self.assertNotIn(role_id, ec2_roles) +- +- # Create second ec2 credential with the same access key id and check +- # for conflict. + self.post( + '/credentials', + body={'credential': ref}, + token=token_id, +- expected_status=http.client.CONFLICT, ++ expected_status=http.client.FORBIDDEN, + ) + + def _get_trust_token(self): +@@ -743,6 +709,38 @@ + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + ++ def test_trust_token_cannot_rescope_credential_project(self): ++ """A credential's project_id can never be moved via PATCH. ++ ++ Originally, the project-boundary check only validated the stored ++ (pre-image) project_id, not the requested one, so a delegated ++ token could PATCH an in-scope credential to move it to any other ++ project. PATCH no longer accepts project_id at all, for any ++ token, which closes this more generally. See LP#2159643. ++ """ ++ 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 ++ ) ++ ref = unit.new_credential_ref( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ cred_id = r.result['credential']['id'] ++ ++ trust_token = self._get_trust_token() ++ self.patch( ++ f'/credentials/{cred_id}', ++ body={'credential': {'project_id': other_project['id']}}, ++ token=trust_token, ++ expected_status=http.client.BAD_REQUEST, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(cred_id) ++ self.assertEqual(self.project_id, stored['project_id']) ++ + def test_trust_token_cannot_list_totp_credentials(self): + """Trust-scoped token must not see TOTP/MFA credentials (project_id=None). + +@@ -781,6 +779,89 @@ + expected_status=http.client.FORBIDDEN, + ) + ++ def test_escape_hatch_allows_admin_trust_token_to_read_ec2_credential( ++ self, ++ ): ++ """The escape hatch's one use case still works: admin trust + ec2.""" ++ self.config_fixture.config( ++ group='security_compliance', ++ allow_insecure_admin_trust_cross_project_credentials_access=True, ++ ) ++ blob, ref = unit.new_ec2_credential( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ ec2_id = r.result['credential']['id'] ++ ++ # _get_trust_token() grants self.role_id, which load_sample_data() ++ # creates as the "admin" role. ++ trust_token = self._get_trust_token() ++ r = self.get(f'/credentials/{ec2_id}', token=trust_token) ++ self.assertEqual( ++ blob['access'], ++ json.loads(r.result['credential']['blob'])['access'], ++ ) ++ ++ def test_escape_hatch_still_blocks_non_ec2_credential_regardless_of_role( ++ self, ++ ): ++ """The escape hatch never exempts non-ec2 credentials.""" ++ self.config_fixture.config( ++ group='security_compliance', ++ allow_insecure_admin_trust_cross_project_credentials_access=True, ++ ) ++ ref = unit.new_credential_ref(user_id=self.user_id) ++ r = self.post('/credentials', body={'credential': ref}) ++ credential_id = r.result['credential']['id'] ++ ++ trust_token = self._get_trust_token() ++ self.get( ++ f'/credentials/{credential_id}', ++ token=trust_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_escape_hatch_still_blocks_non_admin_trust_token(self): ++ """The insecure escape hatch does not exempt non-admin-role tokens.""" ++ self.config_fixture.config( ++ group='security_compliance', ++ allow_insecure_admin_trust_cross_project_credentials_access=True, ++ ) ++ _, ref = unit.new_ec2_credential( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ ec2_id = r.result['credential']['id'] ++ ++ member_role = unit.new_role_ref(name='member') ++ PROVIDERS.role_api.create_role(member_role['id'], member_role) ++ PROVIDERS.assignment_api.add_role_to_user_and_project( ++ self.user_id, self.project_id, member_role['id'] ++ ) ++ 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=[member_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=self.trustee_user['id'], ++ password=self.trustee_user['password'], ++ trust_id=trust['id'], ++ ) ++ r = self.v3_create_token(auth_data) ++ trust_token = r.headers.get('X-Subject-Token') ++ ++ self.get( ++ f'/credentials/{ec2_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 = { +@@ -795,7 +876,7 @@ + self.patch( + f'/credentials/{totp_id}', + token=trust_token, +- body={'credential': totp_ref}, ++ body={'credential': {'blob': totp_ref['blob']}}, + expected_status=http.client.FORBIDDEN, + ) + +@@ -884,13 +965,12 @@ + ) + ) + +- def test_app_cred_ec2_credential(self): +- """Test creating ec2 credential from an application credential. ++ def test_app_cred_cannot_create_ec2_credential(self): ++ """An application credential cannot create any credential (LP#2159643). + +- Call ``POST /credentials``. ++ Delegated tokens (application credentials included) are now ++ rejected from /v3/credentials outright. + """ +- # 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 +@@ -900,57 +980,20 @@ + ) + app_cred = r.result['application_credential'] + +- # Get an application credential token + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + token_id = r.headers.get('X-Subject-Token') + +- # Create the credential with the app cred token +- blob, ref = unit.new_ec2_credential( ++ _, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=self.project_id + ) +- r = self.post('/credentials', body={'credential': ref}, token=token_id) +- +- # We expect the response blob to contain the app_cred_id +- ret_ref = ref.copy() +- ret_blob = blob.copy() +- ret_blob['app_cred_id'] = app_cred['id'] +- ret_ref['blob'] = json.dumps(ret_blob) +- self.assertValidCredentialResponse(r, ref=ret_ref) +- +- # Assert credential id is same as hash of access key id for +- # ec2 credentials +- access = blob['access'].encode('utf-8') +- self.assertEqual( +- hashlib.sha256(access).hexdigest(), r.result['credential']['id'] +- ) +- +- # Create a role assignment to ensure that it is ignored and only the +- # roles in the app cred are used +- role = unit.new_role_ref(name='reader') +- role_id = role['id'] +- PROVIDERS.role_api.create_role(role_id, role) +- PROVIDERS.assignment_api.add_role_to_user_and_project( +- self.user_id, self.project_id, role_id +- ) +- +- ret_blob = json.loads(r.result['credential']['blob']) +- ec2token = self._test_get_token( +- access=ret_blob['access'], secret=ret_blob['secret'] +- ) +- ec2_roles = [role['id'] for role in ec2token['roles']] +- self.assertIn(self.role_id, ec2_roles) +- self.assertNotIn(role_id, ec2_roles) +- +- # Create second ec2 credential with the same access key id and check +- # for conflict. + self.post( + '/credentials', + body={'credential': ref}, + token=token_id, +- expected_status=http.client.CONFLICT, ++ expected_status=http.client.FORBIDDEN, + ) + + def _get_app_cred_token(self, unrestricted=False): +@@ -970,6 +1013,64 @@ + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + ++ def test_app_cred_token_cannot_patch_credential(self): ++ """An application-credential token cannot PATCH /v3/credentials.""" ++ token_id = self._get_app_cred_token(unrestricted=True) ++ ref = unit.new_credential_ref( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ credential_id = r.result['credential']['id'] ++ ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': {'type': 'ec2'}}, ++ token=token_id, ++ expected_status=http.client.BAD_REQUEST, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual('cert', stored['type']) ++ ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': {'blob': 'rotated-blob'}}, ++ token=token_id, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual('cert', stored['type']) ++ self.assertEqual(self.project_id, stored['project_id']) ++ ++ def test_app_cred_token_cannot_move_credential_cross_project(self): ++ """PATCH must not let a caller rewrite project_id either (LP#2158931). ++ ++ The pre-image project check alone wasn't enough: it validated ++ the credential's *current* project, never the *new* project_id ++ in the request body, so a delegated token scoped to project A ++ could rewrite a credential it already owned there to point at ++ an unrelated project B. ++ """ ++ app_cred_token = self._get_app_cred_token(unrestricted=True) ++ ref = unit.new_credential_ref( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ credential_id = r.result['credential']['id'] ++ ++ other_project = unit.new_project_ref(domain_id=self.domain_id) ++ PROVIDERS.resource_api.create_project( ++ other_project['id'], other_project ++ ) ++ ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': {'project_id': other_project['id']}}, ++ token=app_cred_token, ++ expected_status=http.client.BAD_REQUEST, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual(self.project_id, stored['project_id']) ++ + def test_restricted_app_cred_cannot_create_ec2_credential(self): + """Test that a restricted app cred cannot create EC2 credentials. + +@@ -1134,7 +1235,7 @@ + self.patch( + f'/credentials/{totp_id}', + token=app_cred_token, +- body={'credential': totp_ref}, ++ body={'credential': {'blob': totp_ref['blob']}}, + expected_status=http.client.FORBIDDEN, + ) + +@@ -1274,49 +1375,52 @@ + content = self.post(url, headers=headers, body=body) + return access_key, content.headers['X-Subject-Token'] + +- def test_access_token_ec2_credential(self): +- """Test creating ec2 credential from an oauth access token. ++ def test_access_token_cannot_create_ec2_credential(self): ++ """An OAuth1 access token cannot create any credential (LP#2159643). + +- Call ``POST /credentials``. ++ Same history as the trust-scoped case: this was never a ++ deliberate feature, just a side effect of access-token-derived ++ tokens being otherwise broadly capable. Delegated tokens ++ (OAuth1 access tokens included) are now rejected from ++ /v3/credentials outright. + """ +- access_key, token_id = self._get_access_token() ++ _, token_id = self._get_access_token() + +- # Create the credential with the access token +- blob, ref = unit.new_ec2_credential( ++ _, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=self.project_id + ) +- r = self.post('/credentials', body={'credential': ref}, token=token_id) +- +- # We expect the response blob to contain the access_token_id +- ret_ref = ref.copy() +- ret_blob = blob.copy() +- ret_blob['access_token_id'] = access_key.decode('utf-8') +- ret_ref['blob'] = json.dumps(ret_blob) +- self.assertValidCredentialResponse(r, ref=ret_ref) +- +- # Assert credential id is same as hash of access key id for +- # ec2 credentials +- access = blob['access'].encode('utf-8') +- self.assertEqual( +- hashlib.sha256(access).hexdigest(), r.result['credential']['id'] ++ self.post( ++ '/credentials', ++ body={'credential': ref}, ++ token=token_id, ++ expected_status=http.client.FORBIDDEN, + ) + +- # Create a role assignment to ensure that it is ignored and only the +- # roles in the access token are used +- role = unit.new_role_ref(name='reader') +- role_id = role['id'] +- PROVIDERS.role_api.create_role(role_id, role) +- PROVIDERS.assignment_api.add_role_to_user_and_project( +- self.user_id, self.project_id, role_id ++ def test_access_token_cannot_get_credential_via_os_ec2(self): ++ """An OAuth1 token cannot read an EC2 cred via OS-EC2 (LP#2159643).""" ++ uri = f'/users/{self.user_id}/credentials/OS-EC2' ++ ec2_cred = self.post(uri, body={'tenant_id': self.project_id}).result[ ++ 'credential' ++ ] ++ _, token_id = self._get_access_token() ++ self.get( ++ '/'.join([uri, ec2_cred['access']]), ++ token=token_id, ++ expected_status=http.client.FORBIDDEN, + ) + +- ret_blob = json.loads(r.result['credential']['blob']) +- ec2token = self._test_get_token( +- access=ret_blob['access'], secret=ret_blob['secret'] ++ def test_access_token_cannot_delete_credential_via_os_ec2(self): ++ """An OAuth1 token cannot delete an EC2 cred via OS-EC2 (2159643).""" ++ uri = f'/users/{self.user_id}/credentials/OS-EC2' ++ ec2_cred = self.post(uri, body={'tenant_id': self.project_id}).result[ ++ 'credential' ++ ] ++ _, token_id = self._get_access_token() ++ cred_uri = '/'.join([uri, ec2_cred['access']]) ++ self.delete( ++ cred_uri, token=token_id, expected_status=http.client.FORBIDDEN + ) +- ec2_roles = [role['id'] for role in ec2token['roles']] +- self.assertIn(self.role_id, ec2_roles) +- self.assertNotIn(role_id, ec2_roles) ++ self.get(cred_uri, expected_status=http.client.OK) + + def test_ec2_auth_access_token_cross_project_blocked(self): + """OAuth1 access-token-backed EC2 credential must not auth cross-project. +@@ -1423,6 +1527,72 @@ + self.assertDictEqual(ec2_cred, r.result['credential']) + self.assertThat(ec2_cred['links']['self'], matchers.EndsWith(uri)) + ++ def _get_ec2_token_via_own_credential(self): ++ """Create an EC2 credential and exchange it for a token.""" ++ ec2_cred = self._get_ec2_cred() ++ 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, ++ ) ++ return ec2_cred, r.headers.get('X-Subject-Token') ++ ++ def test_ec2_token_cannot_list_credentials_via_os_ec2(self): ++ """An ec2credential token must not list EC2 creds via OS-EC2. ++ ++ The OS-EC2 compat endpoint has its own, separate delegation check ++ from /v3/credentials, and it never recognized ec2credential tokens ++ as delegated either -- so an ec2credential token could list every ++ EC2 credential (secrets included) belonging to the user. ++ """ ++ _, ec2_token = self._get_ec2_token_via_own_credential() ++ self.get( ++ self._get_ec2_cred_uri(), ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ ++ def test_ec2_token_cannot_get_credential_via_os_ec2(self): ++ """An ec2credential token must not read an EC2 cred via OS-EC2.""" ++ ec2_cred, ec2_token = self._get_ec2_token_via_own_credential() ++ uri = '/'.join([self._get_ec2_cred_uri(), ec2_cred['access']]) ++ self.get(uri, token=ec2_token, expected_status=http.client.FORBIDDEN) ++ ++ def test_ec2_token_cannot_delete_credential_via_os_ec2(self): ++ """An ec2credential token must not delete an EC2 cred via OS-EC2.""" ++ ec2_cred, ec2_token = self._get_ec2_token_via_own_credential() ++ uri = '/'.join([self._get_ec2_cred_uri(), ec2_cred['access']]) ++ self.delete( ++ uri, token=ec2_token, expected_status=http.client.FORBIDDEN ++ ) ++ # the credential must still exist afterwards ++ self.get(uri, expected_status=http.client.OK) ++ ++ def test_ec2_token_cannot_patch_credential_via_credentials_api(self): ++ """An ec2credential token must not PATCH /v3/credentials.""" ++ ref = unit.new_credential_ref( ++ user_id=self.user_id, project_id=self.project_id ++ ) ++ r = self.post('/credentials', body={'credential': ref}) ++ credential_id = r.result['credential']['id'] ++ original_blob = r.result['credential']['blob'] ++ ++ _, ec2_token = self._get_ec2_token_via_own_credential() ++ self.patch( ++ f'/credentials/{credential_id}', ++ body={'credential': {'blob': 'rotated-blob'}}, ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ self.get( ++ f'/credentials/{credential_id}', ++ token=ec2_token, ++ expected_status=http.client.FORBIDDEN, ++ ) ++ stored = PROVIDERS.credential_api.get_credential(credential_id) ++ self.assertEqual(original_blob, stored['blob']) ++ + def test_ec2_cannot_get_non_ec2_credential(self): + access_key = uuid.uuid4().hex + cred_id = utils.hash_access_key(access_key) +@@ -1509,19 +1679,19 @@ + 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.""" ++ def test_unrestricted_app_cred_cannot_create_ec2_credential(self): ++ """An unrestricted app cred cannot create EC2 creds either (LP#2159643). ++ ++ "unrestricted" only ever governed app-cred management, not this. ++ """ + token_id = self._get_app_cred_token(unrestricted=True) + uri = self._get_ec2_cred_uri() +- r = self.post( ++ self.post( + uri, + body={'tenant_id': self.project_id}, + token=token_id, +- expected_status=http.client.CREATED, ++ expected_status=http.client.FORBIDDEN, + ) +- 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.""" +@@ -1547,78 +1717,181 @@ + 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 +- ) ++ def test_trust_scoped_token_cannot_create_ec2_credential(self): ++ """A trust-scoped token cannot create an EC2 cred via OS-EC2 (LP#2159643). ++ ++ Previously only checked project scope, so same-project access ++ was allowed. Now rejected outright, regardless of project. ++ """ + trust_token = self._get_trust_token() +- uri = f'/users/{self.user_id}/credentials/OS-EC2' ++ uri = self._get_ec2_cred_uri() + self.post( + uri, +- body={'tenant_id': other_project['id']}, ++ body={'tenant_id': self.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 +- ) ++ def test_trust_scoped_token_cannot_get_credential_via_os_ec2(self): ++ """A trust-scoped token cannot read an EC2 cred (LP#2159643).""" + 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 +- ) ++ def test_trust_scoped_token_cannot_delete_credential_via_os_ec2(self): ++ """A trust-scoped token cannot delete an EC2 cred (LP#2159643).""" + 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 + ) ++ # the credential must still exist afterwards ++ self.get(uri, expected_status=http.client.OK) ++ ++ ++class TestPrimaryAuthGuards(unit.BaseTestCase): ++ """Unit-level tests for the delegated-token guards (LP#2159643). ++ ++ Calls the guards directly with stub tokens, independent of HTTP/ ++ middleware. Matters for ec2credential specifically: an unrelated, ++ already-landed fix bans ec2credential-method tokens at the auth ++ middleware layer before these guards ever run, so an HTTP-level test ++ would pass regardless of whether the guards recognize it as delegated. ++ """ ++ ++ def _token(self, methods): ++ token = mock.Mock() ++ token.methods = methods ++ return token ++ ++ def _oslo_context(self, trust_id=None): ++ ctx = mock.Mock() ++ ctx.trust_id = trust_id ++ return ctx ++ ++ def test_require_primary_auth_rejects_ec2credential_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token(['ec2credential']), ++ self._oslo_context(), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_allows_password_token(self): ++ credentials_api._require_primary_auth( ++ self._token(['password']), self._oslo_context(), 'cert' ++ ) ++ ++ def test_require_primary_auth_allows_custom_method_via_config(self): ++ """A custom auth plugin is not mistaken for a delegated credential. ++ ++ Once listed in [auth] additional_primary_auth_methods. ++ """ ++ credentials_api.CONF.set_override( ++ 'additional_primary_auth_methods', ['sso'], group='auth' ++ ) ++ self.addCleanup( ++ credentials_api.CONF.clear_override, ++ 'additional_primary_auth_methods', ++ group='auth', ++ ) ++ credentials_api._require_primary_auth( ++ self._token(['sso']), self._oslo_context(), 'cert' ++ ) ++ ++ def test_require_primary_auth_rejects_unlisted_custom_method(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token(['sso']), ++ self._oslo_context(), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_rejects_empty_methods(self): ++ """An empty method list must be treated as delegated, not allowed.""" ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token([]), ++ self._oslo_context(), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_rejects_empty_methods_with_trust_scope(self): ++ """Same, for a token that is additionally trust-scoped.""" ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token([]), ++ self._oslo_context(trust_id=uuid.uuid4().hex), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_rejects_trust_scoped_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token(['password']), ++ self._oslo_context(trust_id=uuid.uuid4().hex), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_escape_hatch_requires_ec2_type(self): ++ """The escape hatch is scoped to ec2-type credentials only.""" ++ credentials_api.CONF.set_override( ++ 'allow_insecure_admin_trust_cross_project_credentials_access', ++ True, ++ group='security_compliance', ++ ) ++ self.addCleanup( ++ credentials_api.CONF.clear_override, ++ 'allow_insecure_admin_trust_cross_project_credentials_access', ++ group='security_compliance', ++ ) ++ with mock.patch.object( ++ credentials_api.ENFORCER, 'enforce_call', return_value=None ++ ): ++ credentials_api._require_primary_auth( ++ self._token(['ec2credential']), self._oslo_context(), 'ec2' ++ ) ++ self.assertRaises( ++ exception.ForbiddenAction, ++ credentials_api._require_primary_auth, ++ self._token(['ec2credential']), ++ self._oslo_context(), ++ 'cert', ++ ) ++ ++ def test_require_primary_auth_for_ec2_rejects_ec2credential_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ users_api._require_primary_auth_for_ec2, ++ self._oslo_context(), ++ self._token(['ec2credential']), ++ ) ++ ++ def test_require_primary_auth_for_ec2_allows_password_token(self): ++ users_api._require_primary_auth_for_ec2( ++ self._oslo_context(), self._token(['password']) ++ ) ++ ++ def test_require_primary_auth_for_ec2_rejects_trust_scoped_token(self): ++ self.assertRaises( ++ exception.ForbiddenAction, ++ users_api._require_primary_auth_for_ec2, ++ self._oslo_context(trust_id=uuid.uuid4().hex), ++ self._token(['password']), ++ ) ++ ++ def test_require_primary_auth_for_ec2_rejects_empty_methods(self): ++ """Same fernet round-trip gap as _require_primary_auth.""" ++ self.assertRaises( ++ exception.ForbiddenAction, ++ users_api._require_primary_auth_for_ec2, ++ self._oslo_context(), ++ self._token([]), ++ ) +diff --git a/keystone/tests/unit/test_validation.py b/keystone/tests/unit/test_validation.py +index 3fd2df0..669360f 100644 +--- a/keystone/tests/unit/test_validation.py ++++ b/keystone/tests/unit/test_validation.py +@@ -1188,14 +1188,7 @@ + + def test_validate_credential_update_succeeds(self): + """Test that a credential request is properly validated.""" +- request_to_validate = { +- 'credential': { +- 'blob': 'some string', +- 'project_id': uuid.uuid4().hex, +- 'type': 'ec2', +- 'user_id': uuid.uuid4().hex, +- } +- } ++ request_to_validate = {'credential': {'blob': 'some string'}} + self.update_credential_validator.validate(request_to_validate) + + def test_validate_credential_update_without_parameters_fails(self): +@@ -1207,18 +1200,35 @@ + request_to_validate, + ) + +- def test_validate_credential_update_with_extra_parameters_succeeds(self): +- """Validate credential update with extra parameters.""" ++ def test_validate_credential_update_rejects_non_blob_fields(self): ++ """PATCH only accepts `blob` (LP#2159643). ++ ++ `type`, `project_id`, and `user_id` are immutable after creation. ++ """ + request_to_validate = { + 'credential': { + 'blob': 'some string', +- 'extra': False, + 'project_id': uuid.uuid4().hex, + 'type': 'ec2', + 'user_id': uuid.uuid4().hex, + } + } +- self.update_credential_validator.validate(request_to_validate) ++ self.assertRaises( ++ exception.SchemaValidationError, ++ self.update_credential_validator.validate, ++ request_to_validate, ++ ) ++ ++ def test_validate_credential_update_with_extra_parameters_fails(self): ++ """Validate credential update rejects unknown extra parameters.""" ++ request_to_validate = { ++ 'credential': {'blob': 'some string', 'extra': False} ++ } ++ self.assertRaises( ++ exception.SchemaValidationError, ++ self.update_credential_validator.validate, ++ request_to_validate, ++ ) + + + class RegionValidationTestCase(unit.BaseTestCase): +diff --git a/releasenotes/notes/bug-2159643-45b677fc1b848df4.yaml b/releasenotes/notes/bug-2159643-45b677fc1b848df4.yaml +new file mode 100644 +index 0000000..cf9db59 +--- /dev/null ++++ b/releasenotes/notes/bug-2159643-45b677fc1b848df4.yaml +@@ -0,0 +1,55 @@ ++--- ++security: ++ - | ++ [`bug 2159643 `_] ++ Delegated tokens (trusts, application credentials, OAuth1 access ++ tokens, and EC2 credentials, which were not previously recognized ++ as delegated by this check) can no longer access ++ ``/v3/credentials`` or the ``/v3/users/{user_id}/credentials/OS-EC2`` ++ compat endpoints at all, superseding the project-scope comparison ++ introduced by `bug 2150089 ++ `_. Trust-scoped and OAuth1 ++ access-token-scoped tokens creating EC2 credentials is also removed; ++ this was never a deliberate feature (see `bug 1242597 ++ `_ / OSSA-2013-032). ++ ++ ``PATCH /v3/credentials/{credential_id}`` now only accepts ``blob``. ++ ``type``, ``user_id``, and ``project_id`` are immutable after ++ creation. Delete and re-create the credential to change any of ++ those fields. This also fixes `bug 2158931 ++ `_: the previous guard only ++ validated a PATCH request's stored (pre-image) ``project_id``, ++ never the new ``project_id`` in the request body, so a delegated ++ token scoped to one project could rewrite a credential it already ++ owned there to point at a different project. ++ ++ This closes the API-level creation path only. It does not ++ retroactively affect ``type=ec2`` credentials that were already ++ bound to a trust or OAuth1 access token before this fix (for ++ example, created directly through the provider API or ++ ``keystone-manage`` rather than through this guarded endpoint). To ++ fully mitigate the risk this change addresses, operators should ++ also audit their existing EC2 credentials for a ``trust_id`` or ++ ``access_token_id`` in their stored ``blob`` and delete and ++ re-create any that have one, so that no EC2 credential remains ++ tied to a delegation that can independently expire, be revoked, or ++ otherwise change state out from under it. ++upgrade: ++ - | ++ [`bug 2159643 `_] ++ ``PATCH /v3/credentials/{credential_id}`` requests that include ++ ``type``, ``user_id``, or ``project_id`` now fail with ``400 Bad ++ Request``. Update any client or automation that PATCHes credentials ++ to send ``blob`` only; delete and re-create the credential for any ++ other field change. ++ ++ The existing, opt-in, deprecated ++ ``[security_compliance] allow_insecure_admin_trust_cross_project_credentials_access`` ++ option (see `bug 2150089 `_) is ++ now scoped to EC2 credentials only, matching its one documented use ++ case (an admin-role trust driving automated tooling that lists or ++ syncs EC2 credentials with a third-party system). Enabling the ++ option no longer grants access to any credential type other than ++ EC2. Operators relying on this option should migrate the underlying ++ automated workflow away from delegated tokens; it will be removed ++ in a future release. diff -Nru keystone-27.0.0/debian/patches/series keystone-27.0.0/debian/patches/series --- keystone-27.0.0/debian/patches/series 2026-08-28 07:41:35.000000000 +0000 +++ keystone-27.0.0/debian/patches/series 2026-10-06 18:06:28.000000000 +0000 @@ -16,3 +16,4 @@ 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 +CVE-2026-90460_lp-2159643_credentials_reject_delegated-tokens_restrict_PATCH_to_blob_only.patch