From 891cc31b21c4bcaf7264e40fd449cc21335d213c Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 24 Oct 2025 12:28:31 +0200 Subject: [PATCH] feat: Extend token restriction api --- .../idp/identity_provider_list.rego | 2 +- policy/identity.rego | 10 + policy/token/restriction/create.rego | 27 ++ policy/token/restriction/create_test.rego | 19 ++ policy/token/restriction/delete.rego | 27 ++ policy/token/restriction/delete_test.rego | 19 ++ policy/token/restriction/list.rego | 25 ++ policy/token/restriction/list_test.rego | 15 + policy/token/restriction/show.rego | 17 +- policy/token/restriction/show_test.rego | 10 +- policy/token/restriction/update.rego | 27 ++ policy/token/restriction/update_test.rego | 19 ++ src/api/error.rs | 9 + .../v4/federation/identity_provider/list.rs | 16 +- src/api/v4/token/restriction.rs | 10 +- src/api/v4/token/restriction/create.rs | 177 ++++++++++++ src/api/v4/token/restriction/delete.rs | 186 ++++++++++++ src/api/v4/token/restriction/list.rs | 269 ++++++++++++++++++ src/api/v4/token/restriction/show.rs | 7 +- src/api/v4/token/restriction/update.rs | 185 ++++++++++++ src/api/v4/token/types/restriction.rs | 147 +++++++++- src/db/entity/token_restriction.rs | 9 + .../m20251005_131042_token_restriction.rs | 9 + src/identity/backends/sql.rs | 2 +- src/token/fernet.rs | 2 +- src/token/fernet_utils.rs | 2 +- src/token/mod.rs | 99 ++++++- src/token/token_restriction/create.rs | 134 +++++++++ src/token/token_restriction/delete.rs | 61 ++++ src/token/token_restriction/get.rs | 22 +- src/token/token_restriction/list.rs | 131 +++++++++ src/token/token_restriction/mod.rs | 25 ++ src/token/token_restriction/update.rs | 215 ++++++++++++++ src/token/types.rs | 47 +++ 34 files changed, 1922 insertions(+), 59 deletions(-) create mode 100644 policy/token/restriction/create.rego create mode 100644 policy/token/restriction/create_test.rego create mode 100644 policy/token/restriction/delete.rego create mode 100644 policy/token/restriction/delete_test.rego create mode 100644 policy/token/restriction/list.rego create mode 100644 policy/token/restriction/list_test.rego create mode 100644 policy/token/restriction/update.rego create mode 100644 policy/token/restriction/update_test.rego create mode 100644 src/api/v4/token/restriction/create.rs create mode 100644 src/api/v4/token/restriction/delete.rs create mode 100644 src/api/v4/token/restriction/list.rs create mode 100644 src/api/v4/token/restriction/update.rs create mode 100644 src/token/token_restriction/create.rs create mode 100644 src/token/token_restriction/delete.rs create mode 100644 src/token/token_restriction/list.rs create mode 100644 src/token/token_restriction/update.rs diff --git a/policy/federation/idp/identity_provider_list.rego b/policy/federation/idp/identity_provider_list.rego index 8a4b3eb0..d885add8 100644 --- a/policy/federation/idp/identity_provider_list.rego +++ b/policy/federation/idp/identity_provider_list.rego @@ -8,7 +8,7 @@ default allow := false default can_see_other_domain_resources := false -can_see_other_domain_resources := true if { +can_see_other_domain_resources if { "admin" in input.credentials.roles } diff --git a/policy/identity.rego b/policy/identity.rego index 6954253e..9d52fa92 100644 --- a/policy/identity.rego +++ b/policy/identity.rego @@ -39,3 +39,13 @@ foreign_mapping if { input.target.domain_id != null input.target.domain_id != input.credentials.domain_id } + +foreign_token_restriction if { + input.target.domain_id != null + input.target.domain_id != input.credentials.domain_id +} + +own_token_restriction if { + input.target.domain_id != null + input.target.domain_id == input.credentials.domain_id +} diff --git a/policy/token/restriction/create.rego b/policy/token/restriction/create.rego new file mode 100644 index 00000000..03291d5b --- /dev/null +++ b/policy/token/restriction/create.rego @@ -0,0 +1,27 @@ +package identity.token_restriction.create + +import data.identity + +# Create token restriction. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles + identity.own_token_restriction +} + +allow if { + "member" in input.credentials.roles + input.target.user_id != null + input.credentials.user_id == input.target.user_id +} + +violation contains {"field": "domain_id", "msg": "creating token restrictions in other domain requires `admin` role."} if { + identity.foreign_token_restriction + not "admin" in input.credentials.roles +} diff --git a/policy/token/restriction/create_test.rego b/policy/token/restriction/create_test.rego new file mode 100644 index 00000000..25758b2b --- /dev/null +++ b/policy/token/restriction/create_test.rego @@ -0,0 +1,19 @@ +package test_token_restriction_create + +import data.identity.token_restriction.create + +test_allowed if { + create.allow with input as {"credentials": {"roles": ["admin"]}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "domain"}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} + create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid2"}} + create.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} +} + +test_forbidden if { + not create.allow with input as {"credentials": {"roles": []}} + not create.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not create.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not create.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid1"}, "target": {"domain_id": "domain", "user_id": "uid2"}} +} diff --git a/policy/token/restriction/delete.rego b/policy/token/restriction/delete.rego new file mode 100644 index 00000000..4c1f90c4 --- /dev/null +++ b/policy/token/restriction/delete.rego @@ -0,0 +1,27 @@ +package identity.token_restriction.delete + +import data.identity + +# Delete token restriction. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles + identity.own_token_restriction +} + +allow if { + "member" in input.credentials.roles + input.target.user_id != null + input.credentials.user_id == input.target.user_id +} + +violation contains {"field": "domain_id", "msg": "deleting token restrictions in other domain requires `admin` role."} if { + identity.foreign_token_restriction + not "admin" in input.credentials.roles +} diff --git a/policy/token/restriction/delete_test.rego b/policy/token/restriction/delete_test.rego new file mode 100644 index 00000000..1ca3ba59 --- /dev/null +++ b/policy/token/restriction/delete_test.rego @@ -0,0 +1,19 @@ +package test_token_restriction_delete + +import data.identity.token_restriction.delete + +test_allowed if { + delete.allow with input as {"credentials": {"roles": ["admin"]}} + delete.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "domain"}} + delete.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} + delete.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid2"}} + delete.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} +} + +test_forbidden if { + not delete.allow with input as {"credentials": {"roles": []}} + not delete.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not delete.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not delete.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not delete.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid1"}, "target": {"domain_id": "domain", "user_id": "uid2"}} +} diff --git a/policy/token/restriction/list.rego b/policy/token/restriction/list.rego new file mode 100644 index 00000000..30f54e48 --- /dev/null +++ b/policy/token/restriction/list.rego @@ -0,0 +1,25 @@ +package identity.token_restriction.list + +import data.identity + +# List token restriction. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles +} + +allow if { + "member" in input.credentials.roles + identity.own_token_restriction +} + +violation contains {"field": "domain_id", "msg": "showing token restrictions requires `admin` role."} if { + identity.foreign_token_restriction + not "admin" in input.credentials.roles +} diff --git a/policy/token/restriction/list_test.rego b/policy/token/restriction/list_test.rego new file mode 100644 index 00000000..d30244b7 --- /dev/null +++ b/policy/token/restriction/list_test.rego @@ -0,0 +1,15 @@ +package test_token_restriction_list + +import data.identity.token_restriction.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": ["manager"]}} + list.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "domain"}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not list.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} +} diff --git a/policy/token/restriction/show.rego b/policy/token/restriction/show.rego index c528b0a1..818b465a 100644 --- a/policy/token/restriction/show.rego +++ b/policy/token/restriction/show.rego @@ -1,8 +1,8 @@ -package identity.token_restriction_show +package identity.token_restriction.show import data.identity -# Create mapping. +# Show single token restriction. default allow := false @@ -11,20 +11,11 @@ allow if { } allow if { + identity.own_token_restriction "manager" in input.credentials.roles } violation contains {"field": "domain_id", "msg": "showing token restrictions requires `admin` role."} if { - #identity.foreign_mapping + identity.foreign_token_restriction not "admin" in input.credentials.roles } - -#violation contains {"field": "role", "msg": "creating global mapping requires `admin` role."} if { -# identity.global_mapping -# not "admin" in input.credentials.roles -#} -# -#violation contains {"field": "role", "msg": "creating mapping requires `manager` role."} if { -# identity.own_mapping -# not "member" in input.credentials.roles -#} diff --git a/policy/token/restriction/show_test.rego b/policy/token/restriction/show_test.rego index 36bc447e..db4e157f 100644 --- a/policy/token/restriction/show_test.rego +++ b/policy/token/restriction/show_test.rego @@ -1,15 +1,15 @@ package test_token_restriction_show -import data.identity.token_restriction_show +import data.identity.token_restriction.show test_allowed if { - token_restriction_show.allow with input as {"credentials": {"roles": ["admin"]}} + show.allow with input as {"credentials": {"roles": ["admin"]}} #token_restriction_show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "domain"}} #token_restriction_show.allow with input as {"credentials": {"roles": ["reader"]}, "target": {"domain_id": null}} } test_forbidden if { - not token_restriction_show.allow with input as {"credentials": {"roles": []}} - not token_restriction_show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} - not token_restriction_show.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not show.allow with input as {"credentials": {"roles": []}} + not show.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not show.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} } diff --git a/policy/token/restriction/update.rego b/policy/token/restriction/update.rego new file mode 100644 index 00000000..6025786c --- /dev/null +++ b/policy/token/restriction/update.rego @@ -0,0 +1,27 @@ +package identity.token_restriction.update + +import data.identity + +# Update token restriction. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles + identity.own_token_restriction +} + +allow if { + "member" in input.credentials.roles + input.target.user_id != null + input.credentials.user_id == input.target.user_id +} + +violation contains {"field": "domain_id", "msg": "updating token restrictions in other domain requires `admin` role."} if { + identity.foreign_token_restriction + not "admin" in input.credentials.roles +} diff --git a/policy/token/restriction/update_test.rego b/policy/token/restriction/update_test.rego new file mode 100644 index 00000000..6833a1fe --- /dev/null +++ b/policy/token/restriction/update_test.rego @@ -0,0 +1,19 @@ +package test_token_restriction_update + +import data.identity.token_restriction.update + +test_allowed if { + update.allow with input as {"credentials": {"roles": ["admin"]}} + update.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "domain"}} + update.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} + update.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid2"}} + update.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid"}, "target": {"domain_id": "domain", "user_id": "uid"}} +} + +test_forbidden if { + not update.allow with input as {"credentials": {"roles": []}} + not update.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not update.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not update.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "domain"}, "target": {"domain_id": "other_domain"}} + not update.allow with input as {"credentials": {"roles": ["member"], "domain_id": "domain", "user_id": "uid1"}, "target": {"domain_id": "domain", "user_id": "uid2"}} +} diff --git a/src/api/error.rs b/src/api/error.rs index 26c391fd..d4f3bc2b 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -252,6 +252,15 @@ impl KeystoneApiError { _ => source.into(), } } + pub fn token(source: TokenProviderError) -> Self { + match source { + TokenProviderError::TokenRestrictionNotFound(x) => Self::NotFound { + resource: "token restriction".into(), + identifier: x, + }, + _ => source.into(), + } + } } #[derive(Debug, Error)] diff --git a/src/api/v4/federation/identity_provider/list.rs b/src/api/v4/federation/identity_provider/list.rs index 7b5aa270..63e7674b 100644 --- a/src/api/v4/federation/identity_provider/list.rs +++ b/src/api/v4/federation/identity_provider/list.rs @@ -229,21 +229,7 @@ mod tests { #[tokio::test] #[traced_test] async fn test_list_forbidden() { - let mut federation_mock = MockFederationProvider::default(); - federation_mock - .expect_list_identity_providers() - .withf( - |_: &DatabaseConnection, _: &provider_types::IdentityProviderListParameters| true, - ) - .returning(|_, _| { - Ok(vec![provider_types::IdentityProvider { - id: "id".into(), - name: "name".into(), - domain_id: Some("did".into()), - default_mapping_name: Some("dummy".into()), - ..Default::default() - }]) - }); + let federation_mock = MockFederationProvider::default(); let state = get_mocked_state(federation_mock, false, None); let mut api = openapi_router() diff --git a/src/api/v4/token/restriction.rs b/src/api/v4/token/restriction.rs index c7f03397..7787b07c 100644 --- a/src/api/v4/token/restriction.rs +++ b/src/api/v4/token/restriction.rs @@ -32,10 +32,16 @@ Token restrictions allow controlling multiple aspects of the authentication and - `roles` binds the roles of the issued token on the scope. Using this bypasses necessity to grant the roles explicitly to the user. "#; +mod create; +mod delete; +mod list; mod show; +mod update; pub(super) fn openapi_router() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(show::show)) + OpenApiRouter::new() + .routes(routes!(show::show, delete::remove, update::update)) + .routes(routes!(list::list, create::create)) } #[cfg(test)] @@ -45,7 +51,7 @@ mod tests { use std::sync::Arc; use crate::config::Config; - use crate::federation::MockFederationProvider; + use crate::identity::types::UserResponse; use crate::keystone::{Service, ServiceState}; use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult}; diff --git a/src/api/v4/token/restriction/create.rs b/src/api/v4/token/restriction/create.rs new file mode 100644 index 00000000..0ef26735 --- /dev/null +++ b/src/api/v4/token/restriction/create.rs @@ -0,0 +1,177 @@ +// 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 restriction: create + +use axum::{Json, debug_handler, extract::State, http::StatusCode, response::IntoResponse}; +use mockall_double::double; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::api::v4::token::types::*; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::token::TokenApi; + +/// Create the token restriction. +/// +/// Create the token restriction with the specified properties. +/// +/// It is expected that only admin user is able to create token restriction in other domain. +#[utoipa::path( + post, + path = "/", + operation_id = "/token_restriction:create", + responses( + (status = CREATED, description = "token restriction object", body = TokenRestrictionResponse), + ), + security(("x-auth" = [])), + tag="token_restriction" +)] +#[tracing::instrument( + name = "api::token_restriction::create", + level = "debug", + skip(state, user_auth, policy), + err(Debug) +)] +#[debug_handler] +pub(super) async fn create( + Auth(user_auth): Auth, + mut policy: Policy, + State(state): State, + Json(req): Json, +) -> Result { + policy + .enforce( + "identity/token_restriction/create", + &user_auth, + serde_json::to_value(&req.restriction)?, + None, + ) + .await?; + + let res = state + .provider + .get_token_provider() + .create_token_restriction(&state.db, req.into()) + .await + .map_err(KeystoneApiError::token)?; + Ok((StatusCode::CREATED, res).into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use super::{ + super::{openapi_router, tests::get_mocked_state}, + *, + }; + + use crate::assignment::types::Role as ProviderRole; + use crate::token::{MockTokenProvider, types as provider_types}; + + #[tokio::test] + #[traced_test] + async fn test_create() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_create_token_restriction() + .withf( + |_: &DatabaseConnection, req: &provider_types::TokenRestrictionCreate| { + provider_types::TokenRestrictionCreate { + id: String::new(), + domain_id: "did".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_renew: true, + allow_rescope: true, + role_ids: vec!["r1".into()], + } == *req + }, + ) + .returning(|_, _| { + Ok(provider_types::TokenRestriction { + user_id: Some("uid".into()), + allow_renew: true, + allow_rescope: true, + id: "bar".into(), + domain_id: "did".into(), + project_id: Some("pid".into()), + role_ids: vec!["r1".into(), "r2".into()], + roles: Some(vec![ProviderRole { + id: "r1".into(), + name: "r1n".into(), + ..Default::default() + }]), + }) + }); + + let state = get_mocked_state(token_mock, true, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = TokenRestrictionCreateRequest { + restriction: TokenRestrictionCreate { + domain_id: "did".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_renew: true, + allow_rescope: true, + roles: vec![ + ProviderRole { + id: "r1".into(), + name: "r1n".into(), + ..Default::default() + } + .into(), + ], + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("POST") + .header(header::CONTENT_TYPE, "application/json") + .uri("/") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: TokenRestrictionResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(res.restriction.domain_id, req.restriction.domain_id); + //assert_eq!( + // res.identity_provider.domain_id, + // req.identity_provider.domain_id + //); + } +} diff --git a/src/api/v4/token/restriction/delete.rs b/src/api/v4/token/restriction/delete.rs new file mode 100644 index 00000000..ba6d0689 --- /dev/null +++ b/src/api/v4/token/restriction/delete.rs @@ -0,0 +1,186 @@ +// 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 restriction: delete. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use mockall_double::double; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::token::TokenApi; + +/// Delete Token restriction. +/// +/// Deletes the existing token restriction by the ID. +#[utoipa::path( + delete, + path = "/{id}", + operation_id = "/token_restriction:delete", + params( + ("id" = String, Path, description = "The ID of the token restriction.") + ), + responses( + (status = 204, description = "Deleted."), + (status = 404, description = "Token restriction not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + security(("x-auth" = [])), + tag="token_restrictions" +)] +#[tracing::instrument( + name = "api::token_restriction::delete", + level = "debug", + skip(state, user_auth, policy), + err(Debug) +)] +pub(super) async fn remove( + Auth(user_auth): Auth, + mut policy: Policy, + Path(id): Path, + State(state): State, +) -> Result { + let current = state + .provider + .get_token_provider() + .get_token_restriction(&state.db, &id, false) + .await?; + + policy + .enforce( + "identity/token_restriction/delete", + &user_auth, + serde_json::to_value(¤t)?, + None, + ) + .await?; + + if current.is_some() { + state + .provider + .get_token_provider() + .delete_token_restriction(&state.db, &id) + .await + .map_err(KeystoneApiError::token)?; + } else { + return Err(KeystoneApiError::NotFound { + resource: "token_restriction".to_string(), + identifier: id.clone(), + }); + } + Ok((StatusCode::NO_CONTENT).into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use sea_orm::DatabaseConnection; + + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use super::super::{openapi_router, tests::get_mocked_state}; + + use crate::assignment::types::Role as ProviderRole; + use crate::token::{MockTokenProvider, TokenProviderError, types as provider_types}; + + #[tokio::test] + #[traced_test] + async fn test_delete() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_get_token_restriction() + .withf(|_: &DatabaseConnection, id: &'_ str, expand: &bool| id == "foo" && !expand) + .returning(|_, _, _| Ok(None)); + token_mock + .expect_get_token_restriction() + .withf(|_: &DatabaseConnection, id: &'_ str, expand: &bool| id == "bar" && !expand) + .returning(|_, _, _| { + Ok(Some(provider_types::TokenRestriction { + user_id: Some("uid".into()), + allow_renew: true, + allow_rescope: true, + id: "bar".into(), + domain_id: "did".into(), + project_id: Some("pid".into()), + role_ids: vec!["r1".into(), "r2".into()], + roles: Some(vec![ + ProviderRole { + id: "r1".into(), + ..Default::default() + }, + ProviderRole { + id: "r2".into(), + ..Default::default() + }, + ]), + })) + }); + token_mock + .expect_delete_token_restriction() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Err(TokenProviderError::TokenRestrictionNotFound("foo".into()))); + + token_mock + .expect_delete_token_restriction() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| Ok(())); + + let state = get_mocked_state(token_mock, true, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/foo") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/src/api/v4/token/restriction/list.rs b/src/api/v4/token/restriction/list.rs new file mode 100644 index 00000000..a4d9a925 --- /dev/null +++ b/src/api/v4/token/restriction/list.rs @@ -0,0 +1,269 @@ +// 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 Restriction: list + +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use mockall_double::double; +use serde_json::to_value; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::api::v4::token::types::*; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::token::{ + TokenApi, types::TokenRestrictionListParameters as ProviderTokenRestrictionListParameters, +}; + +/// List token restrictions. +/// +/// List existing token restrictions. By default only admin user is allowed to leave `domain_id` +/// query parameter empty, what means that token restrictions for all domains should be listed. +#[utoipa::path( + get, + path = "/", + operation_id = "/token_restriction:list", + params(TokenRestrictionListParameters), + responses( + (status = OK, description = "List of token restrictions.", body = TokenRestrictionList), + (status = 500, description = "Internal error.", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + security(("x-auth" = [])), + tag="token_restrictions" +)] +#[tracing::instrument( + name = "api::token_restriction::list", + level = "debug", + skip(state, user_auth, policy), + err(Debug) +)] +pub(super) async fn list( + Auth(user_auth): Auth, + mut policy: Policy, + Query(query): Query, + State(state): State, +) -> Result { + policy + .enforce( + "identity/token_restriction/list", + &user_auth, + to_value(&query)?, + None, + ) + .await?; + + let provider_list_params: ProviderTokenRestrictionListParameters = query.into(); + + let token_restrictions: Vec = state + .provider + .get_token_provider() + .list_token_restrictions(&state.db, &provider_list_params) + .await? + .into_iter() + .map(Into::into) + .collect(); + Ok(TokenRestrictionList { + restrictions: token_restrictions, + }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use super::{ + super::{openapi_router, tests::get_mocked_state}, + *, + }; + use crate::api::v3::role_assignment::types::Role; + use crate::assignment::types::Role as ProviderRole; + use crate::token::{MockTokenProvider, types as provider_types}; + + #[tokio::test] + #[traced_test] + async fn test_list() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_list_token_restrictions() + .withf( + |_: &DatabaseConnection, _: &provider_types::TokenRestrictionListParameters| true, + ) + .returning(|_, _| { + Ok(vec![provider_types::TokenRestriction { + user_id: Some("uid".into()), + allow_renew: true, + allow_rescope: true, + id: "bar".into(), + domain_id: "did".into(), + project_id: Some("pid".into()), + role_ids: vec!["r1".into(), "r2".into()], + roles: Some(vec![ + ProviderRole { + id: "r1".into(), + name: "r1n".into(), + ..Default::default() + }, + ProviderRole { + id: "r2".into(), + name: "r2n".into(), + ..Default::default() + }, + ]), + }]) + }); + let state = get_mocked_state(token_mock, true, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let res: TokenRestrictionList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![TokenRestriction { + id: "bar".into(), + domain_id: "did".into(), + allow_rescope: true, + allow_renew: true, + user_id: Some("uid".into()), + project_id: Some("pid".into()), + roles: vec![ + Role { + id: "r1".into(), + name: Some("r1n".into()) + }, + Role { + id: "r2".into(), + name: Some("r2n".into()) + } + ] + }], + res.restrictions + ); + } + + #[tokio::test] + #[traced_test] + async fn test_list_qp() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_list_token_restrictions() + .withf( + |_: &DatabaseConnection, qp: &provider_types::TokenRestrictionListParameters| { + provider_types::TokenRestrictionListParameters { + domain_id: Some("did".into()), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![provider_types::TokenRestriction { + user_id: Some("uid".into()), + allow_renew: true, + allow_rescope: true, + id: "bar".into(), + domain_id: "did".into(), + project_id: Some("pid".into()), + role_ids: vec!["r1".into(), "r2".into()], + roles: Some(vec![ + ProviderRole { + id: "r1".into(), + name: "r1n".into(), + ..Default::default() + }, + ProviderRole { + id: "r2".into(), + name: "r2n".into(), + ..Default::default() + }, + ]), + }]) + }); + let state = get_mocked_state(token_mock, true, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=did&user_id=uid&project_id=pid") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: TokenRestrictionList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + #[traced_test] + async fn test_list_forbidden() { + let token_mock = MockTokenProvider::default(); + let state = get_mocked_state(token_mock, false, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/src/api/v4/token/restriction/show.rs b/src/api/v4/token/restriction/show.rs index 13024509..3ba11c49 100644 --- a/src/api/v4/token/restriction/show.rs +++ b/src/api/v4/token/restriction/show.rs @@ -70,7 +70,7 @@ pub(super) async fn show( policy .enforce( - "identity/token_restriction_show", + "identity/token_restriction/show", &user_auth, serde_json::to_value(¤t)?, None, @@ -86,7 +86,6 @@ mod tests { http::{Request, StatusCode}, }; use http_body_util::BodyExt; // for `collect` - use sea_orm::DatabaseConnection; use tower::ServiceExt; // for `call`, `oneshot`, and `ready` use tower_http::trace::TraceLayer; @@ -118,6 +117,7 @@ mod tests { allow_renew: true, allow_rescope: true, id: "bar".into(), + domain_id: "did".into(), project_id: Some("pid".into()), role_ids: vec!["r1".into(), "r2".into()], roles: Some(vec![ @@ -174,10 +174,10 @@ mod tests { assert_eq!( TokenRestriction { id: "bar".into(), + domain_id: "did".into(), allow_rescope: true, allow_renew: true, user_id: Some("uid".into()), - //domain_id: Some("did".into()), project_id: Some("pid".into()), roles: vec![ Role { @@ -207,6 +207,7 @@ mod tests { allow_renew: true, allow_rescope: true, id: "bar".into(), + domain_id: "did".into(), project_id: Some("pid".into()), role_ids: vec!["r1".into(), "r2".into()], roles: Some(vec![ diff --git a/src/api/v4/token/restriction/update.rs b/src/api/v4/token/restriction/update.rs new file mode 100644 index 00000000..7c2a7c7d --- /dev/null +++ b/src/api/v4/token/restriction/update.rs @@ -0,0 +1,185 @@ +// 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 restriction: update +use axum::{ + Json, + extract::{Path, State}, + response::IntoResponse, +}; +use mockall_double::double; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::api::v4::token::types::*; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::token::TokenApi; + +/// Update existing token restriction by the ID. +/// +/// Updates the existing token restriction. +/// +/// It is expected that only admin user is able to update token restriction in other domain. +#[utoipa::path( + put, + path = "/{id}", + operation_id = "/token_restriction:update", + params( + ("id" = String, Path, description = "The ID of the token restriction") + ), + responses( + (status = OK, description = "Token restriction object", body = TokenRestrictionResponse), + (status = 404, description = "Token restriction not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + security(("x-auth" = [])), + tag="token_restriction" +)] +#[tracing::instrument( + name = "api::token_restriction::update", + level = "debug", + skip(state, user_auth, policy), + err(Debug) +)] +pub(super) async fn update( + Auth(user_auth): Auth, + mut policy: Policy, + Path(id): Path, + State(state): State, + Json(req): Json, +) -> Result { + // Fetch the current resource to pass current object into the policy evaluation + let current = state + .provider + .get_token_provider() + .get_token_restriction(&state.db, &id, false) + .await?; + + policy + .enforce( + "identity/token_restriction/update", + &user_auth, + serde_json::to_value(¤t)?, + Some(serde_json::to_value(&req.restriction)?), + ) + .await?; + + let res = state + .provider + .get_token_provider() + .update_token_restriction(&state.db, &id, req.into()) + .await + .map_err(KeystoneApiError::token)?; + Ok(res.into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode, header}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use super::{ + super::{openapi_router, tests::get_mocked_state}, + *, + }; + + use crate::assignment::types::Role as ProviderRole; + use crate::token::{MockTokenProvider, types as provider_types}; + + #[tokio::test] + #[traced_test] + async fn test_update() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_get_token_restriction() + .withf(|_: &DatabaseConnection, id: &'_ str, expand: &bool| id == "1" && !expand) + .returning(|_, _, _| { + Ok(Some(provider_types::TokenRestriction { + id: "1".into(), + domain_id: "did".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_renew: true, + allow_rescope: true, + role_ids: vec!["r1".into()], + roles: None, + })) + }); + token_mock + .expect_update_token_restriction() + .withf( + |_: &DatabaseConnection, + id: &'_ str, + req: &provider_types::TokenRestrictionUpdate| { + id == "1" + && provider_types::TokenRestrictionUpdate { + project_id: Some(Some("new_pid".into())), + ..Default::default() + } == *req + }, + ) + .returning(|_, _, _| { + Ok(provider_types::TokenRestriction { + id: "1".into(), + domain_id: "did".into(), + user_id: Some("uid".into()), + project_id: Some("new_pid".into()), + allow_renew: true, + allow_rescope: true, + role_ids: vec!["r1".into()], + roles: None, + }) + }); + + let state = get_mocked_state(token_mock, true, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let req = TokenRestrictionUpdateRequest { + restriction: TokenRestrictionUpdate { + project_id: Some(Some("new_pid".into())), + ..Default::default() + }, + }; + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("PUT") + .header(header::CONTENT_TYPE, "application/json") + .uri("/1") + .header("x-auth-token", "foo") + .body(Body::from(serde_json::to_string(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let _res: TokenRestrictionResponse = serde_json::from_slice(&body).unwrap(); + } +} diff --git a/src/api/v4/token/types/restriction.rs b/src/api/v4/token/types/restriction.rs index c5564523..092e5ff5 100644 --- a/src/api/v4/token/types/restriction.rs +++ b/src/api/v4/token/types/restriction.rs @@ -19,11 +19,15 @@ use axum::{ }; use derive_builder::Builder; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use crate::api::error::KeystoneApiError; use crate::api::v3::role_assignment::types::Role; -use crate::token::types::TokenRestriction as ProviderTokenRestriction; +use crate::token::types::{ + self as types, TokenRestriction as ProviderTokenRestriction, + TokenRestrictionCreate as ProviderTokenRestrictionCreate, + TokenRestrictionUpdate as ProviderTokenRestrictionUpdate, +}; /// Token restriction data. #[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] @@ -35,6 +39,9 @@ pub struct TokenRestriction { /// Allow token rescope. pub allow_rescope: bool, + /// Domain ID the token restriction belongs to. + pub domain_id: String, + /// Token restriction ID. pub id: String, @@ -53,18 +60,120 @@ pub struct TokenRestriction { pub roles: Vec, } +/// New token restriction data. +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenRestrictionCreate { + /// Allow token renew. + pub allow_renew: bool, + + /// Allow token rescope. + pub allow_rescope: bool, + + /// Domain ID the token restriction belongs to. + pub domain_id: String, + + /// Project ID that the token must be bound to. + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + + /// User ID that the token must be bound to. + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + + /// Bound token roles. + #[builder(default)] + pub roles: Vec, +} + +/// New token restriction data. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenRestrictionUpdate { + /// Allow token renew. + pub allow_renew: Option, + + /// Allow token rescope. + pub allow_rescope: Option, + + /// Project ID that the token must be bound to. + #[builder(default)] + pub project_id: Option>, + + /// User ID that the token must be bound to. + #[builder(default)] + pub user_id: Option>, + + /// Bound token roles. + #[builder(default)] + pub roles: Option>, +} + +/// Token restriction data. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct TokenRestrictionResponse { /// Restriction object. pub restriction: TokenRestriction, } +/// Token restriction creation request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct TokenRestrictionCreateRequest { + /// Restriction object. + pub restriction: TokenRestrictionCreate, +} + +/// Token restriction update request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct TokenRestrictionUpdateRequest { + /// Restriction object. + pub restriction: TokenRestrictionUpdate, +} + +/// Token restriction list filters. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, IntoParams)] +pub struct TokenRestrictionListParameters { + /// Domain id. + pub domain_id: Option, + /// User id. + pub user_id: Option, + /// Project id. + pub project_id: Option, +} + +/// Token restrictions. +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenRestrictionList { + /// Token restrictions. + pub restrictions: Vec, +} + +impl IntoResponse for TokenRestrictionList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +impl From for types::TokenRestrictionListParameters { + fn from(value: TokenRestrictionListParameters) -> Self { + Self { + domain_id: value.domain_id, + user_id: value.user_id, + project_id: value.project_id, + } + } +} + impl From for TokenRestriction { fn from(value: ProviderTokenRestriction) -> Self { Self { allow_rescope: value.allow_rescope, allow_renew: value.allow_renew, id: value.id, + domain_id: value.domain_id, project_id: value.project_id, user_id: value.user_id, roles: value @@ -75,6 +184,40 @@ impl From for TokenRestriction { } } +impl From for ProviderTokenRestrictionCreate { + fn from(value: TokenRestrictionCreateRequest) -> Self { + Self { + allow_rescope: value.restriction.allow_rescope, + allow_renew: value.restriction.allow_renew, + id: String::new(), + domain_id: value.restriction.domain_id, + project_id: value.restriction.project_id, + user_id: value.restriction.user_id, + role_ids: value + .restriction + .roles + .into_iter() + .map(|role| role.id) + .collect(), + } + } +} + +impl From for ProviderTokenRestrictionUpdate { + fn from(value: TokenRestrictionUpdateRequest) -> Self { + Self { + allow_rescope: value.restriction.allow_rescope, + allow_renew: value.restriction.allow_renew, + project_id: value.restriction.project_id, + user_id: value.restriction.user_id, + role_ids: value + .restriction + .roles + .map(|roles| roles.into_iter().map(|role| role.id).collect()), + } + } +} + impl From for Role { fn from(value: crate::assignment::types::role::Role) -> Self { Self { diff --git a/src/db/entity/token_restriction.rs b/src/db/entity/token_restriction.rs index cc6d54ae..2836ed6d 100644 --- a/src/db/entity/token_restriction.rs +++ b/src/db/entity/token_restriction.rs @@ -20,6 +20,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: String, + pub domain_id: String, pub user_id: Option, pub allow_renew: bool, pub allow_rescope: bool, @@ -28,6 +29,14 @@ pub struct Model { #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { + #[sea_orm( + belongs_to = "super::project::Entity", + from = "Column::DomainId", + to = "super::project::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Domain, #[sea_orm( belongs_to = "super::project::Entity", from = "Column::ProjectId", diff --git a/src/db_migration/m20251005_131042_token_restriction.rs b/src/db_migration/m20251005_131042_token_restriction.rs index 572ff2c2..e8085b62 100644 --- a/src/db_migration/m20251005_131042_token_restriction.rs +++ b/src/db_migration/m20251005_131042_token_restriction.rs @@ -28,10 +28,18 @@ impl MigrationTrait for Migration { .table(TokenRestriction::Table) .if_not_exists() .col(string_len(TokenRestriction::Id, 64).primary_key()) + .col(string_len(TokenRestriction::DomainId, 64)) .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-domain") + .from(TokenRestriction::Table, TokenRestriction::DomainId) + .to(Project, project::Column::Id) + .on_delete(ForeignKeyAction::Cascade), + ) .foreign_key( ForeignKey::create() .name("fk-token-restriction-user") @@ -140,6 +148,7 @@ impl MigrationTrait for Migration { enum TokenRestriction { Table, Id, + DomainId, UserId, AllowRenew, AllowRescope, diff --git a/src/identity/backends/sql.rs b/src/identity/backends/sql.rs index 672c68c9..644f066b 100644 --- a/src/identity/backends/sql.rs +++ b/src/identity/backends/sql.rs @@ -36,7 +36,7 @@ use crate::db::entity::{ federated_user as db_federated_user, local_user as db_local_user, nonlocal_user as db_nonlocal_user, password as db_password, prelude::{FederatedUser, LocalUser, NonlocalUser, User as DbUser, UserOption}, - user as db_user, user_option as db_user_option, + user as db_user, }; use crate::identity::IdentityProviderError; use crate::identity::backends::error::{IdentityDatabaseError, db_err}; diff --git a/src/token/fernet.rs b/src/token/fernet.rs index 42147424..3bf2a24f 100644 --- a/src/token/fernet.rs +++ b/src/token/fernet.rs @@ -376,7 +376,7 @@ pub(super) mod tests { assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); assert_eq!( decrypted.methods.clone().sort(), - vec!["password", "token"].sort() + ["password", "token"].sort() ); assert_eq!( decrypted.expires_at.to_rfc3339(), diff --git a/src/token/fernet_utils.rs b/src/token/fernet_utils.rs index b9c47d15..1eaa2150 100644 --- a/src/token/fernet_utils.rs +++ b/src/token/fernet_utils.rs @@ -365,7 +365,7 @@ mod tests { for i in 0..5 { let file_path = tmp_dir.path().join(format!("{i}")); let mut tmp_file = File::create(file_path).unwrap(); - write!(tmp_file, "{}\n", Fernet::generate_key()).unwrap(); + writeln!(tmp_file, "{}", Fernet::generate_key()).unwrap(); } let utils = FernetUtils { key_repository: tmp_dir.keep(), diff --git a/src/token/mod.rs b/src/token/mod.rs index 325cf8eb..593bd382 100644 --- a/src/token/mod.rs +++ b/src/token/mod.rs @@ -61,7 +61,10 @@ pub use federation_project_scoped::{ pub use federation_unscoped::{FederationUnscopedPayload, FederationUnscopedPayloadBuilder}; pub use project_scoped::{ProjectScopePayload, ProjectScopePayloadBuilder}; pub use restricted::{RestrictedPayload, RestrictedPayloadBuilder}; -pub use types::{Token, TokenRestriction}; +pub use types::{ + Token, TokenRestriction, TokenRestrictionCreate, TokenRestrictionListParameters, + TokenRestrictionUpdate, +}; pub use unscoped::{UnscopedPayload, UnscopedPayloadBuilder}; #[derive(Clone, Debug)] @@ -398,6 +401,35 @@ pub trait TokenApi: Send + Sync + Clone { id: &'a str, expand_roles: bool, ) -> Result, TokenProviderError>; + + /// Create new token restriction. + async fn create_token_restriction<'a>( + &self, + db: &DatabaseConnection, + restriction: TokenRestrictionCreate, + ) -> Result; + + /// List token restrictions. + async fn list_token_restrictions<'a>( + &self, + db: &DatabaseConnection, + params: &TokenRestrictionListParameters, + ) -> Result, TokenProviderError>; + + /// Update token restriction by the ID. + async fn update_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + restriction: TokenRestrictionUpdate, + ) -> Result; + + /// Delete token restriction by the ID. + async fn delete_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), TokenProviderError>; } #[async_trait] @@ -753,6 +785,47 @@ impl TokenApi for TokenProvider { ) -> Result, TokenProviderError> { token_restriction::get(db, id, expand_roles).await } + + /// Create new token restriction. + async fn create_token_restriction<'a>( + &self, + db: &DatabaseConnection, + restriction: TokenRestrictionCreate, + ) -> Result { + let mut restriction = restriction; + if restriction.id.is_empty() { + restriction.id = Uuid::new_v4().simple().to_string(); + } + token_restriction::create(db, restriction).await + } + + /// List token restrictions. + async fn list_token_restrictions<'a>( + &self, + db: &DatabaseConnection, + params: &TokenRestrictionListParameters, + ) -> Result, TokenProviderError> { + token_restriction::list(db, params).await + } + + /// Update existing token restriction. + async fn update_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + restriction: TokenRestrictionUpdate, + ) -> Result { + token_restriction::update(db, id, restriction).await + } + + /// Delete token restriction by the ID. + async fn delete_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), TokenProviderError> { + token_restriction::delete(db, id).await + } } #[cfg(test)] @@ -808,6 +881,30 @@ mock! { expand_roles: bool, ) -> Result, TokenProviderError>; + async fn list_token_restrictions<'a>( + &self, + db: &DatabaseConnection, + params: &TokenRestrictionListParameters, + ) -> Result, TokenProviderError>; + + async fn create_token_restriction<'a>( + &self, + db: &DatabaseConnection, + restriction: TokenRestrictionCreate, + ) -> Result; + + async fn update_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + restriction: TokenRestrictionUpdate, + ) -> Result; + + async fn delete_token_restriction<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result<(), TokenProviderError>; } impl Clone for TokenProvider { diff --git a/src/token/token_restriction/create.rs b/src/token/token_restriction/create.rs new file mode 100644 index 00000000..53ee59de --- /dev/null +++ b/src/token/token_restriction/create.rs @@ -0,0 +1,134 @@ +// 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 +//! Create token restriction "policy". + +use sea_orm::DatabaseConnection; +use sea_orm::TransactionTrait; +use sea_orm::entity::*; + +use crate::db::entity::prelude::TokenRestrictionRoleAssociation as DbTokenRestrictionRoleAssociation; +use crate::db::entity::{token_restriction, token_restriction_role_association}; +use crate::token::error::{TokenProviderError, db_err}; +use crate::token::types::*; + +/// Create new token restriction. +pub async fn create( + db: &DatabaseConnection, + restriction: TokenRestrictionCreate, +) -> Result { + let entry = token_restriction::ActiveModel { + id: Set(restriction.id.clone()), + domain_id: Set(restriction.domain_id.clone()), + allow_renew: Set(restriction.allow_renew), + allow_rescope: Set(restriction.allow_rescope), + project_id: restriction + .project_id + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + user_id: restriction + .user_id + .clone() + .map(Set) + .unwrap_or(NotSet) + .into(), + }; + + let txn = db + .begin() + .await + .map_err(|err| db_err(err, "starting the transaction"))?; + let db_entry: token_restriction::Model = entry + .insert(db) + .await + .map_err(|err| db_err(err, "creating token restriction"))?; + + if !restriction.role_ids.is_empty() { + DbTokenRestrictionRoleAssociation::insert_many( + restriction.role_ids.clone().into_iter().map(|rid| { + token_restriction_role_association::ActiveModel { + restriction_id: Set(restriction.id.clone()), + role_id: Set(rid.clone()), + } + }), + ) + .exec(db) + .await + .map_err(|err| db_err(err, "persisting token restriction role association"))?; + } + txn.commit() + .await + .map_err(|err| db_err(err, "committing the transaction"))?; + + Ok(db_entry.into()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Statement, Transaction}; + + use super::super::tests::get_restriction_mock; + use super::*; + + #[tokio::test] + async fn test_create() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_restriction_mock("1")]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + + let req = TokenRestrictionCreate { + id: "1".into(), + domain_id: "did".into(), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + allow_rescope: true, + allow_renew: true, + role_ids: vec!["r1".into(), "r2".into()], + }; + + create(&db, req).await.unwrap(); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::many(vec![ + Statement::from_string(DatabaseBackend::Postgres, r#"BEGIN"#,), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "token_restriction" ("id", "domain_id", "user_id", "allow_renew", "allow_rescope", "project_id") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id", "domain_id", "user_id", "allow_renew", "allow_rescope", "project_id""#, + [ + "1".into(), + "did".into(), + "uid".into(), + true.into(), + true.into(), + "pid".into() + ] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "token_restriction_role_association" ("restriction_id", "role_id") VALUES ($1, $2), ($3, $4) RETURNING "restriction_id", "role_id""#, + ["1".into(), "r1".into(), "1".into(), "r2".into(),] + ), + Statement::from_string(DatabaseBackend::Postgres, r#"COMMIT"#,) + ]),] + ); + } +} diff --git a/src/token/token_restriction/delete.rs b/src/token/token_restriction/delete.rs new file mode 100644 index 00000000..6dbd1314 --- /dev/null +++ b/src/token/token_restriction/delete.rs @@ -0,0 +1,61 @@ +// 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 +//! Delete token restriction entry. + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; + +use crate::db::entity::prelude::TokenRestriction as DbTokenRestriction; +use crate::token::error::{TokenProviderError, db_err}; + +/// Delete existing token restriction by the ID. +pub async fn delete>( + db: &DatabaseConnection, + id: S, +) -> Result<(), TokenProviderError> { + DbTokenRestriction::delete_by_id(id.as_ref()) + .exec(db) + .await + .map_err(|err| db_err(err, "deleting the token restriction"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult, Transaction}; + + use super::*; + + #[tokio::test] + async fn test_delete() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + + delete(&db, "id").await.unwrap(); + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "token_restriction" WHERE "token_restriction"."id" = $1"#, + ["id".into()] + ),] + ); + } +} diff --git a/src/token/token_restriction/get.rs b/src/token/token_restriction/get.rs index 41f6a7bd..8470bdd3 100644 --- a/src/token/token_restriction/get.rs +++ b/src/token/token_restriction/get.rs @@ -11,7 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 - +//! Get existing token restriction. use sea_orm::DatabaseConnection; use sea_orm::entity::*; use sea_orm::query::*; @@ -24,6 +24,7 @@ use crate::db::entity::token_restriction_role_association; use crate::token::error::{TokenProviderError, db_err}; use crate::token::types::TokenRestriction; +/// Get existing token restriction by the ID. pub async fn get>( db: &DatabaseConnection, token_restriction_id: S, @@ -68,21 +69,12 @@ mod tests { #![allow(clippy::derivable_impls)] use crate::assignment::types::Role; - use crate::db::entity::{role, token_restriction, token_restriction_role_association}; + use crate::db::entity::{role, token_restriction_role_association}; use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + use super::super::tests::get_restriction_mock; 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 { @@ -139,6 +131,7 @@ mod tests { get(&db, "id", false).await.unwrap(), Some(TokenRestriction { id: "id".into(), + domain_id: "did".into(), user_id: Some("uid".into()), project_id: Some("pid".into()), allow_rescope: true, @@ -154,7 +147,7 @@ mod tests { [ 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"#, + r#"SELECT "token_restriction"."id", "token_restriction"."domain_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( @@ -178,6 +171,7 @@ mod tests { get(&db, "id", true).await.unwrap(), Some(TokenRestriction { id: "id".into(), + domain_id: "did".into(), user_id: Some("uid".into()), project_id: Some("pid".into()), allow_rescope: true, @@ -204,7 +198,7 @@ mod tests { [ 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"#, + r#"SELECT "token_restriction"."id", "token_restriction"."domain_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( diff --git a/src/token/token_restriction/list.rs b/src/token/token_restriction/list.rs new file mode 100644 index 00000000..e18c6819 --- /dev/null +++ b/src/token/token_restriction/list.rs @@ -0,0 +1,131 @@ +// 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 existing token restriction. + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use crate::db::entity::prelude::{ + TokenRestriction as DbTokenRestriction, + TokenRestrictionRoleAssociation as DbTokenRestrictionRoleAssociation, +}; +use crate::db::entity::token_restriction; +use crate::db::entity::token_restriction_role_association; +use crate::token::error::{TokenProviderError, db_err}; +use crate::token::types::{TokenRestriction, TokenRestrictionListParameters}; + +/// List existing token restrictions. +pub async fn list( + db: &DatabaseConnection, + params: &TokenRestrictionListParameters, +) -> Result, TokenProviderError> { + let mut select = DbTokenRestriction::find(); + if let Some(val) = ¶ms.domain_id { + select = select.filter(token_restriction::Column::DomainId.eq(val)); + } + if let Some(val) = ¶ms.user_id { + select = select.filter(token_restriction::Column::UserId.eq(val)); + } + if let Some(val) = ¶ms.project_id { + select = select.filter(token_restriction::Column::ProjectId.eq(val)); + } + let db_restrictions: Vec<( + token_restriction::Model, + Vec, + )> = select + .find_with_related(DbTokenRestrictionRoleAssociation) + .all(db) + .await + .map_err(|err| db_err(err, "listing token restrictions"))?; + + Ok(db_restrictions.into_iter().map(Into::into).collect()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::derivable_impls)] + + use crate::db::entity::token_restriction_role_association; + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use super::*; + + fn get_restriction_with_roles_mock, R: AsRef>( + tid: T, + rid: R, + ) -> ( + token_restriction::Model, + token_restriction_role_association::Model, + ) { + ( + token_restriction::Model { + id: tid.as_ref().to_string(), + domain_id: "did".to_string(), + user_id: Some("uid".to_string()), + project_id: Some("pid".to_string()), + allow_rescope: true, + allow_renew: true, + }, + token_restriction_role_association::Model { + restriction_id: tid.as_ref().to_string(), + role_id: rid.as_ref().to_string(), + }, + ) + } + + #[tokio::test] + async fn test_list() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![ + get_restriction_with_roles_mock("id", "rid1"), + get_restriction_with_roles_mock("id", "rid2"), + ]]) + .into_connection(); + + assert_eq!( + list( + &db, + &TokenRestrictionListParameters { + domain_id: Some("did".into()), + user_id: Some("uid".into()), + project_id: Some("pid".into()), + }, + ) + .await + .unwrap(), + vec![TokenRestriction { + id: "id".into(), + domain_id: "did".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" AS "A_id", "token_restriction"."domain_id" AS "A_domain_id", "token_restriction"."user_id" AS "A_user_id", "token_restriction"."allow_renew" AS "A_allow_renew", "token_restriction"."allow_rescope" AS "A_allow_rescope", "token_restriction"."project_id" AS "A_project_id", "token_restriction_role_association"."restriction_id" AS "B_restriction_id", "token_restriction_role_association"."role_id" AS "B_role_id" FROM "token_restriction" LEFT JOIN "token_restriction_role_association" ON "token_restriction"."id" = "token_restriction_role_association"."restriction_id" WHERE "token_restriction"."domain_id" = $1 AND "token_restriction"."user_id" = $2 AND "token_restriction"."project_id" = $3 ORDER BY "token_restriction"."id" ASC"#, + ["did".into(), "uid".into(), "pid".into()] + ),] + ); + } +} diff --git a/src/token/token_restriction/mod.rs b/src/token/token_restriction/mod.rs index 81247da0..11d9b1a3 100644 --- a/src/token/token_restriction/mod.rs +++ b/src/token/token_restriction/mod.rs @@ -16,14 +16,23 @@ use crate::db::entity::{role, token_restriction, token_restriction_role_associat use crate::token::types::TokenRestriction; +mod create; +mod delete; mod get; +mod list; +mod update; +pub use create::create; +pub use delete::delete; pub use get::get; +pub use list::list; +pub use update::update; impl From for TokenRestriction { fn from(value: token_restriction::Model) -> Self { TokenRestriction { id: value.id, + domain_id: value.domain_id, user_id: value.user_id, project_id: value.project_id, allow_rescope: value.allow_rescope, @@ -86,3 +95,19 @@ impl restriction } } + +#[cfg(test)] +mod tests { + use crate::db::entity::token_restriction; + + pub fn get_restriction_mock>(id: S) -> token_restriction::Model { + token_restriction::Model { + id: id.as_ref().to_string(), + domain_id: "did".to_string(), + user_id: Some("uid".to_string()), + project_id: Some("pid".to_string()), + allow_rescope: true, + allow_renew: true, + } + } +} diff --git a/src/token/token_restriction/update.rs b/src/token/token_restriction/update.rs new file mode 100644 index 00000000..14277e9c --- /dev/null +++ b/src/token/token_restriction/update.rs @@ -0,0 +1,215 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Update the existing token restriction. + +use sea_orm::DatabaseConnection; +use sea_orm::TransactionTrait; +use sea_orm::entity::*; +use sea_orm::query::*; +use std::collections::BTreeSet; + +use crate::db::entity::prelude::{ + TokenRestriction as DbTokenRestriction, + TokenRestrictionRoleAssociation as DbTokenRestrictionRoleAssociation, +}; +use crate::db::entity::{token_restriction, token_restriction_role_association}; +use crate::token::error::{TokenProviderError, db_err}; +use crate::token::types::*; + +/// Update existing token restriction by the ID. +pub async fn update>( + db: &DatabaseConnection, + id: S, + restriction: TokenRestrictionUpdate, +) -> Result { + if let Some(current) = DbTokenRestriction::find_by_id(id.as_ref()) + .one(db) + .await + .map_err(|err| db_err(err, "searching for the existing token restriction"))? + { + let mut entry: token_restriction::ActiveModel = current.into(); + if let Some(val) = restriction.allow_renew { + entry.allow_renew = Set(val); + } + if let Some(val) = restriction.allow_rescope { + entry.allow_rescope = Set(val); + } + if let Some(val) = &restriction.user_id { + entry.user_id = Set(val.clone()); + } + if let Some(val) = &restriction.project_id { + entry.project_id = Set(val.clone()); + } + + let txn = db + .begin() + .await + .map_err(|err| db_err(err, "starting the transaction"))?; + let db_entry: token_restriction::Model = entry + .update(db) + .await + .map_err(|err| db_err(err, "updating the token restriction"))?; + + if let Some(role_ids) = &restriction.role_ids { + // Read the current role associations + let current_roles: BTreeSet = BTreeSet::from_iter( + DbTokenRestrictionRoleAssociation::find() + .filter( + token_restriction_role_association::Column::RestrictionId.eq(id.as_ref()), + ) + .select_only() + .column(token_restriction_role_association::Column::RoleId) + .into_tuple() + .all(db) + .await + .map_err(|err| db_err(err, "reading current token restriction roles"))?, + ); + // Calculate roles to be add and removed + let roles_to_remove: BTreeSet = current_roles + .iter() + .filter(|&item| !role_ids.contains(item)) + .cloned() + .collect(); + let roles_to_add: BTreeSet = role_ids + .iter() + .filter(|&item| !current_roles.contains(item)) + .cloned() + .collect(); + // Add missing roles + if !roles_to_add.is_empty() { + DbTokenRestrictionRoleAssociation::insert_many(roles_to_add.into_iter().map(|r| { + token_restriction_role_association::ActiveModel { + restriction_id: Set(id.as_ref().into()), + role_id: Set(r), + } + })) + .on_empty_do_nothing() + .exec(db) + .await + .map_err(|err| db_err(err, "Adding new token restriction roles"))?; + } + // Delete unnecessary roles + if !roles_to_remove.is_empty() { + DbTokenRestrictionRoleAssociation::delete_many() + .filter( + Condition::all() + .add( + token_restriction_role_association::Column::RestrictionId + .eq(id.as_ref()), + ) + .add( + token_restriction_role_association::Column::RoleId + .is_in(roles_to_remove.into_iter()), + ), + ) + .exec(db) + .await + .map_err(|err| db_err(err, "delete obsolete token restriction roles"))?; + } + } + + txn.commit() + .await + .map_err(|err| db_err(err, "committing the transaction"))?; + Ok(db_entry.into()) + } else { + Err(TokenProviderError::TokenRestrictionNotFound( + id.as_ref().to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ + DatabaseBackend, IntoMockRow, MockDatabase, MockExecResult, Statement, Transaction, + }; + use std::collections::BTreeMap; + + use super::super::tests::get_restriction_mock; + use super::*; + + #[tokio::test] + async fn test_update() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_restriction_mock("tr1")]]) + .append_query_results([vec![get_restriction_mock("tr1")]]) + .append_query_results([vec![ + BTreeMap::from([("role_id", Into::::into("rid1"))]).into_mock_row(), + BTreeMap::from([("role_id", Into::::into("rid2"))]).into_mock_row(), + ]]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .append_exec_results([MockExecResult { + rows_affected: 1, + ..Default::default() + }]) + .into_connection(); + + let req = TokenRestrictionUpdate { + user_id: Some(Some("new_uid".into())), + project_id: Some(Some("new_pid".into())), + allow_rescope: Some(true), + allow_renew: Some(true), + role_ids: Some(vec!["r1".into(), "r2".into()]), + }; + + update(&db, "tr1", req).await.unwrap(); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction"."id", "token_restriction"."domain_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"#, + ["tr1".into(), 1u64.into()] + ), + Transaction::many(vec![ + Statement::from_string(DatabaseBackend::Postgres, r#"BEGIN"#,), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"UPDATE "token_restriction" SET "user_id" = $1, "allow_renew" = $2, "allow_rescope" = $3, "project_id" = $4 WHERE "token_restriction"."id" = $5 RETURNING "id", "domain_id", "user_id", "allow_renew", "allow_rescope", "project_id""#, + [ + "new_uid".into(), + true.into(), + true.into(), + "new_pid".into(), + "tr1".into() + ] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "token_restriction_role_association"."role_id" FROM "token_restriction_role_association" WHERE "token_restriction_role_association"."restriction_id" = $1"#, + ["tr1".into(),] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "token_restriction_role_association" ("restriction_id", "role_id") VALUES ($1, $2), ($3, $4) RETURNING "restriction_id", "role_id""#, + ["tr1".into(), "r1".into(), "tr1".into(), "r2".into(),] + ), + Statement::from_sql_and_values( + DatabaseBackend::Postgres, + r#"DELETE FROM "token_restriction_role_association" WHERE "token_restriction_role_association"."restriction_id" = $1 AND "token_restriction_role_association"."role_id" IN ($2, $3)"#, + ["tr1".into(), "rid1".into(), "rid2".into()] + ), + Statement::from_string(DatabaseBackend::Postgres, r#"COMMIT"#,) + ]) + ] + ); + } +} diff --git a/src/token/types.rs b/src/token/types.rs index cd151b9c..d48eab24 100644 --- a/src/token/types.rs +++ b/src/token/types.rs @@ -148,6 +148,8 @@ pub struct TokenRestriction { pub allow_renew: bool, /// Id. pub id: String, + /// Domain Id the token restriction belongs to. + pub domain_id: String, /// Optional project ID to be used with this restriction. pub project_id: Option, /// Roles bound to the restriction. @@ -158,6 +160,51 @@ pub struct TokenRestriction { pub user_id: Option, } +/// New token restriction information. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct TokenRestrictionCreate { + /// 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, + /// Domain Id the token restriction belongs to. + pub domain_id: String, + /// Optional project ID to be used with this restriction. + pub project_id: Option, + /// Roles bound to the restriction. + pub role_ids: Vec, + /// User id + pub user_id: Option, +} + +/// Token restriction update information. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct TokenRestrictionUpdate { + /// Whether the restriction allows to rescope the token. + pub allow_rescope: Option, + /// Whether it is allowed to renew the token with this restriction. + pub allow_renew: Option, + /// Optional project ID to be used with this restriction. + pub project_id: Option>, + /// Roles bound to the restriction. + pub role_ids: Option>, + /// User id. + pub user_id: Option>, +} + +/// Token restriction list filters. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct TokenRestrictionListParameters { + /// Domain id. + pub domain_id: Option, + /// User id. + pub user_id: Option, + /// Project id. + pub project_id: Option, +} + pub trait TokenBackend: DynClone + Send + Sync + std::fmt::Debug { /// Set config fn set_config(&mut self, g: Config);