Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion doc/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
* [Passkey Auth](adr/0005-auth-passkey.md)
* [Federation IDP](adr/0006-federation-idp.md)
* [Federation Mapping](adr/0007-federation-mapping.md)
* [Workload Federation](adr/0008-workload-federation.md)
* [Workload Federation](adr/0008-federation-workload.md)
* [Auth token revocation](adr/0009-auth-token-revoke.md)
* [PCI-DSS: Failed Auth Protection](adr/0010-pci-dss-failed-auth-protection.md)
* [PCI-DSS: Inactive Account Deactivation](adr/0011-pci-dss-inactive-account-deactivation.md)
* [PCI-DSS: Account Password Expiration](adr/0012-pci-dss-account-password-expiry.md)
* [Policy enforcement](./policy.md)
* [Fernet token]()
* [Token payloads]()
Expand Down
89 changes: 89 additions & 0 deletions doc/src/adr/0010-pci-dss-failed-auth-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 10. PCI-DSS requirement: Invalid authentication attempts are limited

Date: 2025-11-27

## Status

Accepted

## Context

PCI-DSS contains the following requirement to the IAM system:

Invalid authentication attempts are limited by:

- Locking out the user ID after not more than 10 attempts.
- Setting the lockout duration to a minimum of 30 minutes or until the user's
identity is confirmed.

Python Keystone implements this requirement with the help of the
`conf.security_compliance.lockout_duration` during the login attempt to identify
whether the user is currently temporarily disabled:

```python

def _is_account_locked(self, user_id, user_ref):
"""Check if the user account is locked.

Checks if the user account is locked based on the number of failed
authentication attempts.

:param user_id: The user ID
:param user_ref: Reference to the user object
:returns Boolean: True if the account is locked; False otherwise

"""
ignore_option = user_ref.get_resource_option(
options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_id
)
if ignore_option and ignore_option.option_value is True:
return False

attempts = user_ref.local_user.failed_auth_count or 0
max_attempts = CONF.security_compliance.lockout_failure_attempts
lockout_duration = CONF.security_compliance.lockout_duration
if max_attempts and (attempts >= max_attempts):
if not lockout_duration:
return True
else:
delta = datetime.timedelta(seconds=lockout_duration)
last_failure = user_ref.local_user.failed_auth_at
if (last_failure + delta) > timeutils.utcnow():
return True
else:
self._reset_failed_auth(user_id)
return False
```

## Decision

For compatibility reasons rust implementation must adhere to the requirement.

During password authentication before validating the password following check
must be applied part of the locked account verification:

- When `conf.security_compliance.lockout_duration` and
`conf.security_compliance.lockout_failure_attempts` are not set the account is
NOT locked.

- When `user_options.IGNORE_LOCKOUT_ATTEMPT` is set user account is NOT locked

- When `user.failed_auth_count >= conf.security_compliance.lockout_failure_attempts`
the account is locked.

- When `user.failed_auth_at + conf.security_compliance.lockout_duration >
now()` account is locked. When the time is `< now()` - reset the counters
in the database.

- Otherwise the account is NOT locked.

After the authentication is success the `user.failed_auth_at` and
`user.failed_auth_count` are being reset. In the case of failed authentication
such attempt sets the mentioned properties correspondingly.

## Consequences

- Authentication with methods other than username password are not protected.

- Reactivating the temporarily locked account can be performed by the admin or
domain admin via resetting the `user.failed_auth_count` attribute.
99 changes: 99 additions & 0 deletions doc/src/adr/0011-pci-dss-inactive-account-deactivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 11. PCI-DSS requirement: Inactive user accounts are removed/disabled

Date: 2025-11-27

## Status

Accepted

## Context

PCI-DSS contains the following requirement to the IAM system:

Inactive user accounts are removed or disabled within 90 days of inactivity.

Python Keystone implements this requirement with the help of the
`conf.security_compliance.disable_user_account_days_inactive` during the login
attempt to identify whether the user is currently active or deactivated:

```python

def enabled(self):
"""Return whether user is enabled or not."""
if self._enabled:
max_days = (
CONF.security_compliance.disable_user_account_days_inactive
)
inactivity_exempt = getattr(
self.get_resource_option(
iro.IGNORE_USER_INACTIVITY_OPT.option_id
),
'option_value',
False,
)
last_active = self.last_active_at
if not last_active and self.created_at:
last_active = self.created_at.date()
if max_days and last_active:
now = timeutils.utcnow().date()
days_inactive = (now - last_active).days
if days_inactive >= max_days and not inactivity_exempt:
self._enabled = False
return self._enabled

```

In python Keystone there is no periodic process that deactivates inactive
accounts. Instead it is calculated on demand during the login process and
listint/showing user details. With the new application architecture in Rust it
is possible to implement background processes that disable inactive users. This
allows doing less calculations during user authentication and fetching since it
is possible to rely that the background process deactivates accounts when
necessary.

## Decision

For compatibility reasons rust implementation must adhere to the requirement.

After successful authentication when `user.enabled` attribute is not true the
authentication request must be rejected with `http.Unauthorized`.

Additional background process must be implemented to deactivate inactive
accounts. For this when
`conf.security_compliance.disable_user_account_days_inactive` is set a process
should loop over all user accounts. When the `user.last_active_at +
disable_user_account_days_inactive < now()` presence of the
`user.options.IGNORE_USER_INACTIVITY_OPT` should be checked. When absent the
account must be updated setting `user.enabled` to `false`.

Since it is technically possible that the background process is not running for
any reason the same logic should be applied also when converting the identity
backend data to the internal account representation and applied when the user
data is reported by the backend as active. On the other hand having a separate
background process helps updating account data in the backend and produce audit
records on time without waiting for the on-demand logic to apply. It also allows
disabling accounts in the remote identity backends that are connected with
read/write mode (i.e. SCIM push).

After the successful authentication of the user with password or the federated
workflow the `user.last_active_at` should be set to the current date time.

## Consequences

- Authentication with methods other than username password are not updating the
`lst_active_at`. Due to that the account that used i.e. application
credentials for the activation for more than X days would become disabled. This
requires account to perform periodic login using the password.

- It should be considered to update application credentials workflow to update
the `user.last_active_at` attribute after successful authentication.

- It could happen that the periodic account deactivation process does not work
for certain amount of time (i.e due to bugs in the code or the chosen
frequency) allowing the user to login when it should have been disabled. This
can be only prevented by applying the same logic during the conversion of the
database entry to the internal `User` structure the same way like python
keystone is doing.

- Administrator account can be deactivated. Separate tooling or documentation
how to unlock the account must be present.
46 changes: 46 additions & 0 deletions doc/src/adr/0012-pci-dss-account-password-expiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 12. PCI-DSS requirement: Inactive user accounts are removed/disabled

Date: 2025-11-27

## Status

Accepted

## Context

PCI-DSS contains the following requirement to the IAM system:

If passwords/passphrases are used as the only authentication factor for user
access (i.e., in any single-factor authentication implementation) then either:
• Passwords/passphrases are changed at least once every 90 days, OR
• The security posture of accounts is dynamically analyzed, and real-time
access to resources is automatically determined accordingly.

Python Keystone implements this requirement with the help of the
`conf.security_compliance.password_expires_days` and `password.expires_at`
during the login attempt to identify whether the specified used password is
expired. `user.options.IGNORE_PASSWORD_EXPIRY_OPT` option allows bypassing the
expiration check.

## Decision

For compatibility reasons rust implementation must adhere to the requirement.

Password expiration is performed after verification that the password is valid.

- `password.expires_at_int` (as epoch seconds) or the `password.expires_at` (as
date time specifies the password expiration. When none is set password is
considered as valid. Otherwise it is compared against the current time.

- During account password update operation when user is not having the
`user.options.IGNORE_PASSWORD_EXPIRY_OPT` option enabled the current date time
plus the `conf.security_compliance.password_expires_days` time is persisted as
the `password.expires_at_int` property.

- Password expiration MUST NOT be enforced in the password change flow to
prevent a permanent lock out.

## Consequences

- Administrator account can be deactivated. Separate tooling or documentation
how to unlock the account must be present.
3 changes: 3 additions & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# format_code_in_doc_comments = true
# unstable_features = true
# wrap_comments = true
1 change: 0 additions & 1 deletion src/api/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
//
// SPDX-License-Identifier: Apache-2.0
//! Common API helpers
//!
use crate::api::error::KeystoneApiError;
use crate::api::types::ProjectScope;
use crate::keystone::ServiceState;
Expand Down
32 changes: 21 additions & 11 deletions src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,6 @@ pub enum KeystoneApiError {
#[error(transparent)]
JsonExtractorRejection(#[from] JsonRejection),

#[error("the account is disabled for user: {0}")]
UserDisabled(String),

/// Selected authentication is forbidden.
#[error("selected authentication is forbidden")]
SelectedAuthenticationForbidden,
Expand All @@ -186,9 +183,7 @@ impl IntoResponse for KeystoneApiError {
KeystoneApiError::Conflict(_) => StatusCode::CONFLICT,
KeystoneApiError::NotFound { .. } => StatusCode::NOT_FOUND,
KeystoneApiError::BadRequest(..) => StatusCode::BAD_REQUEST,
KeystoneApiError::UserDisabled(..) => StatusCode::UNAUTHORIZED,
KeystoneApiError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
// KeystoneApiError::AuthenticationInfo { .. } => StatusCode::UNAUTHORIZED,
KeystoneApiError::Forbidden => StatusCode::FORBIDDEN,
KeystoneApiError::Policy { .. } => StatusCode::FORBIDDEN,
KeystoneApiError::SelectedAuthenticationForbidden
Expand All @@ -202,7 +197,11 @@ impl IntoResponse for KeystoneApiError {
| KeystoneApiError::RevokeProvider { .. }
| KeystoneApiError::Other(..) => StatusCode::INTERNAL_SERVER_ERROR,
_ =>
// KeystoneApiError::SubjectTokenMissing | KeystoneApiError::InvalidHeader | KeystoneApiError::InvalidToken | KeystoneApiError::Token{..} | KeystoneApiError::WebAuthN{..} | KeystoneApiError::Uuid {..} | KeystoneApiError::Serde {..} | KeystoneApiError::DomainIdOrName | KeystoneApiError::ProjectIdOrName | KeystoneApiError::ProjectDomain =>
// KeystoneApiError::SubjectTokenMissing | KeystoneApiError::InvalidHeader |
// KeystoneApiError::InvalidToken | KeystoneApiError::Token{..} |
// KeystoneApiError::WebAuthN{..} | KeystoneApiError::Uuid {..} |
// KeystoneApiError::Serde {..} | KeystoneApiError::DomainIdOrName |
// KeystoneApiError::ProjectIdOrName | KeystoneApiError::ProjectDomain =>
{
StatusCode::BAD_REQUEST
}
Expand Down Expand Up @@ -341,7 +340,8 @@ impl IntoResponse for WebauthnError {
WebauthnError::UserHasNoCredentials => "User Has No Credentials",
};

// its often easiest to implement `IntoResponse` by calling other implementations
// its often easiest to implement `IntoResponse` by calling other
// implementations
(StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
}
}
Expand All @@ -352,7 +352,20 @@ impl From<AuthenticationError> for KeystoneApiError {
AuthenticationError::AuthenticatedInfoBuilder { source } => {
KeystoneApiError::InternalError(source.to_string())
}
AuthenticationError::UserDisabled(data) => KeystoneApiError::UserDisabled(data),
AuthenticationError::UserDisabled(user_id) => KeystoneApiError::Unauthorized(Some(
format!("The account is disabled for the user: {user_id}"),
)),
AuthenticationError::UserLocked(user_id) => KeystoneApiError::Unauthorized(Some(
format!("The account is locked for the user: {user_id}"),
)),
AuthenticationError::UserPasswordExpired(user_id) => {
KeystoneApiError::Unauthorized(Some(format!(
"The password is expired and need to be changed for user: {user_id}"
)))
}
AuthenticationError::UserNameOrPasswordWrong => {
KeystoneApiError::Unauthorized(Some("Invalid username or password".to_string()))
}
AuthenticationError::TokenRenewalForbidden => {
KeystoneApiError::SelectedAuthenticationForbidden
}
Expand All @@ -365,9 +378,6 @@ impl From<IdentityProviderError> for KeystoneApiError {
fn from(value: IdentityProviderError) -> Self {
match value {
IdentityProviderError::AuthenticationInfo { source } => source.into(),
IdentityProviderError::WrongUsernamePassword => {
Self::Unauthorized(Some("Invalid username or password".to_string()))
}
_ => Self::IdentityError { source: value },
}
}
Expand Down
1 change: 0 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
//
// SPDX-License-Identifier: Apache-2.0
//! Keystone API
//!
use axum::{
extract::State,
http::{HeaderMap, header},
Expand Down
13 changes: 7 additions & 6 deletions src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,13 @@ impl From<Vec<(Service, Vec<ProviderEndpoint>)>> for Catalog {

/// The authorization scope, including the system, a project, or a domain.
///
/// If multiple scopes are specified in the same request (e.g. project and domain or domain and
/// system) an HTTP 400 Bad Request will be returned, as a token cannot be simultaneously scoped to
/// multiple authorization targets. An ID is sufficient to uniquely identify a project but if a
/// project is specified by name, then the domain of the project must also be specified in order to
/// uniquely identify the project by name. A domain scope may be specified by either the domain’s
/// ID or name with equivalent results.
/// If multiple scopes are specified in the same request (e.g. project and
/// domain or domain and system) an HTTP 400 Bad Request will be returned, as a
/// token cannot be simultaneously scoped to multiple authorization targets. An
/// ID is sufficient to uniquely identify a project but if a project is
/// specified by name, then the domain of the project must also be specified in
/// order to uniquely identify the project by name. A domain scope may be
/// specified by either the domain’s ID or name with equivalent results.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
Expand Down
9 changes: 5 additions & 4 deletions src/api/v3/auth/token/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ pub(super) async fn get_project_info_builder(
Ok(project_response)
}

/// Authenticate the user ignoring any scope information. It is important not to expose any
/// hints that user, project, domain, etc might exist before we have authenticated them by
/// taking different amount of time in case of certain validations.
/// Authenticate the user ignoring any scope information. It is important not to
/// expose any hints that user, project, domain, etc might exist before we have
/// authenticated them by taking different amount of time in case of certain
/// validations.
pub(super) async fn authenticate_request(
state: &ServiceState,
req: &AuthRequest,
Expand All @@ -61,7 +62,7 @@ pub(super) async fn authenticate_request(
state
.provider
.get_identity_provider()
.authenticate_by_password(state, req)
.authenticate_by_password(state, &req)
.await?,
);
}
Expand Down
Loading
Loading