From 99972352457f51c4b2f51a25e3df6b391f1b1563 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:33:18 +0200 Subject: [PATCH 1/5] Add tests for restricted app cred guard Verify that the _check_unrestricted_application_credential guard on the OS-EC2 credential create endpoint blocks restricted application credentials from creating EC2 credentials, while still allowing unrestricted application credentials to do so. Generated-By: claude-opus-4-6 (OpenCode) Related-Bug: #2142138 Change-Id: I733305ba61bd8362f0c9675e257b1d42a6ef4053 Signed-off-by: Boris Bobrov (cherry picked from commit c87ce6ed435f01431343d6b2bb6697a640514d27) (cherry picked from commit 52e47439688f320befb49514570cc5ac707931f5) --- keystone/tests/unit/test_v3_credential.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index b0d0e7fcc6..59841beb66 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -994,3 +994,50 @@ def test_ec2_delete_credential(self): self.assertRaises(exception.CredentialNotFound, PROVIDERS.credential_api.get_credential, cred_from_credential_api[0]['id']) + + def _get_app_cred_token(self, unrestricted=False): + """Create an application credential and return a token for it.""" + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + if unrestricted: + ref['unrestricted'] = True + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_ec2_create_credential_with_restricted_app_cred(self): + """Test that a restricted app cred cannot create EC2 credentials. + + A restricted application credential must not be allowed to create + EC2 credentials, as this would bypass the role restriction and + grant full user access to S3. + """ + token_id = self._get_app_cred_token(unrestricted=False) + uri = self._get_ec2_cred_uri() + self.post( + uri, + body={'tenant_id': self.project_id}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + + def test_ec2_create_credential_with_unrestricted_app_cred(self): + """Test that an unrestricted app cred can create EC2 credentials.""" + token_id = self._get_app_cred_token(unrestricted=True) + uri = self._get_ec2_cred_uri() + r = self.post( + uri, + body={'tenant_id': self.project_id}, + token=token_id, + expected_status=http.client.CREATED, + ) + ec2_cred = r.result['credential'] + self.assertEqual(self.user_id, ec2_cred['user_id']) + self.assertEqual(self.project_id, ec2_cred['tenant_id']) From efeea377574e7e18127f5a9c7c10d5e5d53f11f9 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:45:15 +0200 Subject: [PATCH 2/5] Block restricted app creds from creating EC2 credentials via /credentials The POST /v3/credentials endpoint accepted EC2 credential creation from restricted application credential tokens, bypassing the guard on the dedicated OS-EC2 endpoint. Add the same unrestricted application credential check to the generic credentials API for EC2-type credentials, and update the existing test to use an unrestricted application credential. Related-Bug: #2142138 Generated-By: claude-opus-4-6 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: Idb192a2fd370fc26c7d76788e9ad1856483d3239 (cherry picked from commit d6a3dc511057d6ac25bd2d75776a4233c5608684) (cherry picked from commit c33033594b87c37d858efba312dcf15f34afc3e9) --- keystone/api/credentials.py | 19 ++++++++--- keystone/tests/unit/test_v3_credential.py | 39 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index 90b53dd686..b960e2deaa 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -32,6 +32,16 @@ ENFORCER = rbac_enforcer.RBACEnforcer +def _check_unrestricted_application_credential(token): + if 'application_credential' in token.methods: + if not token.application_credential['unrestricted']: + action = _( + "Using method 'application_credential' is not " + "allowed for managing additional credentials." + ) + raise exception.ForbiddenAction(action=action) + + def _build_target_enforcement(): target = {} try: @@ -156,11 +166,12 @@ def post(self): action='identity:create_credential', target_attr=target ) validation.lazy_validate(schema.credential_create, credential) + token = self.auth_context['token'] + if credential.get('type', '').lower() == 'ec2': + _check_unrestricted_application_credential(token) trust_id = getattr(self.oslo_context, 'trust_id', None) - app_cred_id = getattr( - self.auth_context['token'], 'application_credential_id', None) - access_token_id = getattr( - self.auth_context['token'], 'access_token_id', None) + app_cred_id = getattr(token, 'application_credential_id', None) + access_token_id = getattr(token, 'access_token_id', None) ref = self._assign_unique_id( self._normalize_dict(credential), trust_id=trust_id, app_cred_id=app_cred_id, diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 59841beb66..17f08afe4d 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -688,9 +688,11 @@ def test_app_cred_ec2_credential(self): Call ``POST /credentials``. """ - # Create the app cred + # Create an unrestricted app cred (restricted app creds are + # blocked from creating EC2 credentials) ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) del ref['id'] + ref['unrestricted'] = True r = self.post('/users/%s/application_credentials' % self.user_id, body={'application_credential': ref}) app_cred = r.result['application_credential'] @@ -743,6 +745,41 @@ def test_app_cred_ec2_credential(self): token=token_id, expected_status=http.client.CONFLICT) + def _get_app_cred_token(self, unrestricted=False): + """Create an application credential and return its token.""" + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + if unrestricted: + ref['unrestricted'] = True + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + r = self.v3_create_token(auth_data) + return r.headers.get('X-Subject-Token') + + def test_restricted_app_cred_cannot_create_ec2_credential(self): + """Test that a restricted app cred cannot create EC2 credentials. + + A restricted application credential must not be allowed to + create EC2 credentials via POST /credentials either, as this + would bypass the guard on the OS-EC2 endpoint. + """ + token_id = self._get_app_cred_token(unrestricted=False) + blob, ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=self.project_id + ) + self.post( + '/credentials', + body={'credential': ref}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + class TestCredentialAccessToken(CredentialBaseTestCase): """Test credential with access token.""" From 26714bca9cf0ddcc56bd81e445669b72cf041ce0 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Thu, 21 May 2026 22:19:22 +0200 Subject: [PATCH 3/5] Add audience mapper to devstack Keycloak client Keycloak 26.6.2 fixed CVE-2026-37979 by requiring the authenticated client at the OAuth2 token introspection endpoint to be listed in the introspected token's "aud" claim. The devstack OIDC plugin uses the "devstack" client both to issue user access tokens (via Keycloak's ROPC flow) and to introspect those same tokens (via Apache mod_auth_openidc's OIDCOAuthIntrospectionEndpoint). Without an audience mapper, the access tokens issued by Keycloak do not list "devstack" in "aud", so introspection returns {"active": false} and Apache responds with HTTP 401, breaking the keystone-tempest-oidc-federation job. Add an audience protocol mapper to the "devstack" client so that "devstack" is included in the access token's audience. This is upstream's recommended fix and lets us continue tracking quay.io/keycloak/keycloak:latest. Also handle the 409 Conflict that Keycloak returns when the client or mapper already exists from a previous setup run, so the script is idempotent. Generated-By: claude-opus-4-7 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: Ic70d4756c121e3f096c372ee9b6f5314838e66de --- devstack/tools/oidc/setup_keycloak_client.py | 56 +++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/devstack/tools/oidc/setup_keycloak_client.py b/devstack/tools/oidc/setup_keycloak_client.py index 15fa37b41f..4025705e5c 100644 --- a/devstack/tools/oidc/setup_keycloak_client.py +++ b/devstack/tools/oidc/setup_keycloak_client.py @@ -1,3 +1,4 @@ +import http import os import requests @@ -34,6 +35,17 @@ def _admin_auth(self, realm): self.session.headers.update(headers) return r + def _get_client_uuid(self, realm, client_id): + resp = self.session.get( + self.construct_url(realm, 'clients'), + params={'clientId': client_id}, + ) + resp.raise_for_status() + for client in resp.json(): + if client.get('clientId') == client_id: + return client['id'] + return None + def create_client(self, realm, client_id, client_secret, redirect_uris): self._admin_auth(realm) data = { @@ -43,7 +55,49 @@ def create_client(self, realm, client_id, client_secret, redirect_uris): 'implicitFlowEnabled': True, 'directAccessGrantsEnabled': True, } - return self.session.post(self.construct_url(realm, 'clients'), json=data) + resp = self.session.post( + self.construct_url(realm, 'clients'), json=data + ) + if resp.status_code == http.HTTPStatus.CONFLICT: + # Client already exists from a previous run; reuse it. + client_uuid = self._get_client_uuid(realm, client_id) + if client_uuid is None: + resp.raise_for_status() + else: + resp.raise_for_status() + # Keycloak returns 201 with the new client's UUID in the + # Location header: .../admin/realms//clients/ + client_uuid = resp.headers['Location'].rsplit('/', 1)[-1] + # Since Keycloak 26.6.2 (CVE-2026-37979) the OAuth2 token + # introspection endpoint requires the introspecting client to + # be present in the access token's "aud" claim. Apache's + # mod_auth_openidc uses this same client to introspect bearer + # tokens it receives from federated users, so we add an + # audience protocol mapper that lists the client itself in + # "aud". Without this, introspection returns active=false and + # mod_auth_openidc rejects the token with HTTP 401. + self._add_audience_mapper(realm, client_uuid, client_id) + return resp + + def _add_audience_mapper(self, realm, client_uuid, audience_client_id): + mapper = { + 'name': f'{audience_client_id}-audience', + 'protocol': 'openid-connect', + 'protocolMapper': 'oidc-audience-mapper', + 'config': { + 'included.client.audience': audience_client_id, + 'access.token.claim': 'true', + 'id.token.claim': 'false', + 'introspection.token.claim': 'true', + }, + } + path = f'clients/{client_uuid}/protocol-mappers/models' + resp = self.session.post(self.construct_url(realm, path), json=mapper) + if resp.status_code == http.HTTPStatus.CONFLICT: + # Mapper already present from a previous run; nothing to do. + return resp + resp.raise_for_status() + return resp def main(): From e799b90af0e8ae291e791a5a37cf8faab721cc33 Mon Sep 17 00:00:00 2001 From: Boris Bobrov Date: Tue, 7 Apr 2026 23:55:23 +0200 Subject: [PATCH 4/5] Block app cred tokens from authorizing OAuth1 requests The OAuth1 authorize endpoint checked is_delegated_auth to block trust-scoped and OAuth-scoped tokens from authorizing request tokens, but application credential tokens were not covered by this check. A restricted application credential could authorize a request token with any role the user actually holds, producing an access token that yields an unrestricted Keystone token with roles beyond the application credential's restricted set. Add an explicit check for application credential tokens on the OAuth1 authorize endpoint, consistent with how trust-scoped and OAuth-scoped tokens are already blocked. Depends-on: https://review.opendev.org/c/openstack/keystone/+/990631 Related-Bug: #2142138 Generated-By: claude-opus-4-6 (OpenCode) Signed-off-by: Boris Bobrov Change-Id: I9506557609ff7edaa6a961f356f9b8e19faaefc3 (cherry picked from commit 29246c5fd8d1dafbe6cc8cec4c57faf5590cd44e) (cherry picked from commit 33744fef63a618e074af4915f03427a054ac4bc8) --- keystone/api/os_oauth1.py | 11 +++++++++++ keystone/tests/unit/test_v3_oauth1.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/keystone/api/os_oauth1.py b/keystone/api/os_oauth1.py index 615d29c516..4e61652a42 100644 --- a/keystone/api/os_oauth1.py +++ b/keystone/api/os_oauth1.py @@ -292,6 +292,17 @@ def put(self, request_token_id): raise exception.Forbidden( _('Cannot authorize a request token with a token issued via ' 'delegation.')) + auth_context = flask.request.environ.get( + authorization.AUTH_CONTEXT_ENV, {} + ) + token = auth_context.get('token') + if token and 'application_credential' in token.methods: + raise exception.Forbidden( + _( + 'Cannot authorize a request token with a token issued via ' + 'delegation.' + ) + ) req_token = PROVIDERS.oauth_api.get_request_token(request_token_id) diff --git a/keystone/tests/unit/test_v3_oauth1.py b/keystone/tests/unit/test_v3_oauth1.py index 6b6942b612..46ca2a5ba1 100644 --- a/keystone/tests/unit/test_v3_oauth1.py +++ b/keystone/tests/unit/test_v3_oauth1.py @@ -651,6 +651,30 @@ def test_trust_token_cannot_list_request_tokens(self): self.get(url, token=trust_token, expected_status=http.client.FORBIDDEN) + def _create_app_cred_get_token(self): + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + auth_data = self.build_authentication_request( + app_cred_id=app_cred['id'], secret=app_cred['secret'] + ) + return self.get_requested_token(auth_data) + + def test_app_cred_token_cannot_authorize_request_token(self): + app_cred_token = self._create_app_cred_get_token() + url = self._approve_request_token_url() + body = {'roles': [{'id': self.role_id}]} + self.put( + url, + body=body, + token=app_cred_token, + expected_status=http.client.FORBIDDEN, + ) + class FernetAuthTokenTests(AuthTokenTests, OAuthFlowTests): From 23250661170196352a49d31c0574be7948f8c43c Mon Sep 17 00:00:00 2001 From: Grzegorz Grasza Date: Wed, 22 Apr 2026 13:23:44 +0200 Subject: [PATCH 5/5] Enforce app cred project boundary on EC2 credential paths POST /v3/credentials did not validate that the caller-supplied project_id for an EC2-type credential matched the project of the authenticating application credential. This allowed an attacker holding an unrestricted application credential for project A to create an EC2 credential targeting project B; a subsequent /v3/ec2tokens exchange would then issue a Keystone token scoped to project B while still carrying the original app_cred_id, enabling cross-project lateral movement within the credential owner's role footprint. Two fixes: 1. credentials.py: after extracting app_cred_id from the token, check that credential['project_id'] == app_cred['project_id'] for EC2-type credentials and raise ForbiddenAction otherwise. 2. EC2_S3_Resource.py: in handle_authenticate(), assert that the stored EC2 credential project_id matches the application credential's project before issuing the token. This issue is orthogonal to CVE-2026-33551 (LP#2142138 / Gerrit 983655), which blocks restricted application credentials from creating EC2 credentials at all. The project-boundary check is absent regardless of the restricted flag and requires separate treatment. Closes-Bug: #2149775 Related-Bug: #OSPRH-29345 Assisted-by: claude-sonnet-4-6 Change-Id: I7c10c8a52e57e63cb9c66d03d69540abefe5425c Signed-off-by: Grzegorz Grasza Signed-off-by: Dr. Jens Harbott (cherry picked from commit b6fd80996b882890a51f3e2aab41d952d7ff68ae) (cherry picked from commit d9e18a37888cabdea919c58b24f630fd722aa8b0) --- keystone/api/_shared/EC2_S3_Resource.py | 7 ++ keystone/api/credentials.py | 13 ++++ keystone/tests/unit/test_v3_credential.py | 92 +++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/keystone/api/_shared/EC2_S3_Resource.py b/keystone/api/_shared/EC2_S3_Resource.py index 7b2fc21b29..80ef7fc35e 100644 --- a/keystone/api/_shared/EC2_S3_Resource.py +++ b/keystone/api/_shared/EC2_S3_Resource.py @@ -155,6 +155,13 @@ def handle_authenticate(self): app_cred = ac_client.get_application_credential( cred_data['app_cred_id']) roles = [r['id'] for r in app_cred['roles']] + if cred_data['project_id'] != app_cred['project_id']: + raise ks_exceptions.Unauthorized( + _( + 'EC2 credential project does not match the ' + 'application credential project.' + ) + ) elif cred_data['access_token_id']: access_token = PROVIDERS.oauth_api.get_access_token( cred_data['access_token_id']) diff --git a/keystone/api/credentials.py b/keystone/api/credentials.py index b960e2deaa..af5e646b69 100644 --- a/keystone/api/credentials.py +++ b/keystone/api/credentials.py @@ -172,6 +172,19 @@ def post(self): trust_id = getattr(self.oslo_context, 'trust_id', None) app_cred_id = getattr(token, 'application_credential_id', None) access_token_id = getattr(token, 'access_token_id', None) + if ( + app_cred_id is not None + and credential.get('type', '').lower() == 'ec2' + ): + ac_api = PROVIDERS.application_credential_api + app_cred = ac_api.get_application_credential(app_cred_id) + if credential.get('project_id') != app_cred['project_id']: + action = _( + 'EC2 credential project_id must match the ' + 'project of the application credential used ' + 'to authenticate' + ) + raise exception.ForbiddenAction(action=action) ref = self._assign_unique_id( self._normalize_dict(credential), trust_id=trust_id, app_cred_id=app_cred_id, diff --git a/keystone/tests/unit/test_v3_credential.py b/keystone/tests/unit/test_v3_credential.py index 17f08afe4d..2faa877084 100644 --- a/keystone/tests/unit/test_v3_credential.py +++ b/keystone/tests/unit/test_v3_credential.py @@ -780,6 +780,98 @@ def test_restricted_app_cred_cannot_create_ec2_credential(self): expected_status=http.client.FORBIDDEN, ) + def test_app_cred_ec2_credential_cross_project_forbidden(self): + """EC2 credential project_id must match the app cred project. + + An unrestricted app cred scoped to project A must not be used to + create an EC2 credential targeting a different project B. + + Call ``POST /credentials``. + """ + token_id = self._get_app_cred_token(unrestricted=True) + + other_project = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + _, ec2_ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'] + ) + self.post( + '/credentials', + body={'credential': ec2_ref}, + token=token_id, + expected_status=http.client.FORBIDDEN, + ) + + def test_app_cred_ec2_auth_cross_project_rejected(self): + """EC2 auth is rejected when credential project differs from app cred. + + A pre-existing EC2 credential whose project_id does not match the + linked application credential's project must be rejected at + authentication time, preventing cross-project lateral movement. + + Call ``POST /ec2tokens``. + """ + ref = unit.new_application_credential_ref(roles=[{'id': self.role_id}]) + del ref['id'] + r = self.post( + f'/users/{self.user_id}/application_credentials', + body={'application_credential': ref}, + ) + app_cred = r.result['application_credential'] + + other_project = unit.new_project_ref(domain_id=self.domain_id) + PROVIDERS.resource_api.create_project( + other_project['id'], other_project + ) + + # Bypass the API to plant a credential with a mismatched project_id. + # This simulates a credential that existed before the creation-time + # check was added, or one created via a direct DB write. + blob = { + 'access': uuid.uuid4().hex, + 'secret': uuid.uuid4().hex, + 'trust_id': None, + 'app_cred_id': app_cred['id'], + } + _, ec2_ref = unit.new_ec2_credential( + user_id=self.user_id, project_id=other_project['id'], blob=blob + ) + PROVIDERS.credential_api.create_credential(ec2_ref['id'], ec2_ref) + + signer = ec2_utils.Ec2Signer(blob['secret']) + params = { + 'SignatureMethod': 'HmacSHA256', + 'SignatureVersion': '2', + 'AWSAccessKeyId': blob['access'], + } + request = { + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + sig_ref = { + 'access': blob['access'], + 'signature': signer.generate(request), + 'host': 'foo', + 'verb': 'GET', + 'path': '/bar', + 'params': params, + } + PROVIDERS.assignment_api.create_system_grant_for_user( + self.user_id, self.role_id + ) + token = self.get_system_scoped_token() + self.post( + '/ec2tokens', + body={'ec2Credentials': sig_ref}, + token=token, + expected_status=http.client.UNAUTHORIZED, + ) + class TestCredentialAccessToken(CredentialBaseTestCase): """Test credential with access token."""