diff --git a/doc/src/SUMMARY.md b/doc/src/SUMMARY.md index f8d7cdce..3f7f20b4 100644 --- a/doc/src/SUMMARY.md +++ b/doc/src/SUMMARY.md @@ -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]() diff --git a/doc/src/adr/0009-auth-token-revoke.md b/doc/src/adr/0009-auth-token-revoke.md new file mode 100644 index 00000000..dbb89d81 --- /dev/null +++ b/doc/src/adr/0009-auth-token-revoke.md @@ -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, + pub project_id: Option, + pub user_id: Option, + pub role_id: Option, + pub trust_id: Option, + pub consumer_id: Option, + pub access_token_id: Option, + pub issued_before: DateTime, + pub expires_at: Option, + pub revoked_at: DateTime, + pub audit_id: Option, + pub audit_chain_id: Option, +``` + +### 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. diff --git a/policy/auth/token/revoke.rego b/policy/auth/token/revoke.rego new file mode 100644 index 00000000..d66eceb6 --- /dev/null +++ b/policy/auth/token/revoke.rego @@ -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 +} + diff --git a/policy/auth/token/revoke_test.rego b/policy/auth/token/revoke_test.rego new file mode 100644 index 00000000..04aca956 --- /dev/null +++ b/policy/auth/token/revoke_test.rego @@ -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"}}} +} diff --git a/src/api/auth.rs b/src/api/auth.rs index 03d9e399..db9591bc 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -43,7 +43,7 @@ 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); @@ -51,17 +51,10 @@ where 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)) } diff --git a/src/api/error.rs b/src/api/error.rs index f86781f9..ea134c4c 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -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), #[error("You are not authorized to perform the requested action.")] Forbidden, @@ -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, @@ -356,7 +356,7 @@ impl From for KeystoneApiError { AuthenticationError::TokenRenewalForbidden => { KeystoneApiError::SelectedAuthenticationForbidden } - AuthenticationError::Unauthorized => KeystoneApiError::Unauthorized, + AuthenticationError::Unauthorized => KeystoneApiError::Unauthorized(None), } } } @@ -365,6 +365,9 @@ impl From 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 }, } } diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index 01bc7f19..013d822e 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -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) @@ -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)) => { @@ -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"); } diff --git a/src/api/v3/auth/token/delete.rs b/src/api/v3/auth/token/delete.rs new file mode 100644 index 00000000..b0351251 --- /dev/null +++ b/src/api/v3/auth/token/delete.rs @@ -0,0 +1,302 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 +//! Revoke the authentication token. +//! + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use mockall_double::double; +use serde_json::{json, to_value}; +use tracing::error; + +use crate::api::{auth::Auth, error::KeystoneApiError}; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::revoke::RevokeApi; +use crate::token::TokenApi; + +/// Revoke token. +/// +/// Revokes a token. +/// +/// This call is similar to the HEAD /auth/tokens call except that the `X-Subject-Token` token is +/// immediately not valid, regardless of the expires_at attribute value. An additional +/// `X-Auth-Token` is not required. +#[utoipa::path( + delete, + path = "/", + responses( + (status = 204, description = "Token has been revoked."), + ), + tag="auth" +)] +#[tracing::instrument( + name = "api::v3::token::delete", + level = "debug", + skip(state, headers, user_auth, policy) +)] +pub(super) async fn delete( + Auth(user_auth): Auth, + mut policy: Policy, + headers: HeaderMap, + State(state): State, +) -> Result { + let subject_token: String = headers + .get("X-Subject-Token") + .ok_or(KeystoneApiError::SubjectTokenMissing)? + .to_str() + .map_err(|_| KeystoneApiError::InvalidHeader)? + .to_string(); + + // Default behavior is to return 404 for expired tokens. It makes sense to log internally the + // error before mapping it. + let token = state + .provider + .get_token_provider() + .validate_token(&state, &subject_token, None, None, None) + .await + .inspect_err(|e| error!("{:?}", e.to_string())) + .map_err(|_| KeystoneApiError::NotFound { + resource: "token".into(), + identifier: String::new(), + })?; + + policy + .enforce( + "identity/auth/token/revoke", + &user_auth, + to_value(json!({"token": &token}))?, + None, + ) + .await?; + + state + .provider + .get_revoke_provider() + .revoke_token(&state, &token) + .await + .map_err(|_| KeystoneApiError::Forbidden)?; + + Ok((StatusCode::NO_CONTENT).into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use sea_orm::DatabaseConnection; + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use crate::config::Config; + use crate::keystone::Service; + use crate::provider::Provider; + use crate::revoke::MockRevokeProvider; + use crate::token::{ + MockTokenProvider, Token as ProviderToken, TokenProviderError, UnscopedPayload, + }; + + use super::super::{openapi_router, tests::get_policy_factory_mock}; + + fn get_prepopulated_token_mock() -> MockTokenProvider { + let decoded_auth_token = ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + }); + + let mut token_mock = MockTokenProvider::default(); + // x-auth-token validated + let decoded_auth_token_clone1 = decoded_auth_token.clone(); + token_mock + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "foo") + .returning(move |_, _, _, _, _| Ok(decoded_auth_token_clone1.clone())); + // auth-token expanded + let decoded_auth_token_clone2 = decoded_auth_token.clone(); + token_mock + .expect_expand_token_information() + .withf(move |_, token: &ProviderToken| *token == decoded_auth_token_clone2.clone()) + .returning(|_, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + // auth-token roles populated + let decoded_auth_token_clone3 = decoded_auth_token.clone(); + token_mock + .expect_populate_role_assignments() + .withf(move |_, token: &ProviderToken| *token == decoded_auth_token_clone3.clone()) + .returning(|_, _| Ok(())); + + token_mock + } + + #[tokio::test] + async fn test_delete() { + let decoded_subject_token = ProviderToken::Unscoped(UnscopedPayload { + user_id: "foobar".into(), + ..Default::default() + }); + let mut token_mock = get_prepopulated_token_mock(); + + // subject token validated + let decoded_subject_token_clone = decoded_subject_token.clone(); + token_mock + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "baz") + .returning(move |_, _, _, _, _| Ok(decoded_subject_token_clone.clone())); + + let mut revoke_mock = MockRevokeProvider::default(); + // subject token revoked + let decoded_subject_token_clone2 = decoded_subject_token.clone(); + revoke_mock + .expect_revoke_token() + .withf(move |_, token: &ProviderToken| *token == decoded_subject_token_clone2.clone()) + .returning(|_, _| Ok(())); + + let provider = Provider::mocked_builder() + .token(token_mock) + .revoke(revoke_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + get_policy_factory_mock(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("DELETE") + .header("x-auth-token", "foo") + .header("x-subject-token", "baz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn test_delete_expired() { + let mut token_mock = get_prepopulated_token_mock(); + // subject token validated + token_mock + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "baz") + .returning(move |_, _, _, _, _| Err(TokenProviderError::Expired)); + + let provider = Provider::mocked_builder() + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + get_policy_factory_mock(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("DELETE") + .header("x-auth-token", "foo") + .header("x-subject-token", "baz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_delete_revoked() { + let mut token_mock = get_prepopulated_token_mock(); + // subject token validated + token_mock + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "baz") + .returning(move |_, _, _, _, _| Err(TokenProviderError::TokenRevoked)); + + let provider = Provider::mocked_builder() + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + get_policy_factory_mock(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .method("DELETE") + .header("x-auth-token", "foo") + .header("x-subject-token", "baz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index 369e95b6..0d876054 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -20,12 +20,13 @@ use crate::keystone::ServiceState; mod common; mod create; +mod delete; mod show; mod token_impl; pub mod types; pub(crate) fn openapi_router() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(show::show, create::create)) + OpenApiRouter::new().routes(routes!(show::show, create::create, delete::delete)) } #[cfg(test)] diff --git a/src/api/v3/auth/token/show.rs b/src/api/v3/auth/token/show.rs index 55168520..25641db2 100644 --- a/src/api/v3/auth/token/show.rs +++ b/src/api/v3/auth/token/show.rs @@ -39,9 +39,14 @@ use crate::keystone::ServiceState; use crate::policy::Policy; use crate::token::TokenApi; -/// Validate the token. +/// Validate and show information for token. /// -/// Parse the token checking whether the content is valid, whether it has been expired or revoked. +/// Validates and shows information for a token, including its expiration date and authorization +/// scope. +/// +/// Pass your own token in the X-Auth-Token request header. +/// +/// Pass the token that you want to validate in the X-Subject-Token request header. #[utoipa::path( get, path = "/", @@ -72,10 +77,17 @@ pub(super) async fn show( // Default behavior is to return 404 for expired tokens. It makes sense to log internally the // error before mapping it. - let mut token = state + let token = state .provider .get_token_provider() - .validate_token(&state, &subject_token, query.allow_expired, None) + .validate_token( + &state, + &subject_token, + query.allow_expired, + None, + // Do not expand the token for the policy evaluation + Some(true), + ) .await .inspect_err(|e| error!("{:?}", e.to_string())) .map_err(|_| KeystoneApiError::NotFound { @@ -92,12 +104,13 @@ pub(super) async fn show( ) .await?; - token = state - .provider - .get_token_provider() - .expand_token_information(&state, &token) - .await - .map_err(|_| KeystoneApiError::Forbidden)?; + //// Expand the token since we didn't expand it before. + //token = state + // .provider + // .get_token_provider() + // .expand_token_information(&state, &token) + // .await + // .map_err(|_| KeystoneApiError::Forbidden)?; let mut response_token = ApiResponseToken::from_provider_token(&state, &token).await?; @@ -164,15 +177,14 @@ mod tests { })) }); let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(ProviderToken::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); token_mock - .expect_populate_role_assignments() - .returning(|_, _| Ok(())); + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { @@ -265,8 +277,8 @@ mod tests { let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() - .withf(|_, token: &'_ str, _, _| token == "foo") - .returning(|_, _, _, _| { + .withf(|_, token: &'_ str, _, _, _| token == "foo") + .returning(|_, _, _, _, _| { Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() @@ -274,18 +286,15 @@ mod tests { }); token_mock .expect_validate_token() - .withf(|_, token: &'_ str, allow_expired: &Option, _| { + .withf(|_, token: &'_ str, allow_expired: &Option, _, _| { token == "bar" && *allow_expired == Some(true) }) - .returning(|_, _, _, _| { + .returning(|_, _, _, _, _| { Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) }); - token_mock - .expect_populate_role_assignments() - .returning(|_, _| Ok(())); token_mock .expect_expand_token_information() .returning(|_, _| { @@ -342,26 +351,69 @@ mod tests { let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() - .withf(|_, token: &'_ str, _, _| token == "foo") - .returning(|_, _, _, _| { + .withf(|_, token: &'_ str, _, _, _| token == "foo") + .returning(|_, _, _, _, _| { Ok(ProviderToken::Unscoped(UnscopedPayload { user_id: "bar".into(), ..Default::default() })) }); token_mock - .expect_expand_token_information() - .withf(|_, token: &ProviderToken| token.user_id() == "bar") - .returning(|_, _| { + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "baz") + .returning(|_, _, _, _, _| Err(TokenProviderError::Expired)); + + let provider = Provider::mocked_builder() + .token(token_mock) + .build() + .unwrap(); + + let state = Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + get_policy_factory_mock(), + ) + .unwrap(), + ); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .header("x-subject-token", "baz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_get_revoked() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_validate_token() + .withf(|_, token: &'_ str, _, _, _| token == "foo") + .returning(|_, _, _, _, _| { Ok(ProviderToken::Unscoped(UnscopedPayload { - user_id: "foo".into(), + user_id: "bar".into(), ..Default::default() })) }); token_mock .expect_validate_token() - .withf(|_, token: &'_ str, _, _| token == "baz") - .returning(|_, _, _, _| Err(TokenProviderError::Expired)); + .withf(|_, token: &'_ str, _, _, _| token == "baz") + .returning(|_, _, _, _, _| Err(TokenProviderError::TokenRevoked)); let provider = Provider::mocked_builder() .token(token_mock) diff --git a/src/api/v3/role/mod.rs b/src/api/v3/role/mod.rs index 35f43572..4469c7b5 100644 --- a/src/api/v3/role/mod.rs +++ b/src/api/v3/role/mod.rs @@ -129,12 +129,14 @@ mod tests { fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/api/v3/role_assignment/mod.rs b/src/api/v3/role_assignment/mod.rs index c7f76110..54ef6141 100644 --- a/src/api/v3/role_assignment/mod.rs +++ b/src/api/v3/role_assignment/mod.rs @@ -99,12 +99,14 @@ mod tests { fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/api/v4/federation/common.rs b/src/api/v4/federation/common.rs index 2376a61f..1b09467e 100644 --- a/src/api/v4/federation/common.rs +++ b/src/api/v4/federation/common.rs @@ -46,14 +46,14 @@ pub(super) async fn get_authz_info( if let Some(project) = find_project_from_scope(state, &scope.into()).await? { AuthzInfo::Project(project) } else { - return Err(KeystoneApiError::Unauthorized); + return Err(KeystoneApiError::Unauthorized(None)); } } Some(ProviderScope::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(ProviderScope::System(_scope)) => todo!(), diff --git a/src/api/v4/federation/error.rs b/src/api/v4/federation/error.rs index 0bf35f23..a873f2d6 100644 --- a/src/api/v4/federation/error.rs +++ b/src/api/v4/federation/error.rs @@ -214,11 +214,11 @@ impl From for KeystoneApiError { } OidcError::NonJwtMapping | OidcError::NoJwtIssuer => { // Not exposing info about mapping and idp existence. - KeystoneApiError::Unauthorized + KeystoneApiError::Unauthorized(Some("mapping error".to_string())) } OidcError::UserNotFound(_) => { // Not exposing info about mapping and idp existence. - KeystoneApiError::Unauthorized + KeystoneApiError::Unauthorized(Some("User not found".to_string())) } } } diff --git a/src/api/v4/federation/identity_provider.rs b/src/api/v4/federation/identity_provider.rs index fa962fce..b6b737fc 100644 --- a/src/api/v4/federation/identity_provider.rs +++ b/src/api/v4/federation/identity_provider.rs @@ -43,11 +43,8 @@ pub(super) fn openapi_router() -> OpenApiRouter { #[cfg(test)] mod tests { - - // for `collect` use sea_orm::DatabaseConnection; use std::sync::Arc; - // for `call`, `oneshot`, and `ready` use crate::config::Config; use crate::federation::MockFederationProvider; @@ -63,15 +60,9 @@ mod tests { policy_allowed_see_other_domains: Option, ) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); token_mock - .expect_expand_token_information() - .returning(|_, _| { + .expect_validate_token() + .returning(|_, _, _, _, _| { Ok(Token::Unscoped(UnscopedPayload { user_id: "bar".into(), user: Some(UserResponse { diff --git a/src/api/v4/federation/mapping.rs b/src/api/v4/federation/mapping.rs index 991a2b12..0c47b8df 100644 --- a/src/api/v4/federation/mapping.rs +++ b/src/api/v4/federation/mapping.rs @@ -56,12 +56,14 @@ mod tests { policy_allowed: bool, ) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/api/v4/role/mod.rs b/src/api/v4/role/mod.rs index 192d9343..ee2499d8 100644 --- a/src/api/v4/role/mod.rs +++ b/src/api/v4/role/mod.rs @@ -58,12 +58,14 @@ mod tests { fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/api/v4/role_assignment/mod.rs b/src/api/v4/role_assignment/mod.rs index f2607bb7..d55e9b19 100644 --- a/src/api/v4/role_assignment/mod.rs +++ b/src/api/v4/role_assignment/mod.rs @@ -54,12 +54,14 @@ mod tests { fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/api/v4/token/restriction.rs b/src/api/v4/token/restriction.rs index 36f23c51..39b671ac 100644 --- a/src/api/v4/token/restriction.rs +++ b/src/api/v4/token/restriction.rs @@ -63,12 +63,14 @@ mod tests { policy_allowed: bool, policy_allowed_see_other_domains: Option, ) -> ServiceState { - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/revoke/backend/sql/list.rs b/src/revoke/backend/sql/list.rs index 5296cea4..4fdc4edb 100644 --- a/src/revoke/backend/sql/list.rs +++ b/src/revoke/backend/sql/list.rs @@ -26,6 +26,7 @@ use crate::revoke::types::{RevocationEvent, RevocationEventListParameters}; fn build_query_filters( params: &RevocationEventListParameters, ) -> Result, RevokeDatabaseError> { + tracing::info!("Query parameters: {:?}", params); let mut select = DbRevocationEvent::find(); //if let Some(val) = ¶ms.access_token_id { @@ -36,29 +37,70 @@ fn build_query_filters( // select = select.filter(db_revocation_event::Column::AuditChainId.eq(val)); //} - if let Some(val) = ¶ms.audit_id { - select = select.filter(db_revocation_event::Column::AuditId.eq(val)); - } + select = select.filter( + Condition::any() + .add(db_revocation_event::Column::AuditId.is_null()) + .add_option( + params + .audit_id + .as_ref() + .map(|val| db_revocation_event::Column::AuditId.eq(val)), + ), + ); - if let Some(val) = ¶ms.domain_id { - select = select.filter(db_revocation_event::Column::DomainId.eq(val)); - } + select = select.filter( + Condition::any() + .add(db_revocation_event::Column::DomainId.is_null()) + .add_option( + params + .domain_ids + .as_ref() + .and_then(|val| if val.is_empty() { None } else { Some(val) }) + .map(|val| db_revocation_event::Column::DomainId.is_in(val)), + ), + ); if let Some(val) = params.expires_at { select = select.filter(db_revocation_event::Column::ExpiresAt.eq(val)); } if let Some(val) = params.issued_before { - select = select.filter(db_revocation_event::Column::IssuedBefore.lt(val)); + select = select.filter(db_revocation_event::Column::IssuedBefore.gte(val)); } - if let Some(val) = ¶ms.project_id { - select = select.filter(db_revocation_event::Column::ProjectId.eq(val)); - } + select = select.filter( + Condition::any() + .add(db_revocation_event::Column::ProjectId.is_null()) + .add_option( + params + .project_id + .as_ref() + .map(|val| db_revocation_event::Column::ProjectId.eq(val)), + ), + ); - if let Some(val) = ¶ms.user_id { - select = select.filter(db_revocation_event::Column::UserId.is_in(val)); - } + select = select.filter( + Condition::any() + .add(db_revocation_event::Column::RoleId.is_null()) + .add_option( + params + .role_ids + .as_ref() + .and_then(|val| if val.is_empty() { None } else { Some(val) }) + .map(|val| db_revocation_event::Column::RoleId.is_in(val)), + ), + ); + select = select.filter( + Condition::any() + .add(db_revocation_event::Column::UserId.is_null()) + .add_option( + params + .user_ids + .as_ref() + .and_then(|val| if val.is_empty() { None } else { Some(val) }) + .map(|val| db_revocation_event::Column::UserId.is_in(val)), + ), + ); Ok(select) } @@ -123,10 +165,11 @@ mod tests { &db, &RevocationEventListParametersBuilder::default() .audit_id("audit_id") - .domain_id("domain_id") + .domain_ids(vec!["domain_id1".into(), "domain_id2".into()]) .expires_at(time2) .issued_before(time1) .project_id("project_id") + .role_ids(vec!["rid1".into(), "rid2".into()]) .build() .unwrap() ) @@ -139,17 +182,21 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT COUNT(*) AS num_items FROM (SELECT "revocation_event"."id", "revocation_event"."domain_id", "revocation_event"."project_id", "revocation_event"."user_id", "revocation_event"."role_id", "revocation_event"."trust_id", "revocation_event"."consumer_id", "revocation_event"."access_token_id", "revocation_event"."issued_before", "revocation_event"."expires_at", "revocation_event"."revoked_at", "revocation_event"."audit_id", "revocation_event"."audit_chain_id" FROM "revocation_event" WHERE "revocation_event"."audit_id" = $1 AND "revocation_event"."domain_id" = $2 AND "revocation_event"."expires_at" = $3 AND "revocation_event"."issued_before" < $4 AND "revocation_event"."project_id" = $5) AS "sub_query""#, + r#"SELECT COUNT(*) AS num_items FROM (SELECT "revocation_event"."id", "revocation_event"."domain_id", "revocation_event"."project_id", "revocation_event"."user_id", "revocation_event"."role_id", "revocation_event"."trust_id", "revocation_event"."consumer_id", "revocation_event"."access_token_id", "revocation_event"."issued_before", "revocation_event"."expires_at", "revocation_event"."revoked_at", "revocation_event"."audit_id", "revocation_event"."audit_chain_id" FROM "revocation_event" WHERE ("revocation_event"."audit_id" IS NULL OR "revocation_event"."audit_id" = $1) AND ("revocation_event"."domain_id" IS NULL OR "revocation_event"."domain_id" IN ($2, $3)) AND "revocation_event"."expires_at" = $4 AND "revocation_event"."issued_before" >= $5 AND ("revocation_event"."project_id" IS NULL OR "revocation_event"."project_id" = $6) AND ("revocation_event"."role_id" IS NULL OR "revocation_event"."role_id" IN ($7, $8)) AND "revocation_event"."user_id" IS NULL) AS "sub_query""#, [ "audit_id".into(), - "domain_id".into(), + "domain_id1".into(), + "domain_id2".into(), time2.into(), time1.into(), "project_id".into(), + "rid1".into(), + "rid2".into(), ] ),] ); } + #[tokio::test] async fn test_list() { let db = MockDatabase::new(DatabaseBackend::Postgres) @@ -162,10 +209,11 @@ mod tests { &db, &RevocationEventListParametersBuilder::default() .audit_id("audit_id") - .domain_id("domain_id") + .domain_ids(vec!["domain_id1".into()]) .expires_at(time2) .issued_before(time1) .project_id("project_id") + .role_ids(vec!["rid1".into()]) .build() .unwrap() ) @@ -191,15 +239,308 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "revocation_event"."id", "revocation_event"."domain_id", "revocation_event"."project_id", "revocation_event"."user_id", "revocation_event"."role_id", "revocation_event"."trust_id", "revocation_event"."consumer_id", "revocation_event"."access_token_id", "revocation_event"."issued_before", "revocation_event"."expires_at", "revocation_event"."revoked_at", "revocation_event"."audit_id", "revocation_event"."audit_chain_id" FROM "revocation_event" WHERE "revocation_event"."audit_id" = $1 AND "revocation_event"."domain_id" = $2 AND "revocation_event"."expires_at" = $3 AND "revocation_event"."issued_before" < $4 AND "revocation_event"."project_id" = $5"#, + r#"SELECT "revocation_event"."id", "revocation_event"."domain_id", "revocation_event"."project_id", "revocation_event"."user_id", "revocation_event"."role_id", "revocation_event"."trust_id", "revocation_event"."consumer_id", "revocation_event"."access_token_id", "revocation_event"."issued_before", "revocation_event"."expires_at", "revocation_event"."revoked_at", "revocation_event"."audit_id", "revocation_event"."audit_chain_id" FROM "revocation_event" WHERE ("revocation_event"."audit_id" IS NULL OR "revocation_event"."audit_id" = $1) AND ("revocation_event"."domain_id" IS NULL OR "revocation_event"."domain_id" IN ($2)) AND "revocation_event"."expires_at" = $3 AND "revocation_event"."issued_before" >= $4 AND ("revocation_event"."project_id" IS NULL OR "revocation_event"."project_id" = $5) AND ("revocation_event"."role_id" IS NULL OR "revocation_event"."role_id" IN ($6)) AND "revocation_event"."user_id" IS NULL"#, [ "audit_id".into(), - "domain_id".into(), + "domain_id1".into(), time2.into(), time1.into(), "project_id".into(), + "rid1".into(), ] ),] ); } + + #[tokio::test] + async fn test_query_mysql() { + let time1 = Utc::now(); + let time2 = time1.checked_add_days(Days::new(1)).unwrap(); + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .domain_ids(vec!["domain_id1".into()]) + .expires_at(time2) + .issued_before(time1) + .project_id("project_id") + .role_ids(vec!["rid1".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::MySql) + .to_string(); + assert_eq!( + format!( + "SELECT `revocation_event`.`id`, `revocation_event`.`domain_id`, `revocation_event`.`project_id`, `revocation_event`.`user_id`, `revocation_event`.`role_id`, `revocation_event`.`trust_id`, `revocation_event`.`consumer_id`, `revocation_event`.`access_token_id`, `revocation_event`.`issued_before`, `revocation_event`.`expires_at`, `revocation_event`.`revoked_at`, `revocation_event`.`audit_id`, `revocation_event`.`audit_chain_id` FROM `revocation_event` WHERE (`revocation_event`.`audit_id` IS NULL OR `revocation_event`.`audit_id` = 'audit_id') AND (`revocation_event`.`domain_id` IS NULL OR `revocation_event`.`domain_id` IN ('domain_id1')) AND `revocation_event`.`expires_at` = '{}' AND `revocation_event`.`issued_before` >= '{}' AND (`revocation_event`.`project_id` IS NULL OR `revocation_event`.`project_id` = 'project_id') AND (`revocation_event`.`role_id` IS NULL OR `revocation_event`.`role_id` IN ('rid1')) AND `revocation_event`.`user_id` IS NULL", + time2.format("%F %X%.6f %:z"), + time1.format("%F %X%.6f %:z"), + ), + query + ); + } + + #[tokio::test] + async fn test_query_postgres() { + let time1 = Utc::now(); + let time2 = time1.checked_add_days(Days::new(1)).unwrap(); + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .domain_ids(vec!["domain_id1".into()]) + .expires_at(time2) + .issued_before(time1) + .project_id("project_id") + .role_ids(vec!["rid1".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Postgres) + .to_string(); + assert_eq!( + format!( + "SELECT \"revocation_event\".\"id\", \"revocation_event\".\"domain_id\", \"revocation_event\".\"project_id\", \"revocation_event\".\"user_id\", \"revocation_event\".\"role_id\", \"revocation_event\".\"trust_id\", \"revocation_event\".\"consumer_id\", \"revocation_event\".\"access_token_id\", \"revocation_event\".\"issued_before\", \"revocation_event\".\"expires_at\", \"revocation_event\".\"revoked_at\", \"revocation_event\".\"audit_id\", \"revocation_event\".\"audit_chain_id\" FROM \"revocation_event\" WHERE (\"revocation_event\".\"audit_id\" IS NULL OR \"revocation_event\".\"audit_id\" = 'audit_id') AND (\"revocation_event\".\"domain_id\" IS NULL OR \"revocation_event\".\"domain_id\" IN ('domain_id1')) AND \"revocation_event\".\"expires_at\" = '{}' AND \"revocation_event\".\"issued_before\" >= '{}' AND (\"revocation_event\".\"project_id\" IS NULL OR \"revocation_event\".\"project_id\" = 'project_id') AND (\"revocation_event\".\"role_id\" IS NULL OR \"revocation_event\".\"role_id\" IN ('rid1')) AND \"revocation_event\".\"user_id\" IS NULL", + time2.format("%F %X%.6f %:z"), + time1.format("%F %X%.6f %:z"), + ), + query + ); + } + + #[tokio::test] + async fn test_query_mysql_multiple_domains() { + let time1 = Utc::now(); + let time2 = time1.checked_add_days(Days::new(1)).unwrap(); + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .domain_ids(vec!["domain_id1".into(), "domain_id2".into()]) + .expires_at(time2) + .issued_before(time1) + .project_id("project_id") + .role_ids(vec!["rid1".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::MySql) + .to_string(); + assert_eq!( + format!( + "SELECT `revocation_event`.`id`, `revocation_event`.`domain_id`, `revocation_event`.`project_id`, `revocation_event`.`user_id`, `revocation_event`.`role_id`, `revocation_event`.`trust_id`, `revocation_event`.`consumer_id`, `revocation_event`.`access_token_id`, `revocation_event`.`issued_before`, `revocation_event`.`expires_at`, `revocation_event`.`revoked_at`, `revocation_event`.`audit_id`, `revocation_event`.`audit_chain_id` FROM `revocation_event` WHERE (`revocation_event`.`audit_id` IS NULL OR `revocation_event`.`audit_id` = 'audit_id') AND (`revocation_event`.`domain_id` IS NULL OR `revocation_event`.`domain_id` IN ('domain_id1', 'domain_id2')) AND `revocation_event`.`expires_at` = '{}' AND `revocation_event`.`issued_before` >= '{}' AND (`revocation_event`.`project_id` IS NULL OR `revocation_event`.`project_id` = 'project_id') AND (`revocation_event`.`role_id` IS NULL OR `revocation_event`.`role_id` IN ('rid1')) AND `revocation_event`.`user_id` IS NULL", + time2.format("%F %X%.6f %:z"), + time1.format("%F %X%.6f %:z"), + ), + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_empty() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert_eq!( + "SELECT \"revocation_event\".\"id\", \"revocation_event\".\"domain_id\", \"revocation_event\".\"project_id\", \"revocation_event\".\"user_id\", \"revocation_event\".\"role_id\", \"revocation_event\".\"trust_id\", \"revocation_event\".\"consumer_id\", \"revocation_event\".\"access_token_id\", \"revocation_event\".\"issued_before\", \"revocation_event\".\"expires_at\", \"revocation_event\".\"revoked_at\", \"revocation_event\".\"audit_id\", \"revocation_event\".\"audit_chain_id\" FROM \"revocation_event\" WHERE \"revocation_event\".\"audit_id\" IS NULL AND \"revocation_event\".\"domain_id\" IS NULL AND \"revocation_event\".\"project_id\" IS NULL AND \"revocation_event\".\"role_id\" IS NULL AND \"revocation_event\".\"user_id\" IS NULL", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_audit_id() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("(\"revocation_event\".\"audit_id\" IS NULL OR \"revocation_event\".\"audit_id\" = 'audit_id')"), + "{}", query + ); + } + + #[tokio::test] + async fn test_query_sqlite_domain_ids_empty() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .domain_ids(Vec::new()) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND \"revocation_event\".\"domain_id\" IS NULL"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_domain_ids_list() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .domain_ids(vec!["d1".into(), "d2".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND (\"revocation_event\".\"domain_id\" IS NULL OR \"revocation_event\".\"domain_id\" IN ('d1', 'd2'))"), + "{}", query + ); + } + + #[tokio::test] + async fn test_query_sqlite_project_id() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .project_id("pid") + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND (\"revocation_event\".\"project_id\" IS NULL OR \"revocation_event\".\"project_id\" = 'pid')"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_role_ids_empty() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .role_ids(Vec::new()) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND \"revocation_event\".\"role_id\" IS NULL"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_role_ids_list() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .role_ids(vec!["d1".into(), "d2".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND (\"revocation_event\".\"role_id\" IS NULL OR \"revocation_event\".\"role_id\" IN ('d1', 'd2'))"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_user_ids_empty() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .user_ids(Vec::new()) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND \"revocation_event\".\"user_id\" IS NULL"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_user_ids_list() { + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .user_ids(vec!["d1".into(), "d2".into()]) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains("AND (\"revocation_event\".\"user_id\" IS NULL OR \"revocation_event\".\"user_id\" IN ('d1', 'd2'))"), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_expires_at() { + let time1 = Utc::now(); + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .expires_at(time1) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains( + format!( + "AND \"revocation_event\".\"expires_at\" = '{}'", + time1.format("%F %X%.6f %:z") + ) + .as_str() + ), + "{}", + query + ); + } + + #[tokio::test] + async fn test_query_sqlite_issued_before() { + let time1 = Utc::now(); + let query = build_query_filters( + &RevocationEventListParametersBuilder::default() + .issued_before(time1) + .build() + .unwrap(), + ) + .unwrap() + .build(DatabaseBackend::Sqlite) + .to_string(); + assert!( + query.contains( + format!( + "AND \"revocation_event\".\"issued_before\" >= '{}'", + time1.format("%F %X%.6f %:z") + ) + .as_str() + ), + "{}", + query + ); + } } diff --git a/src/revoke/mod.rs b/src/revoke/mod.rs index 551d74ad..9d4fbf36 100644 --- a/src/revoke/mod.rs +++ b/src/revoke/mod.rs @@ -97,6 +97,7 @@ impl RevokeApi for RevokeProvider { state: &ServiceState, token: &Token, ) -> Result { + tracing::info!("Checking for the revocation events"); self.backend_driver.is_token_revoked(state, token).await } diff --git a/src/revoke/types.rs b/src/revoke/types.rs index da9c08da..a9f1b9fd 100644 --- a/src/revoke/types.rs +++ b/src/revoke/types.rs @@ -90,25 +90,40 @@ pub struct RevocationEventCreate { pub struct RevocationEventListParameters { //pub access_token_id: Option, //pub audit_chain_id: Option, + /// Audit_id to match against. #[builder(default)] pub audit_id: Option, //pub consumer_id: Option, + /// List revocation events with an empty `domain_id` or matching any of the given values. #[builder(default)] - pub domain_id: Option, + pub domain_ids: Option>, + /// Expires_at parameter to match against. #[builder(default)] pub expires_at: Option>, + /// List revocation events with the `issued_before` value greater or equal the value + /// (revocating tokens issued before the certain time). #[builder(default)] pub issued_before: Option>, + /// Project_id to match against. #[builder(default)] pub project_id: Option, #[builder(default)] + /// Revocation timestamp to match against. Currently not respected. pub revoked_at: Option>, - //pub role_id: Option, + /// List revocation events with an empty `role_id` or matching any of the given values. + #[builder(default)] + pub role_ids: Option>, //pub trust_id: Option, + /// User_id to match against. #[builder(default)] - pub user_id: Option>, + pub user_ids: Option>, } +/// Convert Token into the revocation events listing parameters following the +/// +// TODO: It is necessary to also consider list of the token roles against the role_id of the entry +// TODO: domain_id of the database entry should be compared against the user_domain_id and the +// scope_domain_id. That means, however, that we must resolve the user first. impl TryFrom<&Token> for RevocationEventListParameters { type Error = RevokeProviderError; fn try_from(value: &Token) -> Result { @@ -124,18 +139,29 @@ impl TryFrom<&Token> for RevocationEventListParameters { ) .cloned(), //consumer_id: None, - domain_id: value.domain().map(|domain| domain.id.clone()), + domain_ids: Some( + value + .user() + .iter() + .map(|user| user.domain_id.clone()) + .chain(value.domain().map(|domain| domain.id.clone())) + .collect::>(), + ), expires_at: None, issued_before: Some(*value.issued_at()), project_id: value.project_id().cloned(), revoked_at: None, - //role_id: None, + role_ids: value + .roles() + .map(|roles| roles.iter().map(|role| role.id.clone()).collect()), //trust_id: None, - user_id: Some(vec![value.user_id().clone()]), + user_ids: Some(vec![value.user_id().clone()]), }) } } +/// Convert the Token into the new revocation events revord following the +/// impl TryFrom<&Token> for RevocationEventCreate { type Error = RevokeProviderError; fn try_from(value: &Token) -> Result { @@ -166,7 +192,60 @@ impl TryFrom<&Token> for RevocationEventCreate { #[cfg(test)] mod tests { use super::*; + use crate::identity::types::UserResponse; use crate::token::ProjectScopePayload; + //use crate::resource::types::Domain; + use crate::assignment::types::Role; + + #[test] + fn test_list_for_project_scope_token() { + let token = Token::ProjectScope(ProjectScopePayload { + user_id: "user_id".into(), + user: Some(UserResponse { + id: "user_id".to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + }), + methods: Vec::from(["password".to_string()]), + project_id: "project_id".into(), + audit_ids: vec!["Zm9vCg".into()], + expires_at: DateTime::parse_from_rfc3339("2025-11-17T19:55:06.123456Z") + .unwrap() + .with_timezone(&Utc), + roles: Some(vec![ + Role { + id: "role_id1".to_string(), + ..Default::default() + }, + Role { + id: "role_id2".to_string(), + ..Default::default() + }, + ]), + ..Default::default() + }); + let revocation: RevocationEventListParameters = + RevocationEventListParameters::try_from(&token).unwrap(); + + //assert!(revocation.audit_chain_id.is_none()); + assert_eq!( + *token.audit_ids().first().unwrap(), + revocation.audit_id.unwrap() + ); + //assert!(revocation.consumer_id.is_none()); + assert_eq!( + revocation.domain_ids.unwrap(), + vec!["user_domain_id".to_string()] + ); + assert!(revocation.expires_at.is_none()); + assert_eq!(revocation.project_id.unwrap(), "project_id".to_string()); + assert_eq!( + revocation.role_ids.unwrap(), + vec!["role_id1".to_string(), "role_id2".to_string()] + ); + //assert!(revocation.trust_id.is_none()); + assert_eq!(revocation.user_ids.unwrap(), vec!["user_id".to_string()]); + } #[test] fn test_create_from_token() { @@ -178,6 +257,16 @@ mod tests { expires_at: DateTime::parse_from_rfc3339("2025-11-17T19:55:06.123456Z") .unwrap() .with_timezone(&Utc), + roles: Some(vec![ + Role { + id: "role_id1".to_string(), + ..Default::default() + }, + Role { + id: "role_id2".to_string(), + ..Default::default() + }, + ]), ..Default::default() }); let revocation: RevocationEventCreate = RevocationEventCreate::try_from(&token).unwrap(); diff --git a/src/tests/api.rs b/src/tests/api.rs index c196ffa3..22bf4301 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -26,7 +26,7 @@ pub(crate) fn get_mocked_state_unauthed() -> ServiceState { let mut token_mock = MockTokenProvider::default(); token_mock .expect_validate_token() - .returning(|_, _, _, _| Err(TokenProviderError::InvalidToken)); + .returning(|_, _, _, _, _| Err(TokenProviderError::InvalidToken)); let provider = Provider::mocked_builder() .token(token_mock) @@ -46,12 +46,14 @@ pub(crate) fn get_mocked_state_unauthed() -> ServiceState { pub(crate) fn get_mocked_state(identity_mock: MockIdentityProvider) -> ServiceState { let mut token_mock = MockTokenProvider::default(); - token_mock.expect_validate_token().returning(|_, _, _, _| { - Ok(Token::Unscoped(UnscopedPayload { - user_id: "bar".into(), - ..Default::default() - })) - }); + token_mock + .expect_validate_token() + .returning(|_, _, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_expand_token_information() .returning(|_, _| { diff --git a/src/token/mock.rs b/src/token/mock.rs index 2a4185d3..61f87443 100644 --- a/src/token/mock.rs +++ b/src/token/mock.rs @@ -47,6 +47,7 @@ mock! { credential: &'a str, allow_expired: Option, window_seconds: Option, + expand: Option ) -> Result; #[mockall::concretize] diff --git a/src/token/mod.rs b/src/token/mod.rs index 92ff27ff..5ea0afe0 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -341,10 +341,16 @@ impl TokenApi for TokenProvider { allow_expired: Option, window_seconds: Option, ) -> Result { + // TODO: is the expand really false? let token = self - .validate_token(state, credential, allow_expired, window_seconds) + .validate_token( + state, + credential, + allow_expired, + window_seconds, + Some(false), + ) .await?; - tracing::debug!("The token is {:?}", token); if let Token::Restricted(restriction) = &token && !restriction.allow_renew { @@ -370,8 +376,9 @@ impl TokenApi for TokenProvider { credential: &'a str, allow_expired: Option, window_seconds: Option, + expand: Option, ) -> Result { - let token = self.backend_driver.decode(credential)?; + let mut token = self.backend_driver.decode(credential)?; if Local::now().to_utc() > token .expires_at() @@ -382,6 +389,11 @@ impl TokenApi for TokenProvider { return Err(TokenProviderError::Expired); } + // Expand the token unless `expand = Some(false)` + if expand.is_none_or(|v| v) { + token = self.expand_token_information(state, &token).await?; + } + if state .provider .get_revoke_provider() @@ -749,6 +761,7 @@ mod tests { use std::io::Write; use std::sync::Arc; use tempfile::tempdir; + use tracing_test::traced_test; use uuid::Uuid; use super::*; @@ -757,8 +770,10 @@ mod tests { types::{Assignment, AssignmentType, Role, RoleAssignmentListParameters}, }; use crate::config::Config; + use crate::identity::{MockIdentityProvider, types::UserResponse}; use crate::keystone::Service; use crate::provider::Provider; + use crate::resource::{MockResourceProvider, types::Project}; use crate::revoke::MockRevokeProvider; use crate::token::{DomainScopePayload, ProjectScopePayload, Token, UnscopedPayload}; @@ -786,8 +801,8 @@ mod tests { fn generate_token(validity: Option) -> Result { Ok(Token::ProjectScope(ProjectScopePayload { methods: vec!["password".into()], - user_id: Uuid::new_v4().into(), - project_id: Uuid::new_v4().into(), + user_id: Uuid::new_v4().simple().to_string(), + project_id: Uuid::new_v4().simple().to_string(), audit_ids: vec!["Zm9vCg".into()], expires_at: Utc::now() .checked_add_signed(validity.unwrap_or_default()) @@ -903,24 +918,71 @@ mod tests { ); } - /// Test that a valid token with Revocation events fails validation. + /// Test that a valid token with revocation events fails validation. #[tokio::test] + #[traced_test] async fn test_validate_token_revoked() { let token = generate_token(Some(TimeDelta::hours(1))).unwrap(); let config = setup_config(); let token_provider = TokenProvider::new(&config).unwrap(); - let mut revoke_provider = MockRevokeProvider::default(); + let mut revoke_mock = MockRevokeProvider::default(); //let token_clone = token.clone(); - revoke_provider + revoke_mock .expect_is_token_revoked() // TODO: in roundtrip the precision of expiry is reduced and issued_at is different //.withf(move |_, t: &Token| { // *t == token_clone //}) .returning(|_, _| Ok(true)); + + let mut identity_mock = MockIdentityProvider::default(); + let token_clone = token.clone(); + identity_mock + .expect_get_user() + .withf(move |_, id: &'_ str| id == token_clone.user_id()) + .returning(|_, id: &'_ str| { + Ok(Some(UserResponse { + id: id.to_string(), + domain_id: "user_domain_id".into(), + ..Default::default() + })) + }); + let mut resource_mock = MockResourceProvider::default(); + let token_clone2 = token.clone(); + resource_mock + .expect_get_project() + .withf(move |_, id: &'_ str| id == token_clone2.project_id().unwrap()) + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + name: "project".to_string(), + ..Default::default() + })) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + let token_clone3 = token.clone(); + assignment_mock + .expect_list_role_assignments() + .withf(move |_, q: &RoleAssignmentListParameters| { + q.project_id == token_clone3.project_id().cloned() + }) + .returning(|_, q: &RoleAssignmentListParameters| { + Ok(vec![Assignment { + role_id: "rid".into(), + role_name: Some("role_name".into()), + actor_id: q.user_id.clone().unwrap(), + target_id: q.project_id.clone().unwrap(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); let provider = Provider::mocked_builder() - .revoke(revoke_provider) + .assignment(assignment_mock) + .identity(identity_mock) + .revoke(revoke_mock) + .resource(resource_mock) .build() .unwrap(); let state = Arc::new( @@ -935,7 +997,7 @@ mod tests { let credential = token_provider.encode_token(&token).unwrap(); match token_provider - .validate_token(&state, &credential, Some(false), None) + .validate_token(&state, &credential, Some(false), None, None) .await { Err(TokenProviderError::TokenRevoked) => {} diff --git a/src/token/types/provider_api.rs b/src/token/types/provider_api.rs index a3cd1062..a44f2da4 100644 --- a/src/token/types/provider_api.rs +++ b/src/token/types/provider_api.rs @@ -33,16 +33,33 @@ pub trait TokenApi: Send + Sync + Clone { window_seconds: Option, ) -> Result; - /// Validate the token + /// Validate the token. + /// + /// # Arguments + /// + /// * `state` - An application state. + /// * `credential` - A token as a string. + /// * `allow_expired` - Indicates whether for the expired token the an error should be raised + /// or not. + /// * `window_seconds` - An additional token expiration buffer that is added to the + /// `token.expires_at() during the expiration calculation. + /// * `expand` - Indicates whether the token information should be expanded or not. Defaults to + /// true. async fn validate_token<'a>( &self, state: &ServiceState, credential: &'a str, allow_expired: Option, window_seconds: Option, + expand: Option, ) -> Result; - /// Issue a token for given parameters + /// Issue a token for given parameters. + /// + /// # Arguments + /// + /// * `authentication_info` - Authentication information for the token. + /// * `authz_info` - Authorization information (scope) for the token. fn issue_token( &self, authentication_info: AuthenticatedInfo, diff --git a/tests/functional/auth/token.rs b/tests/functional/auth/token.rs index 11354d61..c71782f6 100644 --- a/tests/functional/auth/token.rs +++ b/tests/functional/auth/token.rs @@ -12,4 +12,5 @@ // // SPDX-License-Identifier: Apache-2.0 +mod revoke; mod validate; diff --git a/tests/functional/auth/token/revoke.rs b/tests/functional/auth/token/revoke.rs new file mode 100644 index 00000000..50b1e119 --- /dev/null +++ b/tests/functional/auth/token/revoke.rs @@ -0,0 +1,215 @@ +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +use reqwest::{Client, StatusCode}; +use std::env; +use tracing_test::traced_test; + +use openstack_keystone::api::types::*; +use openstack_keystone::api::v3::auth::token::types::*; + +use crate::common::*; + +#[tokio::test] +#[traced_test] +async fn test_revoke() { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + + let admin_token = auth( + &keystone_url, + get_password_auth( + "admin", + env::var("OPENSTACK_ADMIN_PASSWORD").unwrap_or("password".to_string()), + "default", + ) + .expect("can't prepare password auth"), + Some(Scope::Project( + ProjectScopeBuilder::default() + .name("admin") + .domain(DomainBuilder::default().id("default").build().unwrap()) + .build() + .unwrap(), + )), + ) + .await + .expect("no token"); + + let test_token = auth( + &keystone_url, + get_password_auth( + "admin", + env::var("OPENSTACK_ADMIN_PASSWORD").unwrap_or("password".to_string()), + "default", + ) + .expect("can't prepare password auth"), + Some(Scope::Project( + ProjectScopeBuilder::default() + .name("admin") + .domain(DomainBuilder::default().id("default").build().unwrap()) + .build() + .unwrap(), + )), + ) + .await + .expect("no token"); + + let _auth_rsp: TokenResponse = check_token( + &client, + keystone_url.clone(), + admin_token.clone(), + test_token.clone(), + ) + .await + .unwrap() + .json() + .await + .unwrap(); + + let rsp = client + .delete(format!("{}/v3/auth/tokens", keystone_url)) + .header("x-auth-token", admin_token.clone()) + .header("x-subject-token", test_token.clone()) + .send() + .await + .unwrap(); + assert_eq!(rsp.status(), StatusCode::NO_CONTENT); + + let rsp = client + .get(format!("{}/v3/auth/tokens", keystone_url)) + .header("x-auth-token", admin_token.clone()) + .header("x-subject-token", test_token.clone()) + .send() + .await + .unwrap(); + assert_eq!(rsp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[traced_test] +async fn test_revoke_parent_invalidates_child() { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + + let admin_token = auth( + &keystone_url, + get_password_auth( + "admin", + env::var("OPENSTACK_ADMIN_PASSWORD").unwrap_or("password".to_string()), + "default", + ) + .expect("can't prepare password auth"), + Some(Scope::Project( + ProjectScopeBuilder::default() + .name("admin") + .domain(DomainBuilder::default().id("default").build().unwrap()) + .build() + .unwrap(), + )), + ) + .await + .expect("no token"); + + let parent_token = auth( + &keystone_url, + get_password_auth( + "admin", + env::var("OPENSTACK_ADMIN_PASSWORD").unwrap_or("password".to_string()), + "default", + ) + .expect("can't prepare password auth"), + Some(Scope::Project( + ProjectScopeBuilder::default() + .name("admin") + .domain(DomainBuilder::default().id("default").build().unwrap()) + .build() + .unwrap(), + )), + ) + .await + .expect("no token"); + + let child_token = auth_with_token( + &keystone_url, + &parent_token, + Some(Scope::Project( + ProjectScopeBuilder::default() + .name("admin") + .domain(DomainBuilder::default().id("default").build().unwrap()) + .build() + .unwrap(), + )), + ) + .await + .expect("no token"); + + let _auth_rsp: TokenResponse = check_token( + &client, + keystone_url.clone(), + admin_token.clone(), + parent_token.clone(), + ) + .await + .unwrap() + .json() + .await + .unwrap(); + + let _auth_rsp: TokenResponse = check_token( + &client, + keystone_url.clone(), + admin_token.clone(), + child_token.clone(), + ) + .await + .unwrap() + .json() + .await + .unwrap(); + + let rsp = client + .delete(format!("{}/v3/auth/tokens", keystone_url)) + .header("x-auth-token", admin_token.clone()) + .header("x-subject-token", parent_token.clone()) + .send() + .await + .unwrap(); + assert_eq!(rsp.status(), StatusCode::NO_CONTENT); + + assert_eq!( + StatusCode::NOT_FOUND, + check_token( + &client, + keystone_url.clone(), + admin_token.clone(), + parent_token.clone(), + ) + .await + .unwrap() + .status() + ); + + assert_eq!( + StatusCode::NOT_FOUND, + check_token( + &client, + keystone_url.clone(), + admin_token.clone(), + child_token.clone(), + ) + .await + .unwrap() + .status() + ); +} diff --git a/tests/functional/auth/token/validate.rs b/tests/functional/auth/token/validate.rs index ffea1028..9b44416f 100644 --- a/tests/functional/auth/token/validate.rs +++ b/tests/functional/auth/token/validate.rs @@ -27,7 +27,12 @@ async fn test_validate_own() { let token = auth( &keystone_url, - get_password_auth("admin", "password", "default").expect("can't prepare password auth"), + get_password_auth( + "admin", + env::var("OPENSTACK_ADMIN_PASSWORD").unwrap_or("password".to_string()), + "default", + ) + .expect("can't prepare password auth"), Some(Scope::Project( ProjectScopeBuilder::default() .name("admin") @@ -39,15 +44,12 @@ async fn test_validate_own() { .await .expect("no token"); - let auth_rsp: TokenResponse = client - .get(format!("{}/v3/auth/tokens", keystone_url)) - .header("x-auth-token", token.clone()) - .header("x-subject-token", token.clone()) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); + let auth_rsp: TokenResponse = + check_token(&client, keystone_url.clone(), token.clone(), token.clone()) + .await + .unwrap() + .json() + .await + .unwrap(); println!("Token: {:?}", auth_rsp); } diff --git a/tests/functional/common.rs b/tests/functional/common.rs index c60af87e..a3361055 100644 --- a/tests/functional/common.rs +++ b/tests/functional/common.rs @@ -13,8 +13,8 @@ // SPDX-License-Identifier: Apache-2.0 //! Common functionality used in the functional tests. -use eyre::Report; -use reqwest::Client; +use eyre::{Report, eyre}; +use reqwest::{Client, StatusCode}; use openstack_keystone::api::types::*; use openstack_keystone::api::v3::auth::token::types::*; @@ -59,16 +59,74 @@ where auth: AuthRequestInner { identity, scope }, }; let client = Client::new(); - Ok(client + let rsp = client .post(format!("{}/v3/auth/tokens", keystone_url,)) .json(&serde_json::to_value(auth_request)?) .send() - .await - .unwrap() + .await?; + + tracing::debug!("Authentication response: {:?}", rsp); + + if rsp.status() != StatusCode::OK { + return Err(eyre!("Authentication failed with {}", rsp.status())); + } + + Ok(rsp .headers() .get("X-Subject-Token") - .unwrap() - .to_str() - .unwrap() + .ok_or_else(|| eyre!("Token is missing in the {:?}", rsp))? + .to_str()? + .into()) + //.unwrap() +} + +/// Authenticate using the token. +pub async fn auth_with_token( + keystone_url: U, + token: S, + scope: Option, +) -> Result +where + S: AsRef + std::fmt::Display, + U: AsRef + std::fmt::Display, +{ + let identity = IdentityBuilder::default() + .methods(vec!["token".into()]) + .token(TokenAuthBuilder::default().id(token.as_ref()).build()?) + .build()?; + let auth_request = AuthRequest { + auth: AuthRequestInner { identity, scope }, + }; + let client = Client::new(); + let rsp = client + .post(format!("{}/v3/auth/tokens", keystone_url,)) + .json(&serde_json::to_value(auth_request)?) + .send() + .await?; + Ok(rsp + .headers() + .get("X-Subject-Token") + .ok_or_else(|| eyre!("Token is missing in the {:?}", rsp))? + .to_str()? .to_string()) } + +/// Perform token check request. +pub async fn check_token( + client: &Client, + keystone_url: U, + auth_token: S1, + subject_token: S2, +) -> Result +where + S1: AsRef + std::fmt::Display, + S2: AsRef + std::fmt::Display, + U: AsRef + std::fmt::Display, +{ + client + .get(format!("{}/v3/auth/tokens", keystone_url.as_ref())) + .header("x-auth-token", auth_token.as_ref()) + .header("x-subject-token", subject_token.as_ref()) + .send() + .await +}