From 9259735611ab798b91cd5d2c598e04ff884023cd Mon Sep 17 00:00:00 2001 From: gtema Date: Sun, 5 Oct 2025 18:32:00 +0200 Subject: [PATCH] feat: Add token restrictions support Implement a new token payload that allows preventing token rescoping, renewing, having fixed roles, etc. Such restrictions are necessary for the proper OIDC/Jwt support. --- src/api/error.rs | 51 ++-- src/api/v3/auth/token/common.rs | 16 ++ src/api/v3/auth/token/mod.rs | 26 ++- src/api/v4/auth/passkey/finish.rs | 9 +- src/api/v4/auth/token/common.rs | 16 ++ src/api/v4/auth/token/mod.rs | 2 +- src/api/v4/federation/common.rs | 59 +++-- src/api/v4/federation/jwt.rs | 20 +- src/api/v4/federation/mapping/list.rs | 8 +- src/api/v4/federation/mapping/show.rs | 3 +- src/api/v4/federation/oidc.rs | 19 +- src/api/v4/federation/types/mapping.rs | 50 ++-- src/auth/mod.rs | 24 +- src/db/entity.rs | 2 + src/db/entity/federated_mapping.rs | 3 +- src/db/entity/prelude.rs | 2 + src/db/entity/token_restriction.rs | 69 ++++++ .../token_restriction_role_association.rs | 60 +++++ src/db_migration/m20250414_000001_idp.rs | 1 + .../m20251005_131042_token_restriction.rs | 154 +++++++++++++ src/db_migration/mod.rs | 2 + src/federation/backends/sql/mapping.rs | 14 +- src/federation/backends/sql/mapping/create.rs | 22 +- src/federation/backends/sql/mapping/get.rs | 2 +- src/federation/backends/sql/mapping/list.rs | 4 +- src/federation/backends/sql/mapping/update.rs | 19 +- src/federation/mod.rs | 16 -- src/federation/types/mapping.rs | 24 +- src/token/error.rs | 52 +++++ src/token/fernet.rs | 35 ++- src/token/fernet_utils.rs | 24 +- src/token/mod.rs | 120 +++++++++- src/token/restricted.rs | 175 ++++++++++++++ src/token/token_restriction/get.rs | 218 ++++++++++++++++++ src/token/token_restriction/mod.rs | 88 +++++++ src/token/types.rs | 31 ++- 36 files changed, 1245 insertions(+), 195 deletions(-) create mode 100644 src/db/entity/token_restriction.rs create mode 100644 src/db/entity/token_restriction_role_association.rs create mode 100644 src/db_migration/m20251005_131042_token_restriction.rs create mode 100644 src/token/restricted.rs create mode 100644 src/token/token_restriction/get.rs create mode 100644 src/token/token_restriction/mod.rs diff --git a/src/api/error.rs b/src/api/error.rs index 28aa904f..26c391fd 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -102,10 +102,7 @@ pub enum KeystoneApiError { // source: OidcError, // }, #[error(transparent)] - IdentityError { - #[from] - source: IdentityProviderError, - }, + IdentityError { source: IdentityProviderError }, #[error(transparent)] Policy { @@ -120,10 +117,7 @@ pub enum KeystoneApiError { }, #[error(transparent)] - TokenError { - #[from] - source: TokenProviderError, - }, + TokenError { source: TokenProviderError }, #[error(transparent)] WebAuthN { @@ -159,9 +153,17 @@ pub enum KeystoneApiError { #[error(transparent)] JsonExtractorRejection(#[from] JsonRejection), - #[error("The account is disabled for user: {0}")] + #[error("the account is disabled for user: {0}")] UserDisabled(String), + /// Selected authentication is forbidden. + #[error("selected authentication is forbidden")] + SelectedAuthenticationForbidden, + + /// Selected authentication is forbidden. + #[error("changing current authentication scope is forbidden")] + AuthenticationRescopeForbidden, + /// Others. #[error(transparent)] Other(#[from] eyre::Report), @@ -180,6 +182,8 @@ impl IntoResponse for KeystoneApiError { // KeystoneApiError::AuthenticationInfo { .. } => StatusCode::UNAUTHORIZED, KeystoneApiError::Forbidden => StatusCode::FORBIDDEN, KeystoneApiError::Policy { .. } => StatusCode::FORBIDDEN, + KeystoneApiError::SelectedAuthenticationForbidden + | KeystoneApiError::AuthenticationRescopeForbidden => StatusCode::BAD_REQUEST, KeystoneApiError::InternalError(_) | KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } @@ -209,7 +213,7 @@ impl KeystoneApiError { resource: "role".into(), identifier: x, }, - _ => Self::AssignmentError { source }, + _ => source.into(), } } pub fn federation(source: FederationProviderError) -> Self { @@ -223,7 +227,7 @@ impl KeystoneApiError { identifier: x, }, FederationProviderError::Conflict(x) => Self::Conflict(x), - _ => Self::Federation { source }, + _ => source.into(), } } pub fn identity(source: IdentityProviderError) -> Self { @@ -236,7 +240,7 @@ impl KeystoneApiError { resource: "group".into(), identifier: x, }, - _ => Self::IdentityError { source }, + _ => source.into(), } } pub fn resource(source: ResourceProviderError) -> Self { @@ -245,7 +249,7 @@ impl KeystoneApiError { resource: "domain".into(), identifier: x, }, - _ => Self::ResourceError { source }, + _ => source.into(), } } } @@ -330,7 +334,28 @@ impl From for KeystoneApiError { KeystoneApiError::InternalError(source.to_string()) } AuthenticationError::UserDisabled(data) => KeystoneApiError::UserDisabled(data), + AuthenticationError::TokenRenewalForbidden => { + KeystoneApiError::SelectedAuthenticationForbidden + } AuthenticationError::Unauthorized => KeystoneApiError::Unauthorized, } } } + +impl From for KeystoneApiError { + fn from(value: IdentityProviderError) -> Self { + match value { + IdentityProviderError::AuthenticationInfo { source } => source.into(), + _ => Self::IdentityError { source: value }, + } + } +} + +impl From for KeystoneApiError { + fn from(value: TokenProviderError) -> Self { + match value { + TokenProviderError::AuthenticationInfo { source } => source.into(), + _ => Self::TokenError { source: value }, + } + } +} diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index 449ea7d8..4bef7d47 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -136,6 +136,22 @@ impl Token { ); } } + ProviderToken::Restricted(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } } if let Some(domain) = domain { diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index a2e11a1f..49f555dc 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -144,7 +144,7 @@ async fn get_authz_info( Ok(authz_info) } -/// Authenticate user issuing a new token +/// Authenticate user issuing a new token. #[utoipa::path( post, path = "/", @@ -163,11 +163,25 @@ async fn post( ) -> Result { let authed_info = authenticate_request(&state, &req).await?; let authz_info = get_authz_info(&state, &req).await?; + if let Some(restriction_id) = &authed_info.token_restriction_id { + let restriction = state + .provider + .get_token_provider() + .get_token_restriction(&state.db, restriction_id, true) + .await? + .ok_or(KeystoneApiError::InternalError( + "token restriction {restriction_id} not found".to_string(), + ))?; + if !restriction.allow_rescope && req.auth.scope.is_some() { + return Err(KeystoneApiError::AuthenticationRescopeForbidden); + } + } - let mut token = state - .provider - .get_token_provider() - .issue_token(authed_info, authz_info)?; + let mut token = + state + .provider + .get_token_provider() + .issue_token(authed_info, authz_info, None)?; token = state .provider @@ -839,7 +853,7 @@ mod tests { .withf(|_: &DatabaseConnection, id: &'_ str| id == "pdid") .returning(move |_, _| Ok(Some(project_domain.clone()))); let mut token_mock = MockTokenProvider::default(); - token_mock.expect_issue_token().returning(|_, _| { + token_mock.expect_issue_token().returning(|_, _, _| { Ok(ProviderToken::ProjectScope(ProjectScopePayload { user_id: "bar".into(), methods: Vec::from(["password".to_string()]), diff --git a/src/api/v4/auth/passkey/finish.rs b/src/api/v4/auth/passkey/finish.rs index 25f03cc7..93756b7b 100644 --- a/src/api/v4/auth/passkey/finish.rs +++ b/src/api/v4/auth/passkey/finish.rs @@ -105,10 +105,11 @@ pub(super) async fn finish( .map_err(AuthenticationError::from)?; authed_info.validate()?; - let token = state - .provider - .get_token_provider() - .issue_token(authed_info, AuthzInfo::Unscoped)?; + let token = + state + .provider + .get_token_provider() + .issue_token(authed_info, AuthzInfo::Unscoped, None)?; let api_token = TokenResponse { token: ApiResponseToken::from_provider_token(&state, &token).await?, diff --git a/src/api/v4/auth/token/common.rs b/src/api/v4/auth/token/common.rs index f31d07c6..61bb5dfd 100644 --- a/src/api/v4/auth/token/common.rs +++ b/src/api/v4/auth/token/common.rs @@ -136,6 +136,22 @@ impl Token { ); } } + ProviderToken::Restricted(token) => { + if project.is_none() { + project = Some( + state + .provider + .get_resource_provider() + .get_project(&state.db, &token.project_id) + .await + .map_err(KeystoneApiError::resource)? + .ok_or_else(|| KeystoneApiError::NotFound { + resource: "project".into(), + identifier: token.project_id.clone(), + })?, + ); + } + } } if let Some(domain) = domain { diff --git a/src/api/v4/auth/token/mod.rs b/src/api/v4/auth/token/mod.rs index f587b9b2..4776ba1b 100644 --- a/src/api/v4/auth/token/mod.rs +++ b/src/api/v4/auth/token/mod.rs @@ -693,7 +693,7 @@ mod tests { .withf(|_: &DatabaseConnection, id: &'_ str| id == "pdid") .returning(move |_, _| Ok(Some(project_domain.clone()))); let mut token_mock = MockTokenProvider::default(); - token_mock.expect_issue_token().returning(|_, _| { + token_mock.expect_issue_token().returning(|_, _, _| { Ok(ProviderToken::ProjectScope(ProjectScopePayload { user_id: "bar".into(), methods: Vec::from(["password".to_string()]), diff --git a/src/api/v4/federation/common.rs b/src/api/v4/federation/common.rs index c644b387..c7736613 100644 --- a/src/api/v4/federation/common.rs +++ b/src/api/v4/federation/common.rs @@ -26,7 +26,6 @@ use crate::federation::types::{ Scope as ProviderScope, identity_provider::IdentityProvider as ProviderIdentityProvider, mapping::Mapping as ProviderMapping, }; -use crate::identity::IdentityApi; use crate::keystone::ServiceState; /// Convert ProviderScope to AuthZ information @@ -140,41 +139,41 @@ pub(super) fn validate_bound_claims( /// # Returns /// The mapped user data pub(super) async fn map_user_data( - state: &ServiceState, + _state: &ServiceState, idp: &ProviderIdentityProvider, mapping: &ProviderMapping, claims_as_json: &Value, ) -> Result { let mut builder = MappedUserDataBuilder::default(); - if let Some(token_user_id) = &mapping.token_user_id { - // TODO: How to check that the user belongs to the right domain) - if let Ok(Some(user)) = state - .provider - .get_identity_provider() - .get_user(&state.db, token_user_id) - .await - { - builder.unique_id(token_user_id.clone()); - builder.user_name(user.name.clone()); - } else { - return Err(OidcError::UserNotFound(token_user_id.clone()))?; - } - } else { - builder.unique_id( - claims_as_json - .get(&mapping.user_id_claim) - .and_then(|x| x.as_str()) - .ok_or_else(|| OidcError::UserIdClaimRequired(mapping.user_id_claim.clone()))? - .to_string(), - ); + //if let Some(token_user_id) = &mapping.token_user_id { + // // TODO: How to check that the user belongs to the right domain) + // if let Ok(Some(user)) = state + // .provider + // .get_identity_provider() + // .get_user(&state.db, token_user_id) + // .await + // { + // builder.unique_id(token_user_id.clone()); + // builder.user_name(user.name.clone()); + // } else { + // return Err(OidcError::UserNotFound(token_user_id.clone()))?; + // } + //} else { + builder.unique_id( + claims_as_json + .get(&mapping.user_id_claim) + .and_then(|x| x.as_str()) + .ok_or_else(|| OidcError::UserIdClaimRequired(mapping.user_id_claim.clone()))? + .to_string(), + ); - builder.user_name( - claims_as_json - .get(&mapping.user_name_claim) - .and_then(|x| x.as_str()) - .ok_or_else(|| OidcError::UserNameClaimRequired(mapping.user_name_claim.clone()))?, - ); - } + builder.user_name( + claims_as_json + .get(&mapping.user_name_claim) + .and_then(|x| x.as_str()) + .ok_or_else(|| OidcError::UserNameClaimRequired(mapping.user_name_claim.clone()))?, + ); + //} builder.domain_id( mapping diff --git a/src/api/v4/federation/jwt.rs b/src/api/v4/federation/jwt.rs index 814d5525..3704d5bd 100644 --- a/src/api/v4/federation/jwt.rs +++ b/src/api/v4/federation/jwt.rs @@ -161,6 +161,17 @@ pub async fn login( })? .to_owned(); + tracing::debug!("Mapping is {:?}", mapping); + let token_restriction = if let Some(tr_id) = &mapping.token_restriction_id { + state + .provider + .get_token_provider() + .get_token_restriction(&state.db, tr_id, true) + .await? + } else { + None + }; + //if !matches!(mapping.r#type, ProviderMappingType::Jwt) { // // need to log helping message, since the error is wrapped // // to prevent existence exposure. @@ -294,10 +305,11 @@ pub async fn login( ) .await?; - let mut token = state - .provider - .get_token_provider() - .issue_token(authed_info, authz_info)?; + let mut token = state.provider.get_token_provider().issue_token( + authed_info, + authz_info, + token_restriction.as_ref(), + )?; // TODO: roles should be granted for the jwt login already diff --git a/src/api/v4/federation/mapping/list.rs b/src/api/v4/federation/mapping/list.rs index 3021afc9..c02d70dd 100644 --- a/src/api/v4/federation/mapping/list.rs +++ b/src/api/v4/federation/mapping/list.rs @@ -153,9 +153,8 @@ mod tests { bound_subject: None, bound_claims: None, oidc_scopes: None, - token_user_id: None, - token_role_ids: None, - token_project_id: None + token_project_id: None, + token_restriction_id: None }], res.mappings ); @@ -193,9 +192,8 @@ mod tests { bound_subject: None, bound_claims: None, oidc_scopes: None, - token_user_id: None, - token_role_ids: None, token_project_id: None, + token_restriction_id: None, }]) }); diff --git a/src/api/v4/federation/mapping/show.rs b/src/api/v4/federation/mapping/show.rs index 27981d8b..3bea05ec 100644 --- a/src/api/v4/federation/mapping/show.rs +++ b/src/api/v4/federation/mapping/show.rs @@ -176,9 +176,8 @@ mod tests { bound_subject: None, bound_claims: None, oidc_scopes: None, - token_user_id: None, - token_role_ids: None, token_project_id: None, + token_restriction_id: None, }, res.mapping, ); diff --git a/src/api/v4/federation/oidc.rs b/src/api/v4/federation/oidc.rs index aabe8fd2..3a3551d2 100644 --- a/src/api/v4/federation/oidc.rs +++ b/src/api/v4/federation/oidc.rs @@ -119,6 +119,16 @@ pub async fn callback( }) })??; + let token_restrictions = if let Some(tr_id) = &mapping.token_restriction_id { + state + .provider + .get_token_provider() + .get_token_restriction(&state.db, tr_id, true) + .await? + } else { + None + }; + let http_client = reqwest::ClientBuilder::new() // Following redirects opens the client up to SSRF vulnerabilities. .redirect(reqwest::redirect::Policy::none()) @@ -288,10 +298,11 @@ pub async fn callback( let authz_info = get_authz_info(&state, auth_state.scope.as_ref()).await?; trace!("Granting the scope: {:?}", authz_info); - let mut token = state - .provider - .get_token_provider() - .issue_token(authed_info, authz_info)?; + let mut token = state.provider.get_token_provider().issue_token( + authed_info, + authz_info, + token_restrictions.as_ref(), + )?; token = state .provider diff --git a/src/api/v4/federation/types/mapping.rs b/src/api/v4/federation/types/mapping.rs index 8ee16c1c..4ab007bb 100644 --- a/src/api/v4/federation/types/mapping.rs +++ b/src/api/v4/federation/types/mapping.rs @@ -94,20 +94,15 @@ pub struct Mapping { #[serde(skip_serializing_if = "Option::is_none")] pub oidc_scopes: Option>, - /// Fixed user_id for which the keystone token would be issued. - #[builder(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub token_user_id: Option, - - /// List of fixed roles that would be included in the token. + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub token_role_ids: Option>, + pub token_project_id: Option, - /// Fixed project_id for the token. + /// Token restrictions to be applied to the granted token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub token_project_id: Option, + pub token_restriction_id: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] @@ -194,23 +189,16 @@ pub struct MappingCreate { #[schema(nullable = false)] pub oidc_scopes: Option>, - /// Fixed user_id for which the keystone token would be issued. - #[builder(default)] - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub token_user_id: Option, - - /// List of fixed roles that would be included in the token. + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - pub token_role_ids: Option>, + pub token_project_id: Option, - /// Fixed project_id for the token. + /// Token restrictions to be applied to the granted token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - pub token_project_id: Option, + pub token_restriction_id: Option, } /// OIDC/JWT attribute mapping update data. @@ -286,20 +274,15 @@ pub struct MappingUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub oidc_scopes: Option>>, - /// Fixed user_id for which the keystone token would be issued. - #[builder(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub token_user_id: Option>, - - /// List of fixed roles that would be included in the token. + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub token_role_ids: Option>>, + pub token_project_id: Option>, - /// Fixed project_id for the token. + /// Token restrictions to be applied to the granted token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] - pub token_project_id: Option>, + pub token_restriction_id: Option, } /// OIDC/JWT attribute mapping create request. @@ -335,9 +318,8 @@ impl From for Mapping { bound_subject: value.bound_subject, bound_claims: value.bound_claims, oidc_scopes: value.oidc_scopes, - token_user_id: value.token_user_id, - token_role_ids: value.token_role_ids, token_project_id: value.token_project_id, + token_restriction_id: value.token_restriction_id, } } } @@ -359,9 +341,8 @@ impl From for types::Mapping { bound_subject: value.mapping.bound_subject, bound_claims: value.mapping.bound_claims, oidc_scopes: value.mapping.oidc_scopes, - token_user_id: value.mapping.token_user_id, - token_role_ids: value.mapping.token_role_ids, token_project_id: value.mapping.token_project_id, + token_restriction_id: value.mapping.token_restriction_id, } } } @@ -381,9 +362,8 @@ impl From for types::MappingUpdate { bound_subject: value.mapping.bound_subject, bound_claims: value.mapping.bound_claims, oidc_scopes: value.mapping.oidc_scopes, - token_user_id: value.mapping.token_user_id, - token_role_ids: value.mapping.token_role_ids, token_project_id: value.mapping.token_project_id, + token_restriction_id: value.mapping.token_restriction_id, } } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index e36c89fd..3ccbac66 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -43,42 +43,50 @@ pub enum AuthenticationError { /// User is disabled #[error("The account is disabled for user: {0}")] UserDisabled(String), + + /// Token renewal is forbidden + #[error("Token renewal (getting token from token) is prohibited.")] + TokenRenewalForbidden, } /// Information about successful authentication #[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[builder(setter(into, strip_option))] pub struct AuthenticatedInfo { - /// User id + /// User id. pub user_id: String, - /// Resolved user object + /// Resolved user object. #[builder(default)] pub user: Option, - /// Resolved user domain information + /// Resolved user domain information. #[builder(default)] pub user_domain: Option, - /// Resolved user object + /// Resolved user object. #[builder(default)] pub user_groups: Vec, - /// Authentication methods + /// Authentication methods. #[builder(default)] pub methods: Vec, - /// Audit IDs + /// Audit IDs. #[builder(default)] pub audit_ids: Vec, - /// Federated IDP id + /// Federated IDP id. #[builder(default)] pub idp_id: Option, - /// Federated protocol id + /// Federated protocol id. #[builder(default)] pub protocol_id: Option, + + /// Token restriction. + #[builder(default)] + pub token_restriction_id: Option, } impl AuthenticatedInfo { diff --git a/src/db/entity.rs b/src/db/entity.rs index b141d3f9..b2f7f186 100644 --- a/src/db/entity.rs +++ b/src/db/entity.rs @@ -63,6 +63,8 @@ pub mod service; pub mod service_provider; pub mod system_assignment; pub mod token; +pub mod token_restriction; +pub mod token_restriction_role_association; pub mod trust; pub mod trust_role; pub mod user; diff --git a/src/db/entity/federated_mapping.rs b/src/db/entity/federated_mapping.rs index 5b5f5aa8..0c9fd504 100644 --- a/src/db/entity/federated_mapping.rs +++ b/src/db/entity/federated_mapping.rs @@ -21,9 +21,8 @@ pub struct Model { pub bound_subject: Option, pub bound_claims: Option, pub oidc_scopes: Option, - pub token_user_id: Option, - pub token_role_ids: Option, pub token_project_id: Option, + pub token_restriction_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/db/entity/prelude.rs b/src/db/entity/prelude.rs index ab8af09a..42dcf44f 100644 --- a/src/db/entity/prelude.rs +++ b/src/db/entity/prelude.rs @@ -61,6 +61,8 @@ pub use super::service::Entity as Service; pub use super::service_provider::Entity as ServiceProvider; pub use super::system_assignment::Entity as SystemAssignment; pub use super::token::Entity as Token; +pub use super::token_restriction::Entity as TokenRestriction; +pub use super::token_restriction_role_association::Entity as TokenRestrictionRoleAssociation; pub use super::trust::Entity as Trust; pub use super::trust_role::Entity as TrustRole; pub use super::user::Entity as User; diff --git a/src/db/entity/token_restriction.rs b/src/db/entity/token_restriction.rs new file mode 100644 index 00000000..cc6d54ae --- /dev/null +++ b/src/db/entity/token_restriction.rs @@ -0,0 +1,69 @@ +// 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 +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.16 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "token_restriction")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub user_id: Option, + pub allow_renew: bool, + pub allow_rescope: bool, + pub project_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::project::Entity", + from = "Column::ProjectId", + to = "super::project::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Project, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + User, + #[sea_orm(has_many = "super::token_restriction_role_association::Entity")] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Project.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db/entity/token_restriction_role_association.rs b/src/db/entity/token_restriction_role_association.rs new file mode 100644 index 00000000..0996c82a --- /dev/null +++ b/src/db/entity/token_restriction_role_association.rs @@ -0,0 +1,60 @@ +// 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 +// +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.16 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "token_restriction_role_association")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub restriction_id: String, + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Role, + #[sea_orm( + belongs_to = "super::token_restriction::Entity", + from = "Column::RestrictionId", + to = "super::token_restriction::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + TokenRestriction, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TokenRestriction.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/db_migration/m20250414_000001_idp.rs b/src/db_migration/m20250414_000001_idp.rs index 362d8b25..2e7fb9a6 100644 --- a/src/db_migration/m20250414_000001_idp.rs +++ b/src/db_migration/m20250414_000001_idp.rs @@ -275,6 +275,7 @@ enum FederatedMapping { TokenRoleIds, TokenProjectId, } + #[derive(DeriveIden)] enum FederatedMappingType { FederatedMappingType, diff --git a/src/db_migration/m20251005_131042_token_restriction.rs b/src/db_migration/m20251005_131042_token_restriction.rs new file mode 100644 index 00000000..572ff2c2 --- /dev/null +++ b/src/db_migration/m20251005_131042_token_restriction.rs @@ -0,0 +1,154 @@ +// 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 sea_orm_migration::{prelude::*, schema::*}; + +use crate::db::entity::prelude::{FederatedMapping, Project, Role, User}; +use crate::db::entity::{project, role, user}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(TokenRestriction::Table) + .if_not_exists() + .col(string_len(TokenRestriction::Id, 64).primary_key()) + .col(string_len_null(TokenRestriction::UserId, 64)) + .col(boolean(TokenRestriction::AllowRenew)) + .col(boolean(TokenRestriction::AllowRescope)) + .col(string_len_null(TokenRestriction::ProjectId, 64)) + .foreign_key( + ForeignKey::create() + .name("fk-token-restriction-user") + .from(TokenRestriction::Table, TokenRestriction::UserId) + .to(User, user::Column::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-token-restriction-project") + .from(TokenRestriction::Table, TokenRestriction::ProjectId) + .to(Project, project::Column::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .create_table( + Table::create() + .table(TokenRestrictionRoleAssociation::Table) + .if_not_exists() + .col(string_len( + TokenRestrictionRoleAssociation::RestrictionId, + 64, + )) + .col(string_len(TokenRestrictionRoleAssociation::RoleId, 64)) + .primary_key( + Index::create() + .name("fk-token-restriction-role-association-pk") + .col(TokenRestrictionRoleAssociation::RestrictionId) + .col(TokenRestrictionRoleAssociation::RoleId), + ) + .foreign_key( + ForeignKey::create() + .name("fk-token-restriction-role-association-restriction") + .from( + TokenRestrictionRoleAssociation::Table, + TokenRestrictionRoleAssociation::RestrictionId, + ) + .to(TokenRestriction::Table, TokenRestriction::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-token-restriction-role-association-role") + .from( + TokenRestrictionRoleAssociation::Table, + TokenRestrictionRoleAssociation::RoleId, + ) + .to(Role, role::Column::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .alter_table( + Table::alter() + .table(FederatedMapping) + .add_column(ColumnDef::new("token_restriction_id").string_len(64)) + .drop_column("token_user_id") + .drop_column("token_role_ids") + .add_foreign_key( + &TableForeignKey::new() + .name("fk-federated-mapping-token-restriction") + .from_tbl(FederatedMapping) + .from_col("token_restriction_id") + .to_tbl(TokenRestriction::Table) + .to_col(TokenRestriction::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(FederatedMapping) + .drop_column("token_restriction_id") + .add_column(ColumnDef::new("token_restriction_id").string_len(64)) + .add_column(ColumnDef::new("token_role_ids").string_len(128)) + .to_owned(), + ) + .await?; + manager + .drop_table( + Table::drop() + .table(TokenRestrictionRoleAssociation::Table) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(TokenRestriction::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum TokenRestriction { + Table, + Id, + UserId, + AllowRenew, + AllowRescope, + ProjectId, +} + +#[derive(DeriveIden)] +enum TokenRestrictionRoleAssociation { + Table, + RestrictionId, + RoleId, +} diff --git a/src/db_migration/mod.rs b/src/db_migration/mod.rs index 6999e3c2..89fbf3c4 100644 --- a/src/db_migration/mod.rs +++ b/src/db_migration/mod.rs @@ -16,6 +16,7 @@ pub use sea_orm_migration::prelude::*; mod m20250301_000001_passkey; mod m20250414_000001_idp; +mod m20251005_131042_token_restriction; pub struct Migrator; @@ -25,6 +26,7 @@ impl MigratorTrait for Migrator { vec![ Box::new(m20250301_000001_passkey::Migration), Box::new(m20250414_000001_idp::Migration), + Box::new(m20251005_131042_token_restriction::Migration), ] } } diff --git a/src/federation/backends/sql/mapping.rs b/src/federation/backends/sql/mapping.rs index 82daa60f..f6dd40b6 100644 --- a/src/federation/backends/sql/mapping.rs +++ b/src/federation/backends/sql/mapping.rs @@ -92,17 +92,12 @@ impl TryFrom for Mapping { builder.oidc_scopes(Vec::from_iter(val.split(",").map(Into::into))); } } - if let Some(val) = &value.token_user_id { - builder.token_user_id(val.clone()); - } - if let Some(val) = &value.token_role_ids { - if !val.is_empty() { - builder.token_role_ids(Vec::from_iter(val.split(",").map(Into::into))); - } - } if let Some(val) = &value.token_project_id { builder.token_project_id(val.clone()); } + if let Some(val) = &value.token_restriction_id { + builder.token_restriction_id(val.clone()); + } Ok(builder.build()?) } } @@ -130,9 +125,8 @@ mod tests { bound_subject: None, bound_claims: None, oidc_scopes: None, - token_user_id: None, - token_role_ids: None, token_project_id: None, + token_restriction_id: None, } } } diff --git a/src/federation/backends/sql/mapping/create.rs b/src/federation/backends/sql/mapping/create.rs index 7ce8879f..193774b0 100644 --- a/src/federation/backends/sql/mapping/create.rs +++ b/src/federation/backends/sql/mapping/create.rs @@ -74,20 +74,14 @@ pub async fn create( .map(|x| Set(x.join(","))) .unwrap_or(NotSet) .into(), - token_user_id: mapping - .token_user_id + token_project_id: mapping + .token_project_id .clone() .map(Set) .unwrap_or(NotSet) .into(), - token_role_ids: mapping - .token_role_ids - .clone() - .map(|x| Set(x.join(","))) - .unwrap_or(NotSet) - .into(), - token_project_id: mapping - .token_project_id + token_restriction_id: mapping + .token_restriction_id .clone() .map(Set) .unwrap_or(NotSet) @@ -132,9 +126,8 @@ mod tests { bound_claims: Some(json!({"department": "foo"})), //claim_mappings: Some(json!({"foo": "bar"})), oidc_scopes: Some(vec!["oidc".into(), "oauth".into()]), - token_user_id: Some("uid".into()), - token_role_ids: Some(vec!["r1".into(), "r2".into()]), token_project_id: Some("pid".into()), + token_restriction_id: Some("trid".into()), }; assert_eq!( @@ -145,7 +138,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"INSERT INTO "federated_mapping" ("id", "name", "idp_id", "domain_id", "type", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id") VALUES ($1, $2, $3, $4, CAST($5 AS "federated_mapping_type"), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, + r#"INSERT INTO "federated_mapping" ("id", "name", "idp_id", "domain_id", "type", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_project_id", "token_restriction_id") VALUES ($1, $2, $3, $4, CAST($5 AS "federated_mapping_type"), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_project_id", "token_restriction_id""#, [ "1".into(), "mapping".into(), @@ -161,9 +154,8 @@ mod tests { "subject".into(), json!({"department": "foo"}).into(), "oidc,oauth".into(), - "uid".into(), - "r1,r2".into(), "pid".into(), + "trid".into(), ] ),] ); diff --git a/src/federation/backends/sql/mapping/get.rs b/src/federation/backends/sql/mapping/get.rs index 94207334..751b2642 100644 --- a/src/federation/backends/sql/mapping/get.rs +++ b/src/federation/backends/sql/mapping/get.rs @@ -66,7 +66,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_project_id", "federated_mapping"."token_restriction_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ),] ); diff --git a/src/federation/backends/sql/mapping/list.rs b/src/federation/backends/sql/mapping/list.rs index 87685d04..3be507a7 100644 --- a/src/federation/backends/sql/mapping/list.rs +++ b/src/federation/backends/sql/mapping/list.rs @@ -107,12 +107,12 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping""#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_project_id", "federated_mapping"."token_restriction_id" FROM "federated_mapping""#, [] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."name" = $1 AND "federated_mapping"."domain_id" = $2 AND "federated_mapping"."idp_id" = $3 AND "federated_mapping"."type" = (CAST($4 AS "federated_mapping_type"))"#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_project_id", "federated_mapping"."token_restriction_id" FROM "federated_mapping" WHERE "federated_mapping"."name" = $1 AND "federated_mapping"."domain_id" = $2 AND "federated_mapping"."idp_id" = $3 AND "federated_mapping"."type" = (CAST($4 AS "federated_mapping_type"))"#, [ "mapping_name".into(), "did".into(), diff --git a/src/federation/backends/sql/mapping/update.rs b/src/federation/backends/sql/mapping/update.rs index b31a6ba4..5dd44710 100644 --- a/src/federation/backends/sql/mapping/update.rs +++ b/src/federation/backends/sql/mapping/update.rs @@ -66,15 +66,12 @@ pub async fn update>( if let Some(val) = mapping.oidc_scopes { entry.oidc_scopes = Set(val.clone().map(|x| x.join(","))); } - if let Some(val) = mapping.token_user_id { - entry.token_user_id = Set(val.to_owned()); - } - if let Some(val) = mapping.token_role_ids { - entry.token_role_ids = Set(val.clone().map(|x| x.join(","))); - } if let Some(val) = mapping.token_project_id { entry.token_project_id = Set(val.to_owned()); } + if let Some(val) = mapping.token_restriction_id { + entry.token_restriction_id = Set(Some(val.to_owned())); + } let db_entry: db_federated_mapping::Model = entry.update(db).await?; db_entry.try_into() @@ -120,9 +117,8 @@ mod tests { bound_claims: Some(json!({"department": "foo"})), //claim_mappings: Some(json!({"foo": "bar"})), oidc_scopes: Some(Some(vec!["oidc".into(), "oauth".into()])), - token_user_id: Some(Some("uid".into())), - token_role_ids: Some(Some(vec!["r1".into(), "r2".into()])), token_project_id: Some(Some("pid".into())), + token_restriction_id: Some("trid".into()), }; assert_eq!( @@ -134,12 +130,12 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_project_id", "federated_mapping"."token_restriction_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"UPDATE "federated_mapping" SET "name" = $1, "idp_id" = $2, "type" = CAST($3 AS "federated_mapping_type"), "allowed_redirect_uris" = $4, "user_id_claim" = $5, "user_name_claim" = $6, "domain_id_claim" = $7, "groups_claim" = $8, "bound_audiences" = $9, "bound_subject" = $10, "bound_claims" = $11, "oidc_scopes" = $12, "token_user_id" = $13, "token_role_ids" = $14, "token_project_id" = $15 WHERE "federated_mapping"."id" = $16 RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, + r#"UPDATE "federated_mapping" SET "name" = $1, "idp_id" = $2, "type" = CAST($3 AS "federated_mapping_type"), "allowed_redirect_uris" = $4, "user_id_claim" = $5, "user_name_claim" = $6, "domain_id_claim" = $7, "groups_claim" = $8, "bound_audiences" = $9, "bound_subject" = $10, "bound_claims" = $11, "oidc_scopes" = $12, "token_project_id" = $13, "token_restriction_id" = $14 WHERE "federated_mapping"."id" = $15 RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_project_id", "token_restriction_id""#, [ "name".into(), "idp".into(), @@ -153,9 +149,8 @@ mod tests { "subject".into(), json!({"department": "foo"}).into(), "oidc,oauth".into(), - "uid".into(), - "r1,r2".into(), "pid".into(), + "trid".into(), "1".into() ] ), diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 4d4134ff..b24423e1 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -340,14 +340,6 @@ impl FederationApi for FederationProvider { ) -> Result { let mut mod_mapping = mapping; mod_mapping.id = Uuid::new_v4().into(); - if let Some(_uid) = &mod_mapping.token_user_id { - // ensure domain_id is set and matches the user_id. - if let Some(_did) = &mod_mapping.domain_id { - // TODO: Get the user_id and compare the domain_id - } else { - return Err(FederationProviderError::MappingTokenUserDomainUnset); - } - } if let Some(_pid) = &mod_mapping.token_project_id { // ensure domain_id is set and matches the one of the project_id. if let Some(_did) = &mod_mapping.domain_id { @@ -379,14 +371,6 @@ impl FederationApi for FederationProvider { // TODO: Check the new idp_id domain escaping } - if let Some(_uid) = &mapping.token_user_id { - // ensure domain_id is set and matches the user_id. - if let Some(_did) = ¤t.domain_id { - // TODO: Get the user_id and compare the domain_id - } else { - return Err(FederationProviderError::MappingTokenUserDomainUnset); - } - } if let Some(_pid) = &mapping.token_project_id { // ensure domain_id is set and matches the one of the project_id. if let Some(_did) = ¤t.domain_id { diff --git a/src/federation/types/mapping.rs b/src/federation/types/mapping.rs index 391683ee..9ffddfe1 100644 --- a/src/federation/types/mapping.rs +++ b/src/federation/types/mapping.rs @@ -74,17 +74,13 @@ pub struct Mapping { //#[builder(default)] //pub claim_mappings: Option, - /// Fixed `user_id` of the token to issue for successful authentication. - #[builder(default)] - pub token_user_id: Option, - - /// List of fixed role ids of the token to issue for successful authentication. - #[builder(default)] - pub token_role_ids: Option>, - /// Fixed `project_id` scope of the token to issue for successful authentication. #[builder(default)] pub token_project_id: Option, + + /// ID of the token restrictions. + #[builder(default)] + pub token_restriction_id: Option, } /// Update attribute mapping data. @@ -138,17 +134,13 @@ pub struct MappingUpdate { #[builder(default)] pub oidc_scopes: Option>>, - /// Fixed `user_id` of the token to issue for successful authentication. - #[builder(default)] - pub token_user_id: Option>, - - /// List of fixed role ids of the token to issue for successful authentication. - #[builder(default)] - pub token_role_ids: Option>>, - /// Fixed `project_id` scope of the token to issue for successful authentication. #[builder(default)] pub token_project_id: Option>, + + /// ID of the token restrictions. + #[builder(default)] + pub token_restriction_id: Option, } /// Attribute mapping type. diff --git a/src/token/error.rs b/src/token/error.rs index bf38b3b9..f8c19e8b 100644 --- a/src/token/error.rs +++ b/src/token/error.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use sea_orm::SqlErr; use std::num::TryFromIntError; use thiserror::Error; @@ -147,6 +148,13 @@ pub enum TokenProviderError { source: crate::token::federation_domain_scoped::FederationDomainScopePayloadBuilderError, }, + #[error(transparent)] + RestrictedBuilder { + /// The source of the error. + #[from] + source: crate::token::restricted::RestrictedPayloadBuilderError, + }, + #[error(transparent)] AssignmentProvider { /// The source of the error. @@ -185,4 +193,48 @@ pub enum TokenProviderError { #[error("unsupported authentication methods in token payload")] UnsupportedAuthMethods, + + #[error("token with restrictions can be only project scoped")] + RestrictedTokenNotProjectScoped, + + #[error("token restriction {0} not found")] + TokenRestrictionNotFound(String), + + /// Conflict + #[error("{message}")] + Conflict { message: String, context: String }, + + /// SqlError + #[error("{message}")] + Sql { message: String, context: String }, + + #[error("Database error while {context}")] + Database { + source: sea_orm::DbErr, + context: String, + }, +} + +/// Convert the DB error into the TokenProviderError with the context information. +pub fn db_err(e: sea_orm::DbErr, context: &str) -> TokenProviderError { + e.sql_err().map_or_else( + || TokenProviderError::Database { + source: e, + context: context.to_string(), + }, + |err| match err { + SqlErr::UniqueConstraintViolation(descr) => TokenProviderError::Conflict { + message: descr.to_string(), + context: context.to_string(), + }, + SqlErr::ForeignKeyConstraintViolation(descr) => TokenProviderError::Conflict { + message: descr.to_string(), + context: context.to_string(), + }, + other => TokenProviderError::Sql { + message: other.to_string(), + context: context.to_string(), + }, + }, + ) } diff --git a/src/token/fernet.rs b/src/token/fernet.rs index 2154d9c3..b300416e 100644 --- a/src/token/fernet.rs +++ b/src/token/fernet.rs @@ -30,7 +30,8 @@ use crate::token::{ domain_scoped::DomainScopePayload, federation_domain_scoped::FederationDomainScopePayload, federation_project_scoped::FederationProjectScopePayload, federation_unscoped::FederationUnscopedPayload, fernet_utils::FernetUtils, - project_scoped::ProjectScopePayload, types::*, unscoped::UnscopedPayload, + project_scoped::ProjectScopePayload, restricted::RestrictedPayload, types::*, + unscoped::UnscopedPayload, }; #[derive(Default, Clone)] @@ -130,6 +131,7 @@ impl FernetTokenProvider { 5 => Ok(FederationProjectScopePayload::disassemble(rd, &self.auth_map)?.into()), 6 => Ok(FederationDomainScopePayload::disassemble(rd, &self.auth_map)?.into()), 9 => Ok(ApplicationCredentialPayload::disassemble(rd, &self.auth_map)?.into()), + 11 => Ok(RestrictedPayload::disassemble(rd, &self.auth_map)?.into()), other => Err(TokenProviderError::InvalidTokenType(other)), } } else { @@ -190,6 +192,13 @@ impl FernetTokenProvider { .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; data.assemble(&mut buf, &self.auth_map)?; } + Token::Restricted(data) => { + write_array_len(&mut buf, 9) + .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; + write_pfix(&mut buf, 11) + .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; + data.assemble(&mut buf, &self.auth_map)?; + } } Ok(buf.into()) } @@ -631,4 +640,28 @@ pub(super) mod tests { let dec_token = backend.decrypt(&encrypted).unwrap(); assert_eq!(token, dec_token); } + + #[tokio::test] + async fn test_restricted_roundtrip() { + let token = Token::Restricted(RestrictedPayload { + user_id: Uuid::new_v4().simple().to_string(), + methods: vec!["password".into()], + token_restriction_id: Uuid::new_v4().simple().to_string(), + project_id: Uuid::new_v4().simple().to_string(), + allow_renew: true, + allow_rescope: true, + audit_ids: vec!["Zm9vCg".into()], + expires_at: Local::now().trunc_subsecs(0).into(), + ..Default::default() + }); + + let mut backend = FernetTokenProvider::default(); + let config = crate::tests::token::setup_config(); + backend.set_config(config); + backend.load_keys().unwrap(); + + let encrypted = backend.encrypt(&token).unwrap(); + let dec_token = backend.decrypt(&encrypted).unwrap(); + assert_eq!(token, dec_token); + } } diff --git a/src/token/fernet_utils.rs b/src/token/fernet_utils.rs index 8c3777d8..1bf3717a 100644 --- a/src/token/fernet_utils.rs +++ b/src/token/fernet_utils.rs @@ -16,7 +16,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use chrono::{DateTime, Utc}; use rmp::{ Marker, - decode::*, + decode::{self, *}, encode::{self, *}, }; use std::collections::BTreeMap; @@ -276,6 +276,17 @@ pub fn write_list_of_uuids, V: AsRef Ok(()) } +/// Read boolean. +pub fn read_bool(rd: &mut R) -> Result { + Ok(decode::read_bool(rd)?) +} + +/// Write boolean. +pub fn write_bool(wd: &mut W, data: bool) -> Result<(), TokenProviderError> { + encode::write_bool(wd, data).map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::FernetUtils; @@ -363,4 +374,15 @@ mod tests { .collect(); assert_eq!(test, decoded); } + + #[test] + fn test_write_bool() { + let test = true; + let mut buf = Vec::with_capacity(1); + write_bool(&mut buf, test).unwrap(); + let msg = buf.clone(); + let mut decode_data = msg.as_slice(); + let decoded = read_bool(&mut decode_data).unwrap(); + assert_eq!(test, decoded); + } } diff --git a/src/token/mod.rs b/src/token/mod.rs index 348b71c7..47b75e02 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -29,6 +29,8 @@ pub mod federation_unscoped; pub mod fernet; pub mod fernet_utils; pub mod project_scoped; +pub mod restricted; +mod token_restriction; pub mod types; pub mod unscoped; @@ -58,7 +60,8 @@ pub use federation_project_scoped::{ }; pub use federation_unscoped::{FederationUnscopedPayload, FederationUnscopedPayloadBuilder}; pub use project_scoped::{ProjectScopePayload, ProjectScopePayloadBuilder}; -pub use types::Token; +pub use restricted::{RestrictedPayload, RestrictedPayloadBuilder}; +pub use types::{Token, TokenRestriction}; pub use unscoped::{UnscopedPayload, UnscopedPayloadBuilder}; #[derive(Clone, Debug)] @@ -261,6 +264,47 @@ impl TokenProvider { } } + /// Create token with the specified restrictions. + fn create_restricted_token( + &self, + authentication_info: &AuthenticatedInfo, + authz_info: &AuthzInfo, + restriction: &TokenRestriction, + ) -> Result { + Ok(Token::Restricted( + RestrictedPayloadBuilder::default() + .user_id( + restriction + .user_id + .as_ref() + .unwrap_or(&authentication_info.user_id.clone()), + ) + .user(authentication_info.user.clone()) + .methods(authentication_info.methods.clone().iter()) + .audit_ids(authentication_info.audit_ids.clone().iter()) + .expires_at( + Local::now() + .to_utc() + .checked_add_signed(TimeDelta::seconds(self.config.token.expiration as i64)) + .ok_or(TokenProviderError::ExpiryCalculation)?, + ) + .token_restriction_id(restriction.id.clone()) + .project_id( + restriction + .project_id + .as_ref() + .or(match authz_info { + AuthzInfo::Project(project) => Some(&project.id), + _ => None, + }) + .ok_or_else(|| TokenProviderError::RestrictedTokenNotProjectScoped)?, + ) + .allow_renew(restriction.allow_renew) + .allow_rescope(restriction.allow_rescope) + .roles(restriction.roles.clone()) + .build()?, + )) + } async fn expand_user_information( &self, token: &mut Token, @@ -294,6 +338,9 @@ impl TokenProvider { Token::FederationDomainScope(data) => { data.user = user; } + Token::Restricted(data) => { + data.user = user; + } } } Ok(()) @@ -322,6 +369,7 @@ pub trait TokenApi: Send + Sync + Clone { &self, authentication_info: AuthenticatedInfo, authz_info: AuthzInfo, + token_restriction: Option<&TokenRestriction>, ) -> Result; /// Encode the token into the X-SubjectToken String @@ -343,6 +391,14 @@ pub trait TokenApi: Send + Sync + Clone { db: &DatabaseConnection, provider: &Provider, ) -> Result; + + /// Get the token restriction by the ID. + async fn get_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + expand_roles: bool, + ) -> Result, TokenProviderError>; } #[async_trait] @@ -358,10 +414,20 @@ impl TokenApi for TokenProvider { let token = self .validate_token(credential, allow_expired, window_seconds) .await?; - Ok(AuthenticatedInfo::builder() - .user_id(token.user_id()) - .methods(token.methods().clone()) - .audit_ids(token.audit_ids().clone()) + tracing::debug!("The token is {:?}", token); + if let Token::Restricted(restriction) = &token { + if !restriction.allow_renew { + return Err(AuthenticationError::TokenRenewalForbidden)?; + } + } + let mut auth_info_builder = AuthenticatedInfo::builder(); + auth_info_builder.user_id(token.user_id()); + auth_info_builder.methods(token.methods().clone()); + auth_info_builder.audit_ids(token.audit_ids().clone()); + if let Token::Restricted(restriction) = &token { + auth_info_builder.token_restriction_id(restriction.token_restriction_id.clone()); + } + Ok(auth_info_builder .build() .map_err(AuthenticationError::from)?) } @@ -393,6 +459,7 @@ impl TokenApi for TokenProvider { &self, authentication_info: AuthenticatedInfo, authz_info: AuthzInfo, + token_restrictions: Option<&TokenRestriction>, ) -> Result { // This should be executed already, but let's better repeat it as last line of defence. // It is also necessary to call this before to stop before we start to resolve authz info. @@ -407,7 +474,10 @@ impl TokenApi for TokenProvider { .trim_end_matches('=') .to_string(), ); - if authentication_info.idp_id.is_some() && authentication_info.protocol_id.is_some() { + if let Some(token_restrictions) = &token_restrictions { + self.create_restricted_token(&authentication_info, &authz_info, token_restrictions) + } else if authentication_info.idp_id.is_some() && authentication_info.protocol_id.is_some() + { match &authz_info { AuthzInfo::Project(project) => { self.create_federated_project_scope_token(&authentication_info, project) @@ -581,6 +651,16 @@ impl TokenApi for TokenProvider { return Err(TokenProviderError::ActorHasNoRolesOnTarget); } } + Token::Restricted(data) => { + if data.roles.is_none() { + self.get_token_restriction(db, &data.token_restriction_id, true) + .await? + .inspect(|restrictions| data.roles = restrictions.roles.clone()) + .ok_or(TokenProviderError::TokenRestrictionNotFound( + data.token_restriction_id.clone(), + ))?; + } + } _ => {} } @@ -645,6 +725,16 @@ impl TokenApi for TokenProvider { data.domain = domain; } } + Token::Restricted(ref mut data) => { + if data.project.is_none() { + let project = provider + .get_resource_provider() + .get_project(db, &data.project_id) + .await?; + + data.project = project; + } + } _ => {} }; @@ -654,6 +744,16 @@ impl TokenApi for TokenProvider { .await?; Ok(new_token) } + + /// Get the token restriction by the ID. + async fn get_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + expand_roles: bool, + ) -> Result, TokenProviderError> { + token_restriction::get(db, id, expand_roles).await + } } #[cfg(test)] @@ -683,6 +783,7 @@ mock! { &self, authentication_info: AuthenticatedInfo, authz_info: AuthzInfo, + token_restriction: Option<&TokenRestriction> ) -> Result; fn encode_token(&self, token: &Token) -> Result; @@ -701,6 +802,13 @@ mock! { provider: &Provider, ) -> Result; + async fn get_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + expand_roles: bool, + ) -> Result, TokenProviderError>; + } impl Clone for TokenProvider { diff --git a/src/token/restricted.rs b/src/token/restricted.rs new file mode 100644 index 00000000..408a3ebb --- /dev/null +++ b/src/token/restricted.rs @@ -0,0 +1,175 @@ +// 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 chrono::{DateTime, Utc}; +use derive_builder::Builder; +use rmp::{decode::read_pfix, encode::write_pfix}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::io::Write; + +use crate::assignment::types::Role; +use crate::identity::types::UserResponse; +use crate::resource::types::Project; +use crate::token::{ + error::TokenProviderError, + fernet::{self, MsgPackToken}, + fernet_utils, + types::Token, +}; + +/// Restricted token payload +#[derive(Builder, Clone, Debug, Default, PartialEq, Serialize)] +#[builder(setter(into))] +pub struct RestrictedPayload { + /// User ID. + pub user_id: String, + /// Authentication methods used to obtain the token. + #[builder(default, setter(name = _methods))] + pub methods: Vec, + /// Token audit IDs. + #[builder(default, setter(name = _audit_ids))] + pub audit_ids: Vec, + /// Token expiration datetime in UTC. + pub expires_at: DateTime, + /// ID of the token restrictions. + pub token_restriction_id: String, + /// Project ID scope for the token. + pub project_id: String, + /// Whether the token can be renewed. + pub allow_renew: bool, + /// Whether the token can be rescoped. + pub allow_rescope: bool, + + #[builder(default)] + pub user: Option, + #[builder(default)] + pub roles: Option>, + #[builder(default)] + pub project: Option, +} + +impl RestrictedPayloadBuilder { + pub fn methods(&mut self, iter: I) -> &mut Self + where + I: Iterator, + V: Into, + { + self.methods + .get_or_insert_with(Vec::new) + .extend(iter.map(Into::into)); + self + } + + pub fn audit_ids(&mut self, iter: I) -> &mut Self + where + I: Iterator, + V: Into, + { + self.audit_ids + .get_or_insert_with(Vec::new) + .extend(iter.map(Into::into)); + self + } +} + +impl From for Token { + fn from(value: RestrictedPayload) -> Self { + Self::Restricted(value) + } +} + +impl MsgPackToken for RestrictedPayload { + type Token = Self; + + fn assemble( + &self, + wd: &mut W, + auth_map: &BTreeMap, + ) -> Result<(), TokenProviderError> { + fernet_utils::write_uuid(wd, &self.user_id)?; + write_pfix( + wd, + fernet::encode_auth_methods(self.methods.clone(), auth_map)? as u8, + ) + .map_err(|x| TokenProviderError::RmpEncode(x.to_string()))?; + fernet_utils::write_uuid(wd, &self.token_restriction_id)?; + fernet_utils::write_time(wd, self.expires_at)?; + fernet_utils::write_uuid(wd, &self.project_id)?; + fernet_utils::write_bool(wd, self.allow_renew)?; + fernet_utils::write_bool(wd, self.allow_rescope)?; + fernet_utils::write_audit_ids(wd, self.audit_ids.clone())?; + + Ok(()) + } + + fn disassemble( + rd: &mut &[u8], + auth_map: &BTreeMap, + ) -> Result { + // Order of reading is important + let user_id = fernet_utils::read_uuid(rd)?; + let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? + .into_iter() + .collect(); + let token_restriction_id = fernet_utils::read_uuid(rd)?; + let expires_at = fernet_utils::read_time(rd)?; + let project_id = fernet_utils::read_uuid(rd)?; + let allow_renew = fernet_utils::read_bool(rd)?; + let allow_rescope = fernet_utils::read_bool(rd)?; + let audit_ids: Vec = fernet_utils::read_audit_ids(rd)?.into_iter().collect(); + Ok(Self { + user_id, + methods, + expires_at, + audit_ids, + token_restriction_id, + project_id, + allow_renew, + allow_rescope, + + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use chrono::{Local, SubsecRound}; + use uuid::Uuid; + + use super::*; + + #[test] + fn test_roundtrip() { + let token = RestrictedPayload { + user_id: Uuid::new_v4().simple().to_string(), + methods: vec!["oidc".into()], + audit_ids: vec!["Zm9vCg".into()], + expires_at: Local::now().trunc_subsecs(0).into(), + token_restriction_id: "trid".into(), + project_id: "pid".into(), + allow_renew: true, + allow_rescope: true, + ..Default::default() + }; + let auth_map = BTreeMap::from([(1, "oidc".into())]); + let mut buf = vec![]; + token.assemble(&mut buf, &auth_map).unwrap(); + let encoded_buf = buf.clone(); + let decoded = + RestrictedPayload::disassemble(&mut encoded_buf.as_slice(), &auth_map).unwrap(); + assert_eq!(token, decoded); + } +} diff --git a/src/token/token_restriction/get.rs b/src/token/token_restriction/get.rs new file mode 100644 index 00000000..41f6a7bd --- /dev/null +++ b/src/token/token_restriction/get.rs @@ -0,0 +1,218 @@ +// 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 sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use crate::db::entity::prelude::{ + Role as DbRole, TokenRestriction as DbTokenRestriction, + TokenRestrictionRoleAssociation as DbTokenRestrictionRoleAssociation, +}; +use crate::db::entity::token_restriction_role_association; +use crate::token::error::{TokenProviderError, db_err}; +use crate::token::types::TokenRestriction; + +pub async fn get>( + db: &DatabaseConnection, + token_restriction_id: S, + expand_roles: bool, +) -> Result, TokenProviderError> { + let restriction: Option = if let Some(entry) = + DbTokenRestriction::find_by_id(token_restriction_id.as_ref()) + .one(db) + .await + .map_err(|err| db_err(err, "reading token restriction record"))? + { + if expand_roles { + let roles = DbTokenRestrictionRoleAssociation::find() + .filter( + token_restriction_role_association::Column::RestrictionId + .eq(token_restriction_id.as_ref()), + ) + .find_also_related(DbRole) + .all(db) + .await + .map_err(|err| db_err(err, "reading token restriction roles"))?; + Some((entry, roles).into()) + } else { + let roles = DbTokenRestrictionRoleAssociation::find() + .filter( + token_restriction_role_association::Column::RestrictionId + .eq(token_restriction_id.as_ref()), + ) + .all(db) + .await + .map_err(|err| db_err(err, "reading token restriction roles"))?; + Some((entry, roles).into()) + } + } else { + None + }; + Ok(restriction) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::derivable_impls)] + + use crate::assignment::types::Role; + use crate::db::entity::{role, token_restriction, token_restriction_role_association}; + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use super::*; + + fn get_restriction_mock>(id: S) -> token_restriction::Model { + token_restriction::Model { + id: id.as_ref().to_string(), + user_id: Some("uid".to_string()), + project_id: Some("pid".to_string()), + allow_rescope: true, + allow_renew: true, + } + } + + fn get_restriction_roles_mock>( + id: S, + ) -> Vec { + vec![ + token_restriction_role_association::Model { + restriction_id: id.as_ref().to_string(), + role_id: "rid1".to_string(), + }, + token_restriction_role_association::Model { + restriction_id: id.as_ref().to_string(), + role_id: "rid2".to_string(), + }, + ] + } + + fn get_restriction_roles_mock_expand>( + id: S, + ) -> Vec<(token_restriction_role_association::Model, role::Model)> { + vec![ + ( + token_restriction_role_association::Model { + restriction_id: id.as_ref().to_string(), + role_id: "rid1".to_string(), + }, + role::Model { + id: "rid1".to_string(), + name: "rid1_name".into(), + ..Default::default() + }, + ), + ( + token_restriction_role_association::Model { + restriction_id: id.as_ref().to_string(), + role_id: "rid2".to_string(), + }, + role::Model { + id: "rid2".to_string(), + name: "rid2_name".into(), + ..Default::default() + }, + ), + ] + } + + #[tokio::test] + async fn test_get() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_restriction_mock("id")]]) + .append_query_results([get_restriction_roles_mock("id")]) + .into_connection(); + + assert_eq!( + get(&db, "id", false).await.unwrap(), + Some(TokenRestriction { + id: "id".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_rescope: true, + allow_renew: true, + role_ids: vec!["rid1".into(), "rid2".into()], + roles: None, + }) + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction"."id", "token_restriction"."user_id", "token_restriction"."allow_renew", "token_restriction"."allow_rescope", "token_restriction"."project_id" FROM "token_restriction" WHERE "token_restriction"."id" = $1 LIMIT $2"#, + ["id".into(), 1u64.into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction_role_association"."restriction_id", "token_restriction_role_association"."role_id" FROM "token_restriction_role_association" WHERE "token_restriction_role_association"."restriction_id" = $1"#, + ["id".into()] + ), + ] + ); + } + + #[tokio::test] + async fn test_get_expand() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_restriction_mock("id")]]) + .append_query_results([get_restriction_roles_mock_expand("id")]) + .into_connection(); + + assert_eq!( + get(&db, "id", true).await.unwrap(), + Some(TokenRestriction { + id: "id".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_rescope: true, + allow_renew: true, + role_ids: vec!["rid1".into(), "rid2".into()], + roles: Some(vec![ + Role { + id: "rid1".into(), + name: "rid1_name".into(), + ..Default::default() + }, + Role { + id: "rid2".into(), + name: "rid2_name".into(), + ..Default::default() + }, + ]), + }) + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction"."id", "token_restriction"."user_id", "token_restriction"."allow_renew", "token_restriction"."allow_rescope", "token_restriction"."project_id" FROM "token_restriction" WHERE "token_restriction"."id" = $1 LIMIT $2"#, + ["id".into(), 1u64.into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction_role_association"."restriction_id" AS "A_restriction_id", "token_restriction_role_association"."role_id" AS "A_role_id", "role"."id" AS "B_id", "role"."name" AS "B_name", "role"."extra" AS "B_extra", "role"."domain_id" AS "B_domain_id", "role"."description" AS "B_description" FROM "token_restriction_role_association" LEFT JOIN "role" ON "token_restriction_role_association"."role_id" = "role"."id" WHERE "token_restriction_role_association"."restriction_id" = $1"#, + ["id".into()] + ), + ] + ); + } +} diff --git a/src/token/token_restriction/mod.rs b/src/token/token_restriction/mod.rs new file mode 100644 index 00000000..81247da0 --- /dev/null +++ b/src/token/token_restriction/mod.rs @@ -0,0 +1,88 @@ +// 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 crate::db::entity::{role, token_restriction, token_restriction_role_association}; + +use crate::token::types::TokenRestriction; + +mod get; + +pub use get::get; + +impl From for TokenRestriction { + fn from(value: token_restriction::Model) -> Self { + TokenRestriction { + id: value.id, + user_id: value.user_id, + project_id: value.project_id, + allow_rescope: value.allow_rescope, + allow_renew: value.allow_renew, + role_ids: Vec::new(), + roles: None, + } + } +} + +impl + From<( + token_restriction::Model, + Vec, + )> for TokenRestriction +{ + fn from( + value: ( + token_restriction::Model, + Vec, + ), + ) -> Self { + let mut restriction: TokenRestriction = value.0.into(); + restriction.role_ids = value.1.into_iter().map(|val| val.role_id).collect(); + restriction + } +} + +impl + From<( + token_restriction::Model, + Vec<( + token_restriction_role_association::Model, + Option, + )>, + )> for TokenRestriction +{ + fn from( + value: ( + token_restriction::Model, + Vec<( + token_restriction_role_association::Model, + Option, + )>, + ), + ) -> Self { + let mut restriction: TokenRestriction = value.0.into(); + let roles: Vec = value + .1 + .into_iter() + .filter_map(|(_a, r)| r) + .map(|role| crate::assignment::types::Role { + id: role.id.clone(), + name: role.name.clone(), + ..Default::default() + }) + .collect(); + restriction.role_ids = roles.iter().map(|role| role.id.clone()).collect(); + restriction.roles = Some(roles); + restriction + } +} diff --git a/src/token/types.rs b/src/token/types.rs index ae17a66b..cd151b9c 100644 --- a/src/token/types.rs +++ b/src/token/types.rs @@ -13,8 +13,9 @@ // SPDX-License-Identifier: Apache-2.0 use chrono::{DateTime, Utc}; +use derive_builder::Builder; use dyn_clone::DynClone; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::assignment::types::Role; use crate::config::Config; @@ -27,6 +28,7 @@ use crate::token::federation_domain_scoped::FederationDomainScopePayload; use crate::token::federation_project_scoped::FederationProjectScopePayload; use crate::token::federation_unscoped::FederationUnscopedPayload; use crate::token::project_scoped::ProjectScopePayload; +use crate::token::restricted::RestrictedPayload; use crate::token::unscoped::UnscopedPayload; #[derive(Clone, Debug, PartialEq, Serialize)] @@ -39,6 +41,7 @@ pub enum Token { FederationProjectScope(FederationProjectScopePayload), FederationDomainScope(FederationDomainScopePayload), ApplicationCredential(ApplicationCredentialPayload), + Restricted(RestrictedPayload), } impl Token { @@ -51,6 +54,7 @@ impl Token { Self::FederationProjectScope(x) => &x.user_id, Self::FederationDomainScope(x) => &x.user_id, Self::ApplicationCredential(x) => &x.user_id, + Self::Restricted(x) => &x.user_id, } } @@ -63,6 +67,7 @@ impl Token { Self::FederationProjectScope(x) => &x.user, Self::FederationDomainScope(x) => &x.user, Self::ApplicationCredential(x) => &x.user, + Self::Restricted(x) => &x.user, } } @@ -75,6 +80,7 @@ impl Token { Self::FederationProjectScope(x) => &x.expires_at, Self::FederationDomainScope(x) => &x.expires_at, Self::ApplicationCredential(x) => &x.expires_at, + Self::Restricted(x) => &x.expires_at, } } @@ -87,6 +93,7 @@ impl Token { Self::FederationProjectScope(x) => &x.methods, Self::FederationDomainScope(x) => &x.methods, Self::ApplicationCredential(x) => &x.methods, + Self::Restricted(x) => &x.methods, } } @@ -99,6 +106,7 @@ impl Token { Self::FederationProjectScope(x) => &x.audit_ids, Self::FederationDomainScope(x) => &x.audit_ids, Self::ApplicationCredential(x) => &x.audit_ids, + Self::Restricted(x) => &x.audit_ids, } } @@ -106,6 +114,7 @@ impl Token { match self { Self::ProjectScope(x) => x.project.as_ref(), Self::FederationProjectScope(x) => x.project.as_ref(), + Self::Restricted(x) => x.project.as_ref(), _ => None, } } @@ -124,11 +133,31 @@ impl Token { Self::ProjectScope(x) => x.roles.as_ref(), Self::FederationProjectScope(x) => x.roles.as_ref(), Self::FederationDomainScope(x) => x.roles.as_ref(), + Self::Restricted(x) => x.roles.as_ref(), _ => None, } } } +/// Token restriction information. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct TokenRestriction { + /// Whether the restriction allows to rescope the token. + pub allow_rescope: bool, + /// Whether it is allowed to renew the token with this restriction. + pub allow_renew: bool, + /// Id. + pub id: String, + /// Optional project ID to be used with this restriction. + pub project_id: Option, + /// Roles bound to the restriction. + pub role_ids: Vec, + /// Optional list of full Role information. + pub roles: Option>, + /// User id + pub user_id: Option, +} + pub trait TokenBackend: DynClone + Send + Sync + std::fmt::Debug { /// Set config fn set_config(&mut self, g: Config);