Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ea18858
Fix trusts jsonschema to support additional properties
gtema Aug 15, 2025
d074c49
fix(pep8): pin setuptools<82 for flake8-import-order compatibility
d34dh0r53 Mar 27, 2026
072d937
fix ldap 'enabled' setting not interpreted as boolean
trefzer Aug 21, 2025
cee944e
Fix OIDC federation UTF-8 double-encoding of non-ASCII characters
xek Mar 23, 2026
4da7640
Prevent unauthorized EC2 credential creation and deletion
xek Feb 26, 2026
5b2e224
Merge "fix ldap 'enabled' setting not interpreted as boolean" into st…
Apr 9, 2026
125efe4
Merge "Fix OIDC federation UTF-8 double-encoding of non-ASCII charact…
Apr 10, 2026
52e4743
Add tests for restricted app cred guard
bbobrov Apr 7, 2026
c330335
Block restricted app creds from creating EC2 credentials via /credent…
bbobrov Apr 7, 2026
33744fe
Block app cred tokens from authorizing OAuth1 requests
bbobrov Apr 7, 2026
f62dd2c
Enforce app cred project boundary on EC2 credential paths
xek Apr 22, 2026
52c167f
Use branch constraints for tempest venv on stable/2025.1
d34dh0r53 May 19, 2026
53af4f7
Merge "Enforce app cred project boundary on EC2 credential paths" int…
May 20, 2026
a98c739
Enforce delegation project boundary for delegated tokens
xek Apr 23, 2026
72edc24
Fix user impersonation through application credentials (CVE-2026-42998)
xek May 12, 2026
db956ac
Forbid trust operations using application credentials (CVE-2026-43000)
xek May 12, 2026
2d1bd64
Preserve expires_at when rescoping federated tokens (CVE-2026-44394)
xek May 12, 2026
ef5dc5d
Prevent RBAC policy bypass via JSON body and query filters (CVE-2026-…
xek May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ repos:
- id: hacking
additional_dependencies:
- flake8-import-order~=0.18.2
- setuptools<82
exclude: '^(doc|releasenotes|tools|devstack)/.*$'
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.9.0
Expand Down
37 changes: 37 additions & 0 deletions .zuul.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@
voting: false
irrelevant-files: *irrelevant-files
- keystone-dsvm-py3-functional-federation-ubuntu-jammy-k2k:
voting: false
irrelevant-files: *irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- keystoneclient-devstack-functional:
voting: false
irrelevant-files: *irrelevant-files
Expand All @@ -171,31 +175,64 @@
- ^setup.cfg$
- tempest-full-py3:
irrelevant-files: *tempest-irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- grenade:
irrelevant-files: *tempest-irrelevant-files
vars:
grenade_devstack_localrc:
shared:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- grenade-skip-level:
irrelevant-files: *tempest-irrelevant-files
vars:
grenade_devstack_localrc:
shared:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- tempest-ipv6-only:
irrelevant-files: *tempest-irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- keystone-protection-functional
- codegenerator-openapi-identity-tips-with-api-ref:
voting: false
- keystone-dsvm-functional-oidc-federation:
voting: false
irrelevant-files: *irrelevant-files
gate:
jobs:
- keystone-dsvm-py3-functional:
irrelevant-files: *irrelevant-files
- keystone-dsvm-py3-functional-federation-ubuntu-jammy-k2k:
voting: false
irrelevant-files: *irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- tempest-full-py3:
irrelevant-files: *tempest-irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- grenade:
irrelevant-files: *tempest-irrelevant-files
vars:
grenade_devstack_localrc:
shared:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- grenade-skip-level:
irrelevant-files: *tempest-irrelevant-files
vars:
grenade_devstack_localrc:
shared:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- tempest-ipv6-only:
irrelevant-files: *tempest-irrelevant-files
vars:
devstack_localrc:
TEMPEST_VENV_UPPER_CONSTRAINTS: "$DEST/requirements/upper-constraints.txt"
- keystone-protection-functional
experimental:
jobs:
Expand Down
6 changes: 3 additions & 3 deletions doc/source/user/application_credentials.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ You can provide an expiration date for application credentials:
+--------------+----------------------------------------------------------------------------------------+

By default, application credentials are restricted from creating or deleting
other application credentials and from creating or deleting trusts. If your
application needs to be able to perform these actions and you accept the risks
involved, you can disable this protection:
other application credentials. If your application needs to be able to perform
these actions and you accept the risks involved, you can disable this
protection:

.. warning::

Expand Down
14 changes: 14 additions & 0 deletions keystone/api/_shared/EC2_S3_Resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,25 @@ def handle_authenticate(self):
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']
)
roles = jsonutils.loads(access_token['role_ids'])
if cred_data['project_id'] != access_token['project_id']:
raise ks_exceptions.Unauthorized(
_(
'EC2 credential project does not match the '
'OAuth1 access token project.'
)
)
auth_context = {'access_token_id': cred_data['access_token_id']}
else:
roles = PROVIDERS.assignment_api.get_roles_for_user_and_project(
Expand Down
79 changes: 71 additions & 8 deletions keystone/api/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,58 @@
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 _check_credential_project_scope(token, oslo_context, credential):
"""Enforce project boundary for delegated tokens.

Non-delegated tokens (password, totp, etc.) are not restricted here --
an admin with a regular token can legitimately manage credentials across
projects. Delegated tokens (trusts, application credentials, OAuth1) are
always bound to a single project at delegation time; only credentials
whose project_id exactly matches the token's project scope are in bounds.

Credentials with project_id=None (e.g. TOTP/MFA bindings) are treated as
out-of-scope for any delegated token: they are user-level secrets with no
project anchor, and a delegated token should never be able to enumerate,
read, or mutate them -- doing so would allow a stolen delegation token to
exfiltrate or destroy a user's MFA binding.
"""
trust_id = getattr(oslo_context, 'trust_id', None)
app_cred_id = getattr(token, 'application_credential_id', None)
access_token_id = getattr(token, 'access_token_id', None)

if not (trust_id or app_cred_id or access_token_id):
return

token_project_id = oslo_context.project_id
cred_project_id = credential.get('project_id')

if cred_project_id != token_project_id:
if CONF.security_compliance.allow_insecure_admin_trust_cross_project_credentials_access:
# When insecure cross-project access is enabled, still restrict to
# admin-role delegated tokens only. See LP#2150089.
try:
ENFORCER.enforce_call(action='admin_required')
return
except exception.ForbiddenAction:
pass
raise exception.ForbiddenAction(
action=_(
'Credential project does not match the '
'project scope of the delegated token'
)
)


def _build_target_enforcement():
target = {}
try:
Expand Down Expand Up @@ -155,6 +207,7 @@ def get(self):
# If the request was filtered, make sure to return only the
# credentials specific to that user. This makes it so that users with
# roles on projects can't see credentials that aren't theirs.
token = self.auth_context['token']
filtered_refs = []
for ref in refs:
# Check each credential again to make sure the user has access to
Expand All @@ -167,8 +220,9 @@ def get(self):
action='identity:get_credential',
target_attr={'credential': cred},
)
_check_credential_project_scope(token, self.oslo_context, cred)
filtered_refs.append(ref)
except exception.Forbidden:
except (exception.Forbidden, exception.ForbiddenAction):
pass
refs = filtered_refs
refs = [_blob_to_json(r) for r in refs]
Expand All @@ -187,13 +241,13 @@ def post(self):
ENFORCER.enforce_call(
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(
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)
_check_credential_project_scope(token, self.oslo_context, credential)
ref = self._assign_unique_id(
self._normalize_dict(credential),
trust_id=trust_id,
Expand Down Expand Up @@ -239,6 +293,9 @@ def get(self, credential_id: str):
build_target=_build_target_enforcement,
)
credential = PROVIDERS.credential_api.get_credential(credential_id)
_check_credential_project_scope(
self.auth_context['token'], self.oslo_context, credential
)
return self.wrap_member(_blob_to_json(credential))

@validation.request_body_schema(schema.update_request_body)
Expand All @@ -253,7 +310,9 @@ def patch(self, credential_id: str):
build_target=_build_target_enforcement,
)
current = PROVIDERS.credential_api.get_credential(credential_id)

_check_credential_project_scope(
self.auth_context['token'], self.oslo_context, current
)
credential = self.request_body_json.get('credential', {})
self._validate_blob_update_keys(current.copy(), credential.copy())
self._require_matching_id(credential)
Expand All @@ -276,6 +335,10 @@ def delete(self, credential_id: str):
action='identity:delete_credential',
build_target=_build_target_enforcement,
)
credential = PROVIDERS.credential_api.get_credential(credential_id)
_check_credential_project_scope(
self.auth_context['token'], self.oslo_context, credential
)

return (
PROVIDERS.credential_api.delete_credential(
Expand Down
11 changes: 11 additions & 0 deletions keystone/api/os_oauth1.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,17 @@ def put(self, request_token_id):
'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)

Expand Down
46 changes: 35 additions & 11 deletions keystone/api/trusts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,21 @@

from keystone.api._shared import json_home_relations
from keystone.api import validation
from keystone.common import authorization
from keystone.common import context
from keystone.common import json_home
from keystone.common import provider_api
from keystone.common import rbac_enforcer
from keystone.common.rbac_enforcer import policy
from keystone.common import utils
import keystone.conf
from keystone import exception
from keystone.i18n import _
from keystone.server import flask as ks_flask
from keystone.trust import schema

LOG = log.getLogger(__name__)
CONF = keystone.conf.CONF
ENFORCER = rbac_enforcer.RBACEnforcer
PROVIDERS = provider_api.ProviderAPIs

Expand All @@ -47,6 +50,30 @@
)


def _check_application_credential():
"""Block application credential tokens from all trust operations.

Application credentials are single-project delegation tokens. Allowing
them to read or manage trusts would permit a compromised application
credential to enumerate or manipulate the trust delegation chain,
expanding its effective scope beyond the single project it was issued for.
This applies regardless of the 'unrestricted' flag.
"""
if CONF.security_compliance.allow_insecure_application_credential_trust_escalation:
return
auth_context = flask.request.environ.get(
authorization.AUTH_CONTEXT_ENV, {}
)
token = auth_context.get('token')
if token and 'application_credential' in token.methods:
raise exception.ForbiddenAction(
action=_(
"Using method 'application_credential' is not "
"allowed for managing trusts."
)
)


def _build_trust_target_enforcement():
target = {}
# NOTE(cmurphy) unlike other APIs, in the event the trust doesn't exist or
Expand Down Expand Up @@ -102,16 +129,7 @@ def _normalize_trust_roles(trust):

class TrustResourceBase(ks_flask.ResourceBase):
def _check_unrestricted(self):
if self.oslo_context.is_admin:
return
token = self.auth_context['token']
if 'application_credential' in token.methods:
if not token.application_credential['unrestricted']:
action = _(
"Using method 'application_credential' is not "
"allowed for managing trusts."
)
raise exception.ForbiddenAction(action=action)
_check_application_credential()


class TrustsResource(TrustResourceBase):
Expand Down Expand Up @@ -200,6 +218,7 @@ def get(self):
)
else:
ENFORCER.enforce_call(action='identity:list_trusts')
_check_application_credential()

trusts = []

Expand Down Expand Up @@ -260,8 +279,10 @@ def post(self):

POST /v3/OS-TRUST/trusts
"""
ENFORCER.enforce_call(action='identity:create_trust')
trust = self.request_body_json.get('trust', {})
ENFORCER.enforce_call(
action='identity:create_trust', target_attr={'trust': trust}
)
self._check_unrestricted()

if trust.get('project_id') and not trust.get('roles'):
Expand Down Expand Up @@ -313,6 +334,7 @@ def get(self, trust_id):
action='identity:get_trust',
build_target=_build_trust_target_enforcement,
)
_check_application_credential()

# NOTE(cmurphy) look up trust before doing is_admin authorization - to
# maintain the API contract, we expect a missing trust to raise a 404
Expand Down Expand Up @@ -416,6 +438,7 @@ def get(self, trust_id):
raise exception.ForbiddenAction(
action=_('Requested user has no relation to this trust')
)
_check_application_credential()

trust = PROVIDERS.trust_api.get_trust(trust_id)

Expand Down Expand Up @@ -467,6 +490,7 @@ def get(self, trust_id, role_id):
raise exception.ForbiddenAction(
action=_('Requested user has no relation to this trust')
)
_check_application_credential()

trust = PROVIDERS.trust_api.get_trust(trust_id)

Expand Down
Loading
Loading