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(): 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 90b53dd686..af5e646b69 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,25 @@ 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) + 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/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_credential.py b/keystone/tests/unit/test_v3_credential.py index b0d0e7fcc6..2faa877084 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,133 @@ 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, + ) + + 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.""" @@ -994,3 +1123,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']) 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):