diff --git a/policy/token/restriction/show.rego b/policy/token/restriction/show.rego new file mode 100644 index 00000000..c528b0a1 --- /dev/null +++ b/policy/token/restriction/show.rego @@ -0,0 +1,30 @@ +package identity.token_restriction_show + +import data.identity + +# Create mapping. + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles +} + +violation contains {"field": "domain_id", "msg": "showing token restrictions requires `admin` role."} if { + #identity.foreign_mapping + 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 new file mode 100644 index 00000000..36bc447e --- /dev/null +++ b/policy/token/restriction/show_test.rego @@ -0,0 +1,15 @@ +package test_token_restriction_show + +import data.identity.token_restriction_show + +test_allowed if { + token_restriction_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"}} +} diff --git a/src/api/mod.rs b/src/api/mod.rs index f43fdcad..8eb5b994 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -45,7 +45,9 @@ use crate::api::types::*; modifiers(&SecurityAddon), tags( (name="identity_providers", description=v4::federation::identity_provider::DESCRIPTION), - (name="mappings", description=v4::federation::mapping::DESCRIPTION) + (name="mappings", description=v4::federation::mapping::DESCRIPTION), + (name="token", description=v4::token::DESCRIPTION), + (name="token_restrictions", description=v4::token::restriction::DESCRIPTION), ) )] pub struct ApiDoc; diff --git a/src/api/v4/mod.rs b/src/api/v4/mod.rs index 39424d99..d5c63e4d 100644 --- a/src/api/v4/mod.rs +++ b/src/api/v4/mod.rs @@ -29,6 +29,7 @@ pub mod federation; pub mod group; pub mod role; pub mod role_assignment; +pub mod token; pub mod user; use crate::api::types::*; @@ -40,6 +41,7 @@ pub(super) fn openapi_router() -> OpenApiRouter { .nest("/federation", federation::openapi_router()) .nest("/role_assignments", role_assignment::openapi_router()) .nest("/roles", role::openapi_router()) + .nest("/tokens", token::openapi_router()) .nest("/users", user::openapi_router()) .routes(routes!(version)) } diff --git a/src/api/v4/token/mod.rs b/src/api/v4/token/mod.rs new file mode 100644 index 00000000..0eb8138f --- /dev/null +++ b/src/api/v4/token/mod.rs @@ -0,0 +1,28 @@ +// 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 utoipa_axum::router::OpenApiRouter; + +use crate::keystone::ServiceState; + +pub mod restriction; +pub mod types; + +pub(crate) static DESCRIPTION: &str = r#"Token API. + +"#; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().nest("/restrictions", restriction::openapi_router()) +} diff --git a/src/api/v4/token/restriction.rs b/src/api/v4/token/restriction.rs new file mode 100644 index 00000000..c7f03397 --- /dev/null +++ b/src/api/v4/token/restriction.rs @@ -0,0 +1,119 @@ +// 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 restrictions API +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::keystone::ServiceState; + +pub(crate) static DESCRIPTION: &str = r#"Token restrictions API. + +Token restrictions allow controlling multiple aspects of the authentication and authorization. + +- `allow_rescope` controls whether it is allowed to change the scope of the token. That is by default possible for normal (i.e. password) authentication, is forbidden for the application credentials and may need to be also forbidden for the JWT based authentication. + +- `allow_renew` controls whether it is possible to renew the token (get a new token from existing token). This is most likely undisired for the JWT auth. + +- `project_id` may control that this token can be only issued for the fixed project scope. + +- `user_id` may specify the fixed user_id that will be used when issuing the token independently of the authentication. This is useful for Service Accounts. + +- `roles` binds the roles of the issued token on the scope. Using this bypasses necessity to grant the roles explicitly to the user. +"#; + +mod show; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(show::show)) +} + +#[cfg(test)] +mod tests { + + use sea_orm::DatabaseConnection; + 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}; + use crate::provider::Provider; + use crate::token::{MockTokenProvider, Token, UnscopedPayload}; + + pub(crate) fn get_mocked_state( + mut token_mock: MockTokenProvider, + policy_allowed: bool, + policy_allowed_see_other_domains: Option, + ) -> ServiceState { + token_mock.expect_validate_token().returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_expand_token_information() + .returning(|_, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + user: Some(UserResponse { + id: "bar".into(), + domain_id: "udid".into(), + ..Default::default() + }), + ..Default::default() + })) + }); + + let provider = Provider::mocked_builder() + .token(token_mock) + .build() + .unwrap(); + + let mut policy_factory_mock = MockPolicyFactory::default(); + if policy_allowed { + policy_factory_mock.expect_instantiate().returning(move || { + let mut policy_mock = MockPolicy::default(); + if policy_allowed_see_other_domains.is_some_and(|x| x) { + policy_mock + .expect_enforce() + .returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed_admin())); + } else { + policy_mock + .expect_enforce() + .returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed())); + } + Ok(policy_mock) + }); + } else { + policy_factory_mock.expect_instantiate().returning(|| { + let mut policy_mock = MockPolicy::default(); + policy_mock.expect_enforce().returning(|_, _, _, _| { + Err(PolicyError::Forbidden(PolicyEvaluationResult::forbidden())) + }); + Ok(policy_mock) + }); + } + Arc::new( + Service::new( + Config::default(), + DatabaseConnection::Disconnected, + provider, + policy_factory_mock, + ) + .unwrap(), + ) + } +} diff --git a/src/api/v4/token/restriction/show.rs b/src/api/v4/token/restriction/show.rs new file mode 100644 index 00000000..66fe75d8 --- /dev/null +++ b/src/api/v4/token/restriction/show.rs @@ -0,0 +1,246 @@ +// 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 + +//! Show token restriction. +use axum::{ + 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; + +/// Get single token restriction. +/// +/// Shows details of the existing token restriction. +#[utoipa::path( + get, + path = "/{id}", + operation_id = "/token_restiction:show", + params( + ("id" = String, Path, description = "The ID of the token restriction") + ), + responses( + (status = OK, description = "Token restriction object", body = TokenRestrictionResponse), + (status = 404, description = "Resource not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + security(("x-auth" = [])), + tag="token_restrictions" +)] +#[tracing::instrument( + name = "api::token_restriction::get", + level = "debug", + skip(state, user_auth, policy), + err(Debug) +)] +pub(super) async fn show( + 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, true) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "token_restriction".into(), + identifier: id, + }) + })??; + + policy + .enforce( + "identity/token_restriction_show", + &user_auth, + serde_json::to_value(¤t)?, + None, + ) + .await?; + Ok(current) +} + +#[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_get() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_get_token_restriction() + .withf(|_, id: &'_ str, expand: &bool| id == "foo" && *expand) + .returning(|_, _, _| Ok(None)); + token_mock + .expect_get_token_restriction() + .withf(|_, 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(), + 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.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .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() + .uri("/bar") + .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: TokenRestrictionResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + TokenRestriction { + id: "bar".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 { + id: "r1".into(), + name: Some("r1n".into()) + }, + Role { + id: "r2".into(), + name: Some("r2n".into()) + } + ] + }, + res.restriction, + ); + } + + #[tokio::test] + #[traced_test] + async fn test_get_forbidden() { + let mut token_mock = MockTokenProvider::default(); + token_mock + .expect_get_token_restriction() + .withf(|_, 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(), + 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, false, None); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/bar") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/src/api/v4/token/types.rs b/src/api/v4/token/types.rs new file mode 100644 index 00000000..03b18b1c --- /dev/null +++ b/src/api/v4/token/types.rs @@ -0,0 +1,17 @@ +// 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 + +mod restriction; + +pub use restriction::*; diff --git a/src/api/v4/token/types/restriction.rs b/src/api/v4/token/types/restriction.rs new file mode 100644 index 00000000..c5564523 --- /dev/null +++ b/src/api/v4/token/types/restriction.rs @@ -0,0 +1,103 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Token restriction types. +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::api::error::KeystoneApiError; +use crate::api::v3::role_assignment::types::Role; +use crate::token::types::TokenRestriction as ProviderTokenRestriction; + +/// Token restriction data. +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct TokenRestriction { + /// Allow token renew. + pub allow_renew: bool, + + /// Allow token rescope. + pub allow_rescope: bool, + + /// Token restriction ID. + pub 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, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct TokenRestrictionResponse { + /// Restriction object. + pub restriction: TokenRestriction, +} + +impl From for TokenRestriction { + fn from(value: ProviderTokenRestriction) -> Self { + Self { + allow_rescope: value.allow_rescope, + allow_renew: value.allow_renew, + id: value.id, + project_id: value.project_id, + user_id: value.user_id, + roles: value + .roles + .map(|roles| roles.into_iter().map(Into::into).collect()) + .unwrap_or_default(), + } + } +} + +impl From for Role { + fn from(value: crate::assignment::types::role::Role) -> Self { + Self { + id: value.id, + name: value.name.into(), + } + } +} + +impl IntoResponse for ProviderTokenRestriction { + fn into_response(self) -> Response { + ( + StatusCode::OK, + Json(TokenRestrictionResponse { + restriction: TokenRestriction::from(self), + }), + ) + .into_response() + } +} + +impl From for KeystoneApiError { + fn from(err: TokenRestrictionBuilderError) -> Self { + Self::InternalError(err.to_string()) + } +}