Delegated tokens (trusts, application credentials, OAuth1 access tokens)
are scoped to a single project at delegation time. This must be enforced
thoroughly while granting the API access to Keystone resources that
might be also bound to a single project. Without this it is possible to
gain different access (using trust to see application credentials for a
different project, reuse the MFA seed, etc).
* Credentials CRUD (/v3/credentials)
All five CRUD operations verified ownership via user_id but did not bind
credential.project_id to the delegating token's project scope.
Fix: _check_credential_project_scope() - no-op for non-delegated tokens,
raises ForbiddenAction on project mismatch. For list, out-of-scope
credentials are silently filtered.
Credentials with project_id=None (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.
* OS-EC2 credential CRUD (/v3/users/{id}/credentials/OS-EC2)
POST accepted any tenant_id from a delegated token. GET and DELETE had
no delegation check at all.
Fix: _check_delegation_for_ec2() enforces the project boundary;
list silently filters.
Additionally, pre-existing OAuth1 access-token-backed EC2 credentials
with a mismatched project_id could be used at auth-time (POST /v3/ec2tokens)
to obtain a cross-project token. Added a check in EC2_S3_Resource.py that
cred_data['project_id'] matches access_token['project_id'] before issuing
the token. The trust branch does not need this check - the token provider
uses the trust's project regardless of the credential's project_id.
* OS-OAUTH1 access token management (/v3/users/{id}/OS-OAUTH1/access_tokens)
GET and DELETE had no delegation check. List blocked trust/OAuth but not
app-cred tokens.
Fix: _block_delegated_token() raises Forbidden for any delegation type
on list, get, and delete.
* Application credential management (/v3/users/{id}/application_credentials)
Trust-scoped and OAuth1 tokens had no guard on the application credential
and access rule management APIs. An impersonating trust could LIST, CREATE,
or DELETE application credentials, creating a persistent backdoor that
outlives the trust's own expiry. App credential tokens are intentionally
excluded - the unrestricted/restricted distinction is handled separately by
_check_unrestricted_application_credential.
Fix: _block_delegated_token_app_creds() raises Forbidden for trust-scoped
and OAuth1 tokens on all six app credential and access rule endpoints.
Closes-Bug: #2150089
Related-Bug: #2149789
Related-Bug: #2149775
Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Change-Id: Iaaa0ec713a0a5e062acc3209d6010982899d8f6f
Signed-off-by: Grzegorz Grasza <xek@redhat.com>
Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
318 lines
11 KiB
Python
318 lines
11 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
from oslo_config import cfg
|
|
|
|
from keystone.conf import utils
|
|
|
|
disable_user_account_days_inactive = cfg.IntOpt(
|
|
'disable_user_account_days_inactive',
|
|
min=1,
|
|
help=utils.fmt(
|
|
"""
|
|
The maximum number of days a user can go without authenticating before being
|
|
considered "inactive" and automatically disabled (locked). This feature is
|
|
disabled by default; set any value to enable it. This feature depends on the
|
|
`sql` backend for the `[identity] driver`. When a user exceeds this threshold
|
|
and is considered "inactive", the user's `enabled` attribute in the HTTP API
|
|
may not match the value of the user's `enabled` column in the user table.
|
|
"""
|
|
),
|
|
)
|
|
|
|
lockout_failure_attempts = cfg.IntOpt(
|
|
'lockout_failure_attempts',
|
|
min=1,
|
|
help=utils.fmt(
|
|
"""
|
|
The maximum number of times that a user can fail to authenticate before the
|
|
user account is locked for the number of seconds specified by
|
|
`[security_compliance] lockout_duration`. This feature is disabled by
|
|
default. If this feature is enabled and `[security_compliance]
|
|
lockout_duration` is not set, then users may be locked out indefinitely
|
|
until the user is explicitly enabled via the API. This feature depends on
|
|
the `sql` backend for the `[identity] driver`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
lockout_duration = cfg.IntOpt(
|
|
'lockout_duration',
|
|
default=1800,
|
|
min=1,
|
|
help=utils.fmt(
|
|
"""
|
|
The number of seconds a user account will be locked when the maximum number of
|
|
failed authentication attempts (as specified by `[security_compliance]
|
|
lockout_failure_attempts`) is exceeded. Setting this option will have no effect
|
|
unless you also set `[security_compliance] lockout_failure_attempts` to a
|
|
non-zero value. This feature depends on the `sql` backend for the `[identity]
|
|
driver`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
password_expires_days = cfg.IntOpt(
|
|
'password_expires_days',
|
|
min=1,
|
|
help=utils.fmt(
|
|
"""
|
|
The number of days for which a password will be considered valid
|
|
before requiring it to be changed. This feature is disabled by default. If
|
|
enabled, new password changes will have an expiration date, however existing
|
|
passwords would not be impacted. This feature depends on the `sql` backend for
|
|
the `[identity] driver`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
unique_last_password_count = cfg.IntOpt(
|
|
'unique_last_password_count',
|
|
default=0,
|
|
min=0,
|
|
help=utils.fmt(
|
|
"""
|
|
This controls the number of previous user password iterations to keep in
|
|
history, in order to enforce that newly created passwords are unique. The total
|
|
number which includes the new password should not be greater or equal to this
|
|
value. Setting the value to zero (the default) disables this feature. Thus, to
|
|
enable this feature, values must be greater than 0. This feature depends on
|
|
the `sql` backend for the `[identity] driver`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
minimum_password_age = cfg.IntOpt(
|
|
'minimum_password_age',
|
|
default=0,
|
|
min=0,
|
|
help=utils.fmt(
|
|
"""
|
|
The number of days that a password must be used before the user can change it.
|
|
This prevents users from changing their passwords immediately in order to wipe
|
|
out their password history and reuse an old password. This feature does not
|
|
prevent administrators from manually resetting passwords. It is disabled by
|
|
default and allows for immediate password changes. This feature depends on the
|
|
`sql` backend for the `[identity] driver`. Note: If `[security_compliance]
|
|
password_expires_days` is set, then the value for this option should be less
|
|
than the `password_expires_days`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
password_regex = cfg.StrOpt(
|
|
'password_regex',
|
|
help=utils.fmt(
|
|
r"""
|
|
The regular expression used to validate password strength requirements. By
|
|
default, the regular expression will match any password. The following is an
|
|
example of a pattern which requires at least 1 letter, 1 digit, and have a
|
|
minimum length of 7 characters: ^(?=.*\\\d)(?=.*[a-zA-Z]).{7,}$ This feature
|
|
depends on the `sql` backend for the `[identity] driver`.
|
|
"""
|
|
),
|
|
) # noqa: W605
|
|
|
|
password_regex_description = cfg.StrOpt(
|
|
'password_regex_description',
|
|
help=utils.fmt(
|
|
"""
|
|
Describe your password regular expression here in language for humans. If a
|
|
password fails to match the regular expression, the contents of this
|
|
configuration variable will be returned to users to explain why their
|
|
requested password was insufficient.
|
|
"""
|
|
),
|
|
)
|
|
|
|
change_password_upon_first_use = cfg.BoolOpt(
|
|
'change_password_upon_first_use',
|
|
default=False,
|
|
help=utils.fmt(
|
|
"""
|
|
Enabling this option requires users to change their password when the user is
|
|
created, or upon administrative reset. Before accessing any services, affected
|
|
users will have to change their password. To ignore this requirement for
|
|
specific users, such as service users, set the `options` attribute
|
|
`ignore_change_password_upon_first_use` to `True` for the desired user via the
|
|
update user API. This feature is disabled by default. This feature is only
|
|
applicable with the `sql` backend for the `[identity] driver`.
|
|
"""
|
|
),
|
|
)
|
|
|
|
report_invalid_password_hash = cfg.ListOpt(
|
|
'report_invalid_password_hash',
|
|
default="",
|
|
sample_default="event",
|
|
item_type=cfg.types.String(
|
|
choices=[
|
|
(
|
|
"event",
|
|
utils.fmt("""
|
|
Enriches `identity.authenticate.failure` event notifications with partial
|
|
invalid password hash
|
|
"""),
|
|
)
|
|
# ("log", "description"),
|
|
]
|
|
),
|
|
help=utils.fmt(
|
|
"""
|
|
When configured, enriches the corresponding output channel with hash of invalid
|
|
password, which could be further used to distinguish bruteforce attacks from
|
|
e.g. external user automations that did not timely update rotated password by
|
|
analyzing variability of the hash value.
|
|
Additional configuration parameters are available using other
|
|
`invalid_password_hash_*` configuration entires, that only take effect when
|
|
this option is activated.
|
|
"""
|
|
),
|
|
)
|
|
|
|
invalid_password_hash_secret_key = cfg.StrOpt(
|
|
'invalid_password_hash_secret_key',
|
|
secret=True,
|
|
help=utils.fmt(
|
|
"""
|
|
If `report_invalid_password_hash` is configured, uses provided secret key when
|
|
generating password hashes to make them unique and distinct from any other
|
|
Keystone installations out there. Should be some secret static value specific
|
|
to the current installation (the same value should be used in distributed
|
|
installations working with the same backend, to make them all generate equal
|
|
hashes for equal invalid passwords). 16 bytes (128 bits) or more is
|
|
recommended.
|
|
"""
|
|
),
|
|
)
|
|
|
|
invalid_password_hash_function = cfg.StrOpt(
|
|
'invalid_password_hash_function',
|
|
default='sha256',
|
|
help=utils.fmt(
|
|
"""
|
|
If `report_invalid_password_hash` is configured, defines the hash function to
|
|
be used by HMAC. Possible values are names suitable to hashlib.new() -
|
|
https://docs.python.org/3/library/hashlib.html#hashlib.new.
|
|
"""
|
|
),
|
|
)
|
|
|
|
invalid_password_hash_max_chars = cfg.IntOpt(
|
|
'invalid_password_hash_max_chars',
|
|
min=1,
|
|
sample_default=5,
|
|
help=utils.fmt(
|
|
"""
|
|
If `report_invalid_password_hash` is configured, defines the number of
|
|
characters of hash of invalid password to be returned. When not specified,
|
|
returns full hash. Its length depends on implementation and
|
|
`invalid_password_hash_function` configuration, but is typically 16+
|
|
characters. It's recommended to use the least reasonable value however - it's
|
|
the most effective measure to protect the hashes.
|
|
"""
|
|
),
|
|
)
|
|
|
|
|
|
allow_insecure_admin_trust_cross_project_credentials_access = cfg.BoolOpt(
|
|
'allow_insecure_admin_trust_cross_project_credentials_access',
|
|
default=False,
|
|
deprecated_for_removal=True,
|
|
deprecated_reason=utils.fmt(
|
|
"""
|
|
Migrate automated workflows that use admin-role trusts to access credentials
|
|
across multiple projects (e.g. Mistral cron triggers) to use non-delegated
|
|
service account credentials instead, then remove this option.
|
|
"""
|
|
),
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
),
|
|
)
|
|
|
|
|
|
allow_insecure_application_credential_trust_escalation = cfg.BoolOpt(
|
|
'allow_insecure_application_credential_trust_escalation',
|
|
default=False,
|
|
deprecated_for_removal=True,
|
|
deprecated_reason=utils.fmt(
|
|
"""
|
|
Migrate workflows where application credentials create trusts to use OIDC
|
|
federation flows (v3oidcclientcredentials, v3oidcdeviceauthz) instead, then
|
|
remove this option.
|
|
"""
|
|
),
|
|
deprecated_since='2026.1',
|
|
help=utils.fmt(
|
|
"""
|
|
INSECURE: When enabled, application credential tokens (including restricted
|
|
ones) are allowed to create, delete, and list trusts. By default (False),
|
|
application credential tokens are blocked from all trust operations regardless
|
|
of the unrestricted flag, because allowing an application credential to
|
|
bootstrap a trust creates a new delegation context. A trust-scoped token
|
|
produced from that trust can then access authentication material (EC2
|
|
credentials, TOTP seeds) and operate entirely outside the delegation chain,
|
|
breaking the audit trail. The 'unrestricted' flag governs credential
|
|
management, not trust management.
|
|
|
|
Enable this only if you have workflows where application credentials must
|
|
create trusts (e.g. Heat stacks authenticated via application credentials).
|
|
Use OIDC federation flows (v3oidcclientcredentials, v3oidcdeviceauthz) as the
|
|
proper long-term alternative. This option is deprecated and will be removed
|
|
in a future release.
|
|
"""
|
|
),
|
|
)
|
|
|
|
|
|
GROUP_NAME = __name__.split('.')[-1]
|
|
ALL_OPTS = [
|
|
disable_user_account_days_inactive,
|
|
lockout_failure_attempts,
|
|
lockout_duration,
|
|
password_expires_days,
|
|
unique_last_password_count,
|
|
minimum_password_age,
|
|
password_regex,
|
|
password_regex_description,
|
|
change_password_upon_first_use,
|
|
report_invalid_password_hash,
|
|
invalid_password_hash_secret_key,
|
|
invalid_password_hash_function,
|
|
invalid_password_hash_max_chars,
|
|
allow_insecure_admin_trust_cross_project_credentials_access,
|
|
allow_insecure_application_credential_trust_escalation,
|
|
]
|
|
|
|
|
|
def register_opts(conf):
|
|
conf.register_opts(ALL_OPTS, group=GROUP_NAME)
|
|
|
|
|
|
def list_opts():
|
|
return {GROUP_NAME: ALL_OPTS}
|