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
1 change: 1 addition & 0 deletions doc/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* [Federation IDP](adr/0006-federation-idp.md)
* [Federation Mapping](adr/0007-federation-mapping.md)
* [Workload Federation](adr/0008-workload-federation.md)
* [Auth token revocation](adr/0009-auth-token-revoke.md)
* [Policy enforcement](./policy.md)
* [Fernet token]()
* [Token payloads]()
Expand Down
119 changes: 119 additions & 0 deletions doc/src/adr/0009-auth-token-revoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 9. Auth token revocation

Date: 2025-11-18

## Status

Accepted

## Context

Issued tokens are having certain configurable validity. In cases when a user
need to be disabled, the project deactivated, or simply to prevent the token use
after the work has been completed it is necessary to provide the possibility to
invalidate the tokens. Python Keystone provides this possibility and so it is
necessary to implement it in the same way.

Since original functionality is not explicitly documented this ADR will become
the base of such information.

## Decision

Fernet token revocation is implemented based on the `revocation_event` database
table.

The table has following fields:

```
pub id: i32,
pub domain_id: Option<String>,
pub project_id: Option<String>,
pub user_id: Option<String>,
pub role_id: Option<String>,
pub trust_id: Option<String>,
pub consumer_id: Option<String>,
pub access_token_id: Option<String>,
pub issued_before: DateTime,
pub expires_at: Option<DateTime>,
pub revoked_at: DateTime,
pub audit_id: Option<String>,
pub audit_chain_id: Option<String>,
```

### Token revocation

When a revocation of thecurrently valid token is being requested the record with
the following information is being inserted into the database:

- `audit_id` is populated with the first entry of the token `audit_ids` list.
When this list is empty an error is being returned.
- `issued_before` is set to the current time with the UTC timezone.
- `revoked_at` is set to the current time with the UTC timezone.
- other fields are left empty.

### Revocation check

A token validation for being revoked is performed based on the presence of the
revocation events in the `revocation_event` table matching the expanded token
properties. This means that before the token revocation is being checked
additional database queries for expanding the scope information including the
roles the token is granting are performed.

Following conditions are combined with the AND condition:

- First element of the token's `audit_ids` property is compared against the
database record. When this list is empty an error is being returned.
- `token.project_id` is compared against the database record when present.
- `token.user_id` is compared against the database record when present.
- `token.trustor_id` is compared against the database record `user_id` when present.
- `token.trustee_id` is compared against the database record `user_id` when present.
- `token.trust_id` is compared against the database record `trust_id` when present.
- `token.issued_at` is compared against the database record with
`revocation_event.issued_before >= token.issued_at`.

Python version of the Keystone applies additional match verification for the
selected data on the server side and not in the database query.

- When `revocation_event.domain_id` is set it is compared against
`token.domain_id` and `token.identity_domain_id`.
- When `revocation_event.role_id` is present it is compared against every of the
`token.roles`.

After the first non matching result further evaluation is being stopped.
Logically there does not seem to be a reason for such handling and it looks to
be an evolutionary design decision. Following checks can be added into the
single database query with a different logic only comparing the corresponding
fields when the column is not empty.

While following checks allow much higher details of the revocation events in the
context of the usual fernet token revocation it is only going to match on the
`audit_id` and `issued_before`.


### Revocation table purge

In the python Keystone there is no automatic cleanup handling. Due to that
expired records are removed during the revocation check. Records to be expired
are selected using the following logic.

- `expire_delta = CONF.token.expiration + CONF.token.expiration_buffer`
- `oldest = utc.now() - expire_delta`
- `DELETE from revocation_event WHERE revoked_at < oldest`

When both python and rust Keystone versions are deployed in parallel and both
try to delete expired records errors can occur. However, if only rust version is
validating the tokens python version will not perform any backups. Additionally
no errors were reported yet in installations with multiple Keystone instances.
Therefore it is necessary for the rust implementation to do periodic cleanup. It
should be exexcuted with the following query filter: `revoked_at < (now -
(expiration + expiration_buffer))`. Such implementation must be made optional
with possibility to disable this behavior using the config file.

## Consequences

- Database table with the revocation events must be periodically cleaned up.

- Token validation processing time is increased with the database lookup.

- Expired revocation records are optionally periodically cleaned by the rust
implementation.
26 changes: 26 additions & 0 deletions policy/auth/token/revoke.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package identity.auth.token.revoke

import data.identity

# Revoke the token

default allow := false

allow if {
"admin" in input.credentials.roles
}

# allow if {
# "service" in input.credentials.roles
# }

# allow if {
# "reader" in input.credentials.roles
# input.credentials.system_scope != null
# "all" == input.credentials.system_scope
# }

allow if {
identity.token_subject
}

15 changes: 15 additions & 0 deletions policy/auth/token/revoke_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package test_auth_token_revoke

import data.identity.auth.token.revoke

test_allowed if {
revoke.allow with input as {"credentials": {"roles": ["admin"]}}
revoke.allow with input as {"credentials": {"user_id": "foo"}, "target": {"token": {"user_id": "foo"}}}
}

test_forbidden if {
not revoke.allow with input as {"credentials": {"roles": ["reader"], "system_scope": "not_all"}}
not revoke.allow with input as {"credentials": {"roles": ["manager"], "user_id": "foo"}, "target": {"token": {"user_id": "bar"}}}
not revoke.allow with input as {"credentials": {"roles": ["member"], "user_id": "foo"}, "target": {"token": {"user_id": "bar"}}}
not revoke.allow with input as {"credentials": {"roles": ["reader"], "user_id": "foo"}, "target": {"token": {"user_id": "bar"}}}
}
13 changes: 3 additions & 10 deletions src/api/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,18 @@ where
auth_header
} else {
debug!("No supported information has been provided.");
return Err(KeystoneApiError::Unauthorized)?;
return Err(KeystoneApiError::Unauthorized(None))?;
};

let state = Arc::from_ref(state);

let token = state
.provider
.get_token_provider()
.validate_token(&state, auth_header, Some(false), None)
.validate_token(&state, auth_header, Some(false), None, Some(true))
.await
.inspect_err(|e| error!("{:#?}", e))
.map_err(|_| KeystoneApiError::Unauthorized)?;

// Expand the information (user, project, roles, etc) about the user when a token is valid
let token = state
.provider
.get_token_provider()
.expand_token_information(&state, &token)
.await?;
.map_err(|_| KeystoneApiError::Unauthorized(None))?;

Ok(Self(token))
}
Expand Down
11 changes: 7 additions & 4 deletions src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ pub enum KeystoneApiError {
#[error("{0}.")]
BadRequest(String),

#[error("The request you have made requires authentication.")]
Unauthorized,
#[error("{}", .0.clone().unwrap_or("The request you have made requires authentication.".to_string()))]
Unauthorized(Option<String>),

#[error("You are not authorized to perform the requested action.")]
Forbidden,
Expand Down Expand Up @@ -187,7 +187,7 @@ impl IntoResponse for KeystoneApiError {
KeystoneApiError::NotFound { .. } => StatusCode::NOT_FOUND,
KeystoneApiError::BadRequest(..) => StatusCode::BAD_REQUEST,
KeystoneApiError::UserDisabled(..) => StatusCode::UNAUTHORIZED,
KeystoneApiError::Unauthorized => StatusCode::UNAUTHORIZED,
KeystoneApiError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
// KeystoneApiError::AuthenticationInfo { .. } => StatusCode::UNAUTHORIZED,
KeystoneApiError::Forbidden => StatusCode::FORBIDDEN,
KeystoneApiError::Policy { .. } => StatusCode::FORBIDDEN,
Expand Down Expand Up @@ -356,7 +356,7 @@ impl From<AuthenticationError> for KeystoneApiError {
AuthenticationError::TokenRenewalForbidden => {
KeystoneApiError::SelectedAuthenticationForbidden
}
AuthenticationError::Unauthorized => KeystoneApiError::Unauthorized,
AuthenticationError::Unauthorized => KeystoneApiError::Unauthorized(None),
}
}
}
Expand All @@ -365,6 +365,9 @@ 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
8 changes: 4 additions & 4 deletions src/api/v3/auth/token/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub(super) async fn authenticate_request(
}
}
authenticated_info
.ok_or(KeystoneApiError::Unauthorized)
.ok_or(KeystoneApiError::Unauthorized(None))
.and_then(|authn| {
authn.validate()?;
Ok(authn)
Expand All @@ -120,14 +120,14 @@ pub(super) async fn get_authz_info(
if let Some(project) = find_project_from_scope(state, scope).await? {
AuthzInfo::Project(project)
} else {
return Err(KeystoneApiError::Unauthorized);
return Err(KeystoneApiError::Unauthorized(None));
}
}
Some(Scope::Domain(scope)) => {
if let Ok(domain) = get_domain(state, scope.id.as_ref(), scope.name.as_ref()).await {
AuthzInfo::Domain(domain)
} else {
return Err(KeystoneApiError::Unauthorized);
return Err(KeystoneApiError::Unauthorized(None));
}
}
Some(Scope::System(_scope)) => {
Expand Down Expand Up @@ -337,7 +337,7 @@ mod tests {
},
)
.await;
if let KeystoneApiError::Unauthorized = rsp.unwrap_err() {
if let KeystoneApiError::Unauthorized(..) = rsp.unwrap_err() {
} else {
panic!("Should receive Unauthorized");
}
Expand Down
Loading
Loading