From 2b887eae737de213b9a69fcb43ef322a225a64c0 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Wed, 12 Nov 2025 18:28:58 +0000 Subject: [PATCH] feat: Add revocation provider --- src/api/auth.rs | 2 +- src/api/error.rs | 10 + src/api/v3/auth/token/mod.rs | 46 +++-- src/api/v3/role/mod.rs | 14 +- src/api/v3/role_assignment/mod.rs | 14 +- src/api/v4/auth/token/mod.rs | 38 ++-- src/api/v4/federation/identity_provider.rs | 14 +- src/api/v4/federation/mapping.rs | 14 +- src/api/v4/role/mod.rs | 14 +- src/api/v4/role_assignment/mod.rs | 14 +- src/api/v4/token/restriction.rs | 14 +- src/config.rs | 19 +- src/error.rs | 9 + src/lib.rs | 1 + src/plugin_manager.rs | 33 ++-- src/provider.rs | 14 ++ src/revoke/backend.rs | 42 +++++ src/revoke/backend/error.rs | 80 ++++++++ src/revoke/backend/sql.rs | 178 ++++++++++++++++++ src/revoke/backend/sql/list.rs | 205 +++++++++++++++++++++ src/revoke/error.rs | 58 ++++++ src/revoke/mock.rs | 45 +++++ src/revoke/mod.rs | 103 +++++++++++ src/revoke/types.rs | 34 ++++ src/tests/api.rs | 16 +- src/token/backend/fernet/utils.rs | 9 +- src/token/error.rs | 16 ++ src/token/mock.rs | 4 + src/token/mod.rs | 73 +++++++- src/token/types.rs | 9 + src/token/types/provider_api.rs | 4 + 31 files changed, 1043 insertions(+), 103 deletions(-) create mode 100644 src/revoke/backend.rs create mode 100644 src/revoke/backend/error.rs create mode 100644 src/revoke/backend/sql.rs create mode 100644 src/revoke/backend/sql/list.rs create mode 100644 src/revoke/error.rs create mode 100644 src/revoke/mock.rs create mode 100644 src/revoke/mod.rs create mode 100644 src/revoke/types.rs diff --git a/src/api/auth.rs b/src/api/auth.rs index 47c7b65f..66ae9055 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -51,7 +51,7 @@ where let token = state .provider .get_token_provider() - .validate_token(auth_header, Some(false), None) + .validate_token(&state.provider, &state.db, auth_header, Some(false), None) .await .inspect_err(|e| error!("{:#?}", e)) .map_err(|_| KeystoneApiError::Unauthorized)?; diff --git a/src/api/error.rs b/src/api/error.rs index d4f3bc2b..f86781f9 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -29,6 +29,7 @@ use crate::federation::error::FederationProviderError; use crate::identity::error::IdentityProviderError; use crate::policy::PolicyError; use crate::resource::error::ResourceProviderError; +use crate::revoke::error::RevokeProviderError; use crate::token::error::TokenProviderError; /// Keystone API operation errors @@ -116,6 +117,14 @@ pub enum KeystoneApiError { source: ResourceProviderError, }, + /// Revoke provider error. + #[error(transparent)] + RevokeProvider { + /// The source of the error. + #[from] + source: RevokeProviderError, + }, + #[error(transparent)] TokenError { source: TokenProviderError }, @@ -190,6 +199,7 @@ impl IntoResponse for KeystoneApiError { | KeystoneApiError::AssignmentError { .. } | KeystoneApiError::TokenError { .. } | KeystoneApiError::Federation { .. } + | 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 => diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index 0619f160..cec7f12f 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -76,7 +76,7 @@ async fn authenticate_request( let mut authz = state .provider .get_token_provider() - .authenticate_by_token(&token.id, Some(false), None) + .authenticate_by_token(&state.provider, &state.db, &token.id, Some(false), None) .await?; // Resolve the user authz.user = Some( @@ -247,7 +247,13 @@ async fn show( let mut token = state .provider .get_token_provider() - .validate_token(&subject_token, query.allow_expired, None) + .validate_token( + &state.provider, + &state.db, + &subject_token, + query.allow_expired, + None, + ) .await .inspect_err(|e| error!("{:?}", e.to_string())) .map_err(|_| KeystoneApiError::NotFound { @@ -414,11 +420,13 @@ mod tests { token_mock .expect_authenticate_by_token() .withf( - |id: &'_ str, allow_expired: &Option, window: &Option| { + |_, _, id: &'_ str, allow_expired: &Option, window: &Option| { id == "fake_token" && *allow_expired == Some(false) && window.is_none() }, ) - .returning(|_, _, _| Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap())); + .returning(|_, _, _, _, _| { + Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap()) + }); let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -541,12 +549,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_validate_token() + .returning(|_, _, _, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_populate_role_assignments() .returning(|_, _, _| Ok(())); @@ -642,8 +652,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() @@ -651,10 +661,10 @@ 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() @@ -719,8 +729,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() @@ -737,8 +747,8 @@ mod tests { }); token_mock .expect_validate_token() - .withf(|token: &'_ str, _, _| token == "baz") - .returning(|_, _, _| Err(TokenProviderError::Expired)); + .withf(|_, _, token: &'_ str, _, _| token == "baz") + .returning(|_, _, _, _, _| Err(TokenProviderError::Expired)); 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 88f984a3..a8177caf 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 882492b1..900b9ade 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/auth/token/mod.rs b/src/api/v4/auth/token/mod.rs index 507c8a02..05b15f94 100644 --- a/src/api/v4/auth/token/mod.rs +++ b/src/api/v4/auth/token/mod.rs @@ -60,7 +60,7 @@ async fn authenticate_request( let mut authz = state .provider .get_token_provider() - .authenticate_by_token(&token.id, Some(false), None) + .authenticate_by_token(&state.provider, &state.db, &token.id, Some(false), None) .await?; // Resolve the user authz.user = Some( @@ -254,11 +254,13 @@ mod tests { token_mock .expect_authenticate_by_token() .withf( - |id: &'_ str, allow_expired: &Option, window: &Option| { + |_, _, id: &'_ str, allow_expired: &Option, window: &Option| { id == "fake_token" && *allow_expired == Some(false) && window.is_none() }, ) - .returning(|_, _, _| Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap())); + .returning(|_, _, _, _, _| { + Ok(AuthenticatedInfo::builder().user_id("uid").build().unwrap()) + }); let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -381,12 +383,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_validate_token() + .returning(|_, _, _, _, _| { + Ok(ProviderToken::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); token_mock .expect_populate_role_assignments() .returning(|_, _, _| Ok(())); @@ -482,8 +486,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() @@ -491,10 +495,10 @@ 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() @@ -559,8 +563,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() @@ -577,8 +581,8 @@ mod tests { }); token_mock .expect_validate_token() - .withf(|token: &'_ str, _, _| token == "baz") - .returning(|_, _, _| Err(TokenProviderError::Expired)); + .withf(|_, _, token: &'_ str, _, _| token == "baz") + .returning(|_, _, _, _, _| Err(TokenProviderError::Expired)); let provider = Provider::mocked_builder() .token(token_mock) diff --git a/src/api/v4/federation/identity_provider.rs b/src/api/v4/federation/identity_provider.rs index e6e6fc9e..c4e2f9c0 100644 --- a/src/api/v4/federation/identity_provider.rs +++ b/src/api/v4/federation/identity_provider.rs @@ -63,12 +63,14 @@ 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_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/mapping.rs b/src/api/v4/federation/mapping.rs index 1ee8fedd..badd1215 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 60a197ce..6c28defe 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 74defe5d..71d52125 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 7787b07c..2be55845 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/config.rs b/src/config.rs index aba0b28f..3de61b4b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,10 +56,14 @@ pub struct Config { #[serde(default)] pub api_policy: PolicySection, - /// Resource provider related configuration + /// Resource provider related configuration. #[serde(default)] pub resource: ResourceSection, + /// Revoke provider configuration. + #[serde(default)] + pub revoke: RevokeSection, + /// Security compliance #[serde(default)] pub security_compliance: SecurityComplianceSection, @@ -162,6 +166,17 @@ pub struct ResourceSection { pub driver: String, } +/// Revoke provider configuration. +#[derive(Debug, Default, Deserialize, Clone)] +pub struct RevokeSection { + /// Entry point for the token revocation backend driver in the `keystone.revoke` namespace. + /// Keystone only provides a `sql` driver. + pub driver: String, + /// The number of seconds after a token has expired before a corresponding revocation event may + /// be purged from the backend. + pub expiration_buffer: usize, +} + #[derive(Debug, Default, Deserialize, Clone)] pub enum PasswordHashingAlgo { #[default] @@ -239,6 +254,8 @@ impl TryFrom> for Config { .set_default("catalog.driver", "sql")? .set_default("federation.driver", "sql")? .set_default("resource.driver", "sql")? + .set_default("revoke.driver", "sql")? + .set_default("revoke.expiration_buffer", "1800")? .set_default("token.expiration", "3600")?; builder diff --git a/src/error.rs b/src/error.rs index da9d0b0d..5b3869f1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,7 @@ use crate::federation::error::*; use crate::identity::error::*; use crate::policy::*; use crate::resource::error::*; +use crate::revoke::error::*; use crate::token::TokenProviderError; #[derive(Debug, Error)] @@ -70,6 +71,14 @@ pub enum KeystoneError { source: ResourceProviderError, }, + /// Revoke provider error. + #[error(transparent)] + RevokeProvider { + /// The source of the error. + #[from] + source: RevokeProviderError, + }, + #[error(transparent)] TokenProvider { #[from] diff --git a/src/lib.rs b/src/lib.rs index 142a79a6..a88cb03e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod plugin_manager; pub mod policy; pub mod provider; pub mod resource; +pub mod revoke; pub mod token; #[cfg(test)] diff --git a/src/plugin_manager.rs b/src/plugin_manager.rs index fe3f17e9..5ca703dc 100644 --- a/src/plugin_manager.rs +++ b/src/plugin_manager.rs @@ -19,25 +19,28 @@ use crate::catalog::types::CatalogBackend; use crate::federation::types::FederationBackend; use crate::identity::types::IdentityBackend; use crate::resource::types::ResourceBackend; +use crate::revoke::backend::RevokeBackend; /// Plugin manager allowing to pass custom backend plugins implementing required trait during the -/// service start +/// service start. #[derive(Clone, Debug, Default)] pub struct PluginManager { - /// Assignments backend plugin + /// Assignments backend plugin. assignment_backends: HashMap>, - /// Catalog backend plugins + /// Catalog backend plugins. catalog_backends: HashMap>, - /// Federation backend plugins + /// Federation backend plugins. federation_backends: HashMap>, - /// Identity backend plugins + /// Identity backend plugins. identity_backends: HashMap>, - /// Resource backend plugins + /// Resource backend plugins. resource_backends: HashMap>, + /// Revoke backend plugins. + revoke_backends: HashMap>, } impl PluginManager { - /// Register identity backend + /// Register identity backend. pub fn register_identity_backend>( &mut self, name: S, @@ -47,7 +50,7 @@ impl PluginManager { .insert(name.as_ref().to_string(), plugin); } - /// Get registered assignment backend + /// Get registered assignment backend. #[allow(clippy::borrowed_box)] pub fn get_assignment_backend>( &self, @@ -56,13 +59,13 @@ impl PluginManager { self.assignment_backends.get(name.as_ref()) } - /// Get registered catalog backend + /// Get registered catalog backend. #[allow(clippy::borrowed_box)] pub fn get_catalog_backend>(&self, name: S) -> Option<&Box> { self.catalog_backends.get(name.as_ref()) } - /// Get registered federation backend + /// Get registered federation backend. #[allow(clippy::borrowed_box)] pub fn get_federation_backend>( &self, @@ -71,7 +74,7 @@ impl PluginManager { self.federation_backends.get(name.as_ref()) } - /// Get registered identity backend + /// Get registered identity backend. #[allow(clippy::borrowed_box)] pub fn get_identity_backend>( &self, @@ -80,7 +83,7 @@ impl PluginManager { self.identity_backends.get(name.as_ref()) } - /// Get registered resource backend + /// Get registered resource backend. #[allow(clippy::borrowed_box)] pub fn get_resource_backend>( &self, @@ -88,4 +91,10 @@ impl PluginManager { ) -> Option<&Box> { self.resource_backends.get(name.as_ref()) } + + /// Get registered revoke backend. + #[allow(clippy::borrowed_box)] + pub fn get_revoke_backend>(&self, name: S) -> Option<&Box> { + self.revoke_backends.get(name.as_ref()) + } } diff --git a/src/provider.rs b/src/provider.rs index 14a05837..d08d1408 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -32,6 +32,9 @@ use crate::plugin_manager::PluginManager; use crate::resource::ResourceApi; #[double] use crate::resource::ResourceProvider; +use crate::revoke::RevokeApi; +#[double] +use crate::revoke::RevokeProvider; use crate::token::TokenApi; #[double] use crate::token::TokenProvider; @@ -41,6 +44,7 @@ use crate::token::TokenProvider; // fn get_token_provider(&self) -> &impl TokenApi; //} +/// Global provider manager. #[derive(Builder, Clone)] // It is necessary to use the owned pattern since otherwise builder invokes clone which immediately // confuses mockall used in tests @@ -52,6 +56,8 @@ pub struct Provider { federation: FederationProvider, identity: IdentityProvider, resource: ResourceProvider, + /// Revoke provider. + revoke: RevokeProvider, token: TokenProvider, } @@ -62,6 +68,7 @@ impl Provider { let federation_provider = FederationProvider::new(&cfg, &plugin_manager)?; let identity_provider = IdentityProvider::new(&cfg, &plugin_manager)?; let resource_provider = ResourceProvider::new(&cfg, &plugin_manager)?; + let revoke_provider = RevokeProvider::new(&cfg, &plugin_manager)?; let token_provider = TokenProvider::new(&cfg)?; Ok(Self { @@ -71,6 +78,7 @@ impl Provider { federation: federation_provider, identity: identity_provider, resource: resource_provider, + revoke: revoke_provider, token: token_provider, }) } @@ -95,6 +103,10 @@ impl Provider { &self.resource } + pub fn get_revoke_provider(&self) -> &impl RevokeApi { + &self.revoke + } + pub fn get_token_provider(&self) -> &impl TokenApi { &self.token } @@ -110,6 +122,7 @@ impl Provider { let assignment_mock = crate::assignment::MockAssignmentProvider::default(); let catalog_mock = crate::catalog::MockCatalogProvider::default(); let federation_mock = crate::federation::MockFederationProvider::default(); + let revoke_mock = crate::revoke::MockRevokeProvider::default(); ProviderBuilder::default() .config(config.clone()) @@ -118,6 +131,7 @@ impl Provider { .identity(identity_mock) .federation(federation_mock) .resource(resource_mock) + .revoke(revoke_mock) .token(token_mock) } } diff --git a/src/revoke/backend.rs b/src/revoke/backend.rs new file mode 100644 index 00000000..13ed8613 --- /dev/null +++ b/src/revoke/backend.rs @@ -0,0 +1,42 @@ +// 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 +//! Token revocation: Backends. + +use async_trait::async_trait; +use dyn_clone::DynClone; +use sea_orm::DatabaseConnection; + +use crate::config::Config; +use crate::revoke::RevokeProviderError; +use crate::token::types::Token; + +pub mod error; +pub mod sql; + +#[async_trait] +pub trait RevokeBackend: DynClone + Send + Sync + std::fmt::Debug { + /// Set config + fn set_config(&mut self, config: Config); + + /// Check token revocation. + /// + /// Check whether there are existing revocation records that invalidate the token. + async fn is_token_revoked( + &self, + db: &DatabaseConnection, + token: &Token, + ) -> Result; +} + +dyn_clone::clone_trait_object!(RevokeBackend); diff --git a/src/revoke/backend/error.rs b/src/revoke/backend/error.rs new file mode 100644 index 00000000..c152deb9 --- /dev/null +++ b/src/revoke/backend/error.rs @@ -0,0 +1,80 @@ +// 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 provider database backend error. + +use sea_orm::SqlErr; +use thiserror::Error; + +/// Revoke provider database error. +#[derive(Error, Debug)] +pub enum RevokeDatabaseError { + /// (De)Ser error. + #[error(transparent)] + Serde { + /// The source of the error. + #[from] + source: serde_json::Error, + }, + + /// Conflict. + #[error("{message}")] + Conflict { + /// The error message. + message: String, + /// The error context. + context: String, + }, + + /// SqlError. + #[error("{message}")] + Sql { + /// The error message. + message: String, + /// The error context. + context: String, + }, + + /// Database error. + #[error("database error while {context}")] + Database { + /// The source of the error. + source: sea_orm::DbErr, + /// The error context. + context: String, + }, +} + +/// Convert the DB error into the [RevokeDatabaseError] with the context information. +pub fn db_err(e: sea_orm::DbErr, context: &str) -> RevokeDatabaseError { + e.sql_err().map_or_else( + || RevokeDatabaseError::Database { + source: e, + context: context.to_string(), + }, + |err| match err { + SqlErr::UniqueConstraintViolation(descr) => RevokeDatabaseError::Conflict { + message: descr.to_string(), + context: context.to_string(), + }, + SqlErr::ForeignKeyConstraintViolation(descr) => RevokeDatabaseError::Conflict { + message: descr.to_string(), + context: context.to_string(), + }, + other => RevokeDatabaseError::Sql { + message: other.to_string(), + context: context.to_string(), + }, + }, + ) +} diff --git a/src/revoke/backend/sql.rs b/src/revoke/backend/sql.rs new file mode 100644 index 00000000..3f35ea6d --- /dev/null +++ b/src/revoke/backend/sql.rs @@ -0,0 +1,178 @@ +// 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 provider: database backend. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use derive_builder::Builder; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use super::RevokeBackend; +use crate::config::Config; +use crate::db::entity::revocation_event as db_revocation_event; +use crate::revoke::RevokeProviderError; +use crate::revoke::backend::error::RevokeDatabaseError; +use crate::token::types::Token; + +mod list; + +/// Sql Database revocation backend. +#[derive(Clone, Debug, Default)] +pub struct SqlBackend { + pub config: Config, +} + +impl SqlBackend {} + +impl TryFrom for RevocationEvent { + type Error = RevokeDatabaseError; + fn try_from(value: db_revocation_event::Model) -> Result { + Ok(Self { + domain_id: value.domain_id, + project_id: value.project_id, + user_id: value.user_id, + role_id: value.role_id, + trust_id: value.trust_id, + consumer_id: value.consumer_id, + access_token_id: value.access_token_id, + issued_before: value.issued_before.and_utc(), + expires_at: value.expires_at.map(|expires_at| expires_at.and_utc()), + revoked_at: value.revoked_at.and_utc(), + audit_id: value.audit_id, + audit_chain_id: value.audit_chain_id, + }) + } +} + +#[async_trait] +impl RevokeBackend for SqlBackend { + /// Set config. + fn set_config(&mut self, config: Config) { + self.config = config; + } + + /// Check the token for being revoked. + /// + /// List not expired revocation records that invalidate the token and returns true if there is + /// at least one such record. + async fn is_token_revoked( + &self, + db: &DatabaseConnection, + token: &Token, + ) -> Result { + // Check for the token revocation events. + if list::count(db, &token.try_into()?).await? > 0 { + Ok(true) + } else { + Ok(false) + } + } +} + +/// Revocation event. +#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[builder(setter(strip_option, into))] +pub struct RevocationEvent { + 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, +} + +/// Revocation list parameters. +/// +/// It may be necessary to list revocation events not related to the certain token. +#[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[builder(setter(strip_option, into))] +struct RevocationEventListParameters { + //pub access_token_id: Option, + //pub audit_chain_id: Option, + #[builder(default)] + pub audit_id: Option, + //pub consumer_id: Option, + #[builder(default)] + pub domain_id: Option, + #[builder(default)] + pub expires_at: Option>, + #[builder(default)] + pub issued_before: Option>, + #[builder(default)] + pub project_id: Option, + #[builder(default)] + pub revoked_at: Option>, + //pub role_id: Option, + //pub trust_id: Option, + #[builder(default)] + pub user_id: Option>, +} + +impl TryFrom<&Token> for RevocationEventListParameters { + type Error = RevokeProviderError; + fn try_from(value: &Token) -> Result { + // TODO: for trust token user_id can be trustee_id or trustor_id + Ok(Self { + //access_token_id: None, + //audit_chain_id: None, + audit_id: Some( + value + .audit_ids() + .first() + .ok_or_else(|| RevokeProviderError::TokenHasNoAuditId)?, + ) + .cloned(), + //consumer_id: None, + domain_id: value.domain().map(|domain| domain.id.clone()), + expires_at: None, + issued_before: Some(*value.issued_at()), + project_id: value.project_id().cloned(), + revoked_at: None, + //role_id: None, + //trust_id: None, + user_id: Some(vec![value.user_id().clone()]), + }) + } +} + +#[cfg(test)] +mod tests { + use crate::db::entity::revocation_event as db_revocation_event; + use chrono::NaiveDateTime; + + pub(super) fn get_mock() -> db_revocation_event::Model { + db_revocation_event::Model { + id: 1i32, + domain_id: Some("did".into()), + project_id: Some("pid".into()), + user_id: Some("uid".into()), + role_id: Some("rid".into()), + trust_id: Some("trust_id".into()), + consumer_id: Some("consumer_id".into()), + access_token_id: Some("access_token_id".into()), + issued_before: NaiveDateTime::default(), + expires_at: Some(NaiveDateTime::default()), + revoked_at: NaiveDateTime::default(), + audit_id: Some("audit_id".into()), + audit_chain_id: Some("audit_chain_id".into()), + } + } +} diff --git a/src/revoke/backend/sql/list.rs b/src/revoke/backend/sql/list.rs new file mode 100644 index 00000000..eb1b6810 --- /dev/null +++ b/src/revoke/backend/sql/list.rs @@ -0,0 +1,205 @@ +// 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 +//! List not expired revocation event records invalidating the token. + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use super::{RevocationEvent, RevocationEventListParameters}; +use crate::db::entity::{ + prelude::RevocationEvent as DbRevocationEvent, revocation_event as db_revocation_event, +}; +use crate::revoke::backend::error::{RevokeDatabaseError, db_err}; + +fn build_query_filters( + params: &RevocationEventListParameters, +) -> Result, RevokeDatabaseError> { + let mut select = DbRevocationEvent::find(); + + //if let Some(val) = ¶ms.access_token_id { + // select = select.filter(db_revocation_event::Column::AccessTokenId.eq(val)); + //} + + //if let Some(val) = ¶ms.audit_chain_id { + // 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)); + } + + if let Some(val) = ¶ms.domain_id { + select = select.filter(db_revocation_event::Column::DomainId.eq(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)); + } + + if let Some(val) = ¶ms.project_id { + select = select.filter(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)); + } + + Ok(select) +} + +/// Count token revocation events. +/// +/// Return not expired revocation records that invalidate the token. +pub async fn count( + db: &DatabaseConnection, + params: &RevocationEventListParameters, +) -> Result { + build_query_filters(params)? + .count(db) + .await + .map_err(|err| db_err(err, "counting revocation events for the token")) +} + +/// List token revocation events. +/// +/// Return not expired revocation records that invalidate the token. +#[allow(unused)] +pub async fn list( + db: &DatabaseConnection, + params: &RevocationEventListParameters, +) -> Result, RevokeDatabaseError> { + let db_entities: Vec = + build_query_filters(params)? + .all(db) + .await + .map_err(|err| db_err(err, "listing revocation events for the token"))?; + + let results: Result, _> = db_entities + .into_iter() + .map(TryInto::::try_into) + .collect(); + + results +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, Days, Utc}; + use sea_orm::{DatabaseBackend, IntoMockRow, MockDatabase, Transaction}; + use std::collections::BTreeMap; + + use super::super::tests::get_mock; + use super::*; + use crate::revoke::backend::sql::RevocationEventListParametersBuilder; + + #[tokio::test] + async fn test_count() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![ + BTreeMap::from([("num_items", Into::::into(3i64))]).into_mock_row(), + ]]) + .into_connection(); + let time1 = Utc::now(); + let time2 = time1.checked_add_days(Days::new(1)).unwrap(); + + assert_eq!( + count( + &db, + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .domain_id("domain_id") + .expires_at(time2) + .issued_before(time1) + .project_id("project_id") + .build() + .unwrap() + ) + .await + .unwrap(), + 3 + ); + + assert_eq!( + 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""#, + [ + "audit_id".into(), + "domain_id".into(), + time2.into(), + time1.into(), + "project_id".into(), + ] + ),] + ); + } + #[tokio::test] + async fn test_list() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_mock()]]) + .into_connection(); + let time1 = Utc::now(); + let time2 = time1.checked_add_days(Days::new(1)).unwrap(); + assert_eq!( + list( + &db, + &RevocationEventListParametersBuilder::default() + .audit_id("audit_id") + .domain_id("domain_id") + .expires_at(time2) + .issued_before(time1) + .project_id("project_id") + .build() + .unwrap() + ) + .await + .unwrap(), + vec![RevocationEvent { + domain_id: Some("did".into()), + project_id: Some("pid".into()), + user_id: Some("uid".into()), + role_id: Some("rid".into()), + trust_id: Some("trust_id".into()), + consumer_id: Some("consumer_id".into()), + access_token_id: Some("access_token_id".into()), + issued_before: DateTime::UNIX_EPOCH, + expires_at: Some(DateTime::UNIX_EPOCH), + revoked_at: DateTime::UNIX_EPOCH, + audit_id: Some("audit_id".into()), + audit_chain_id: Some("audit_chain_id".into()), + }] + ); + + assert_eq!( + 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"#, + [ + "audit_id".into(), + "domain_id".into(), + time2.into(), + time1.into(), + "project_id".into(), + ] + ),] + ); + } +} diff --git a/src/revoke/error.rs b/src/revoke/error.rs new file mode 100644 index 00000000..e262365c --- /dev/null +++ b/src/revoke/error.rs @@ -0,0 +1,58 @@ +// 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 +//! Token revocation errors. + +use thiserror::Error; + +use crate::revoke::backend::error::RevokeDatabaseError; + +/// Revoke provider error. +#[derive(Error, Debug)] +pub enum RevokeProviderError { + /// Unsupported driver. + #[error("unsupported driver {0}")] + UnsupportedDriver(String), + + /// Conflict. + #[error("conflict: {0}")] + Conflict(String), + + /// Data (de)serialization error. + #[error("data serialization error")] + Serde { + /// The source of the error. + #[from] + source: serde_json::Error, + }, + + /// Database provider error. + #[error(transparent)] + RevokeDatabase { + /// The source of the error. + source: RevokeDatabaseError, + }, + + /// No audit ID in the token. + #[error("token does not have the audit_id set")] + TokenHasNoAuditId, +} + +impl From for RevokeProviderError { + fn from(source: RevokeDatabaseError) -> Self { + match source { + RevokeDatabaseError::Conflict { message, .. } => Self::Conflict(message), + _ => Self::RevokeDatabase { source }, + } + } +} diff --git a/src/revoke/mock.rs b/src/revoke/mock.rs new file mode 100644 index 00000000..5c38370b --- /dev/null +++ b/src/revoke/mock.rs @@ -0,0 +1,45 @@ +// 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 +//! Token revocation - internal mocking tools. +//! +use async_trait::async_trait; +#[cfg(test)] +use mockall::mock; +use sea_orm::DatabaseConnection; + +use crate::config::Config; +use crate::plugin_manager::PluginManager; +use crate::revoke::RevokeApi; +use crate::revoke::error::RevokeProviderError; +use crate::token::types::Token; + +#[cfg(test)] +mock! { + pub RevokeProvider { + pub fn new(cfg: &Config, plugin_manager: &PluginManager) -> Result; + } + + #[async_trait] + impl RevokeApi for RevokeProvider { + async fn is_token_revoked( + &self, + db: &DatabaseConnection, + token: &Token, + ) -> Result; + } + + impl Clone for RevokeProvider { + fn clone(&self) -> Self; + } +} diff --git a/src/revoke/mod.rs b/src/revoke/mod.rs new file mode 100644 index 00000000..011b5414 --- /dev/null +++ b/src/revoke/mod.rs @@ -0,0 +1,103 @@ +// 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 +//! Token revocation provider. +//! +//! Token revocation may be implemented in different ways, but in most cases would be represented +//! by the presence of the revocation or the invalidation record matching the certain token +//! parameters. +//! +//! Default backend is the [crate::revoke::backend::sql] and uses the database table +//! [crate::db::entity::revocation_event::Model] for storing the revocation events. They have their +//! own expiration. +//! +//! Tokens are not invalidated by saving the exact value, but rather by saving certain attributes +//! of the token. +//! +//! Following attributes are used for matching of the regular fernet token: +//! +//! - `audit_id` +//! - `domain_id` +//! - `expires_at` +//! - `project_id` +//! - `user_id` +//! +//! Additionally the `token.issued_at` is compared to be lower than the `issued_before` field of +//! the revocation record. +//! + +use async_trait::async_trait; +use sea_orm::DatabaseConnection; + +pub mod backend; +pub mod error; +#[cfg(test)] +mod mock; +pub(crate) mod types; + +use crate::config::Config; +use crate::plugin_manager::PluginManager; +use crate::revoke::backend::{RevokeBackend, sql::SqlBackend}; +use crate::revoke::error::RevokeProviderError; +use crate::token::types::Token; + +#[cfg(test)] +pub use mock::MockRevokeProvider; + +pub use types::*; + +/// Revoke provider. +#[derive(Clone, Debug)] +pub struct RevokeProvider { + /// Backend driver. + backend_driver: Box, +} + +impl RevokeProvider { + pub fn new( + config: &Config, + plugin_manager: &PluginManager, + ) -> Result { + let mut backend_driver = + if let Some(driver) = plugin_manager.get_revoke_backend(config.revoke.driver.clone()) { + driver.clone() + } else { + match config.revoke.driver.as_str() { + "sql" => Box::new(SqlBackend::default()), + _ => { + return Err(RevokeProviderError::UnsupportedDriver( + config.revoke.driver.clone(), + )); + } + } + }; + backend_driver.set_config(config.clone()); + Ok(Self { backend_driver }) + } +} + +#[async_trait] +impl RevokeApi for RevokeProvider { + /// Check whether the token has been revoked or not. + /// + /// Checks revocation events matching the token parameters and return `false` if their count is + /// more than `0`. + #[tracing::instrument(level = "info", skip(self, db, token))] + async fn is_token_revoked( + &self, + db: &DatabaseConnection, + token: &Token, + ) -> Result { + self.backend_driver.is_token_revoked(db, token).await + } +} diff --git a/src/revoke/types.rs b/src/revoke/types.rs new file mode 100644 index 00000000..9a656f7c --- /dev/null +++ b/src/revoke/types.rs @@ -0,0 +1,34 @@ +// 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 +//! Token revocation types definitions. + +use async_trait::async_trait; +use sea_orm::DatabaseConnection; + +use crate::revoke::RevokeProviderError; +use crate::token::types::Token; + +/// Revocation Provider interface. +#[async_trait] +pub trait RevokeApi: Send + Sync + Clone { + /// Check whether the token has been revoked of not. + /// + /// Checks revocation events matching the token parameters and return `false` if their count is + /// more than `0`. + async fn is_token_revoked( + &self, + db: &DatabaseConnection, + token: &Token, + ) -> Result; +} diff --git a/src/tests/api.rs b/src/tests/api.rs index bc495a1c..12162f2c 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/backend/fernet/utils.rs b/src/token/backend/fernet/utils.rs index 1eaa2150..16989af8 100644 --- a/src/token/backend/fernet/utils.rs +++ b/src/token/backend/fernet/utils.rs @@ -263,8 +263,13 @@ pub fn write_audit_ids>( write_array_len(wd, vals.len() as u32) .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; for val in vals.iter() { - write_bin(wd, &URL_SAFE.decode(val)?) - .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; + write_bin( + wd, + &URL_SAFE + .decode(val) + .map_err(|_| TokenProviderError::AuditIdWrongFormat)?, + ) + .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; } Ok(()) } diff --git a/src/token/error.rs b/src/token/error.rs index c1c69466..a6c411ea 100644 --- a/src/token/error.rs +++ b/src/token/error.rs @@ -192,6 +192,14 @@ pub enum TokenProviderError { source: crate::resource::error::ResourceProviderError, }, + /// Revoke Provider error. + #[error(transparent)] + RevokeProvider { + /// The source of the error. + #[from] + source: crate::revoke::error::RevokeProviderError, + }, + #[error("actor has no roles on scope")] ActorHasNoRolesOnTarget, @@ -210,6 +218,10 @@ pub enum TokenProviderError { #[error("token restriction {0} not found")] TokenRestrictionNotFound(String), + /// Revoked token + #[error("token has been revoked")] + TokenRevoked, + /// Conflict #[error("{message}")] Conflict { message: String, context: String }, @@ -223,6 +235,10 @@ pub enum TokenProviderError { source: sea_orm::DbErr, context: String, }, + + /// AuditID must be urlsafe base64 encoded value. + #[error("audit_id must be urlsafe base64 encoded value")] + AuditIdWrongFormat, } /// Convert the DB error into the TokenProviderError with the context information. diff --git a/src/token/mock.rs b/src/token/mock.rs index 80229df3..537d4580 100644 --- a/src/token/mock.rs +++ b/src/token/mock.rs @@ -36,6 +36,8 @@ mock! { impl TokenApi for TokenProvider { async fn authenticate_by_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option, @@ -43,6 +45,8 @@ mock! { async fn validate_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option, diff --git a/src/token/mod.rs b/src/token/mod.rs index 5ab52f2d..5cea95b9 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -39,6 +39,7 @@ use crate::resource::{ ResourceApi, types::{Domain, Project}, }; +use crate::revoke::RevokeApi; use backend::{TokenBackend, fernet::FernetTokenProvider}; pub use error::TokenProviderError; @@ -286,6 +287,8 @@ impl TokenProvider { .build()?, )) } + + /// Expand user information in the token. async fn expand_user_information( &self, token: &mut Token, @@ -331,15 +334,17 @@ impl TokenProvider { #[async_trait] impl TokenApi for TokenProvider { /// Authenticate by token - #[tracing::instrument(level = "info", skip(self, credential))] + #[tracing::instrument(level = "info", skip(self, credential, provider))] async fn authenticate_by_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option, ) -> Result { let token = self - .validate_token(credential, allow_expired, window_seconds) + .validate_token(provider, db, credential, allow_expired, window_seconds) .await?; tracing::debug!("The token is {:?}", token); if let Token::Restricted(restriction) = &token @@ -360,9 +365,11 @@ impl TokenApi for TokenProvider { } /// Validate token - #[tracing::instrument(level = "info", skip(self, credential))] + #[tracing::instrument(level = "info", skip(self, credential, provider))] async fn validate_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option, @@ -378,6 +385,14 @@ impl TokenApi for TokenProvider { return Err(TokenProviderError::Expired); } + if provider + .get_revoke_provider() + .is_token_revoked(db, &token) + .await? + { + return Err(TokenProviderError::TokenRevoked); + } + Ok(token) } @@ -726,16 +741,20 @@ impl TokenApi for TokenProvider { #[cfg(test)] mod tests { + use chrono::Utc; + use eyre::{Result, eyre}; use sea_orm::DatabaseConnection; use std::fs::File; use std::io::Write; use tempfile::tempdir; + use uuid::Uuid; use super::*; use crate::assignment::{ MockAssignmentProvider, types::{Assignment, AssignmentType, Role, RoleAssignmentListParameters}, }; + use crate::revoke::MockRevokeProvider; use crate::config::Config; @@ -761,6 +780,20 @@ mod tests { config } + /// Generate test token to use for validation testing. + 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(), + audit_ids: vec!["Zm9vCg".into()], + expires_at: Utc::now() + .checked_add_signed(validity.unwrap_or_default()) + .ok_or(eyre!("timedelta apply failed"))?, + ..Default::default() + })) + } + #[tokio::test] async fn test_populate_role_assignments() { let token_provider = TokenProvider::new(&Config::default()).unwrap(); @@ -858,4 +891,38 @@ mod tests { .is_ok() ); } + + /// Test that a valid token with Revocation events fails validation. + #[tokio::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 token_clone = token.clone(); + revoke_provider + .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 db = DatabaseConnection::Disconnected; + let provider = Provider::mocked_builder() + .revoke(revoke_provider) + .build() + .unwrap(); + + let credential = token_provider.encode_token(&token).unwrap(); + match token_provider + .validate_token(&provider, &db, &credential, Some(false), None) + .await + { + Err(TokenProviderError::TokenRevoked) => {} + _ => { + panic!("token must be revoked") + } + } + } } diff --git a/src/token/types.rs b/src/token/types.rs index 115e15b3..8f6658f9 100644 --- a/src/token/types.rs +++ b/src/token/types.rs @@ -177,6 +177,15 @@ impl Token { } } + pub const fn project_id(&self) -> Option<&String> { + match self { + Self::ProjectScope(x) => Some(&x.project_id), + Self::FederationProjectScope(x) => Some(&x.project_id), + Self::Restricted(x) => Some(&x.project_id), + _ => None, + } + } + pub const fn domain(&self) -> Option<&Domain> { match self { Self::DomainScope(x) => x.domain.as_ref(), diff --git a/src/token/types/provider_api.rs b/src/token/types/provider_api.rs index ea68ff61..80d2db9f 100644 --- a/src/token/types/provider_api.rs +++ b/src/token/types/provider_api.rs @@ -27,6 +27,8 @@ use super::*; pub trait TokenApi: Send + Sync + Clone { async fn authenticate_by_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option, @@ -35,6 +37,8 @@ pub trait TokenApi: Send + Sync + Clone { /// Validate the token async fn validate_token<'a>( &self, + provider: &Provider, + db: &DatabaseConnection, credential: &'a str, allow_expired: Option, window_seconds: Option,