From 81b6b5e685b1df260b7cd39de137558942f68161 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 4 Mar 2025 20:02:19 +0100 Subject: [PATCH] feat: Add assignments backend - list_roles - get_role - list_role_assignments --- src/api/error.rs | 20 +- src/api/v3/auth/token/common.rs | 11 +- src/api/v3/auth/token/mod.rs | 3 + src/api/v3/mod.rs | 4 + src/api/v3/role/mod.rs | 319 ++++++++++++++++++++++ src/api/v3/role/types.rs | 113 ++++++++ src/api/v3/role_assignment/mod.rs | 289 ++++++++++++++++++++ src/api/v3/role_assignment/types.rs | 258 +++++++++++++++++ src/assignment/backends.rs | 16 ++ src/assignment/backends/error.rs | 47 ++++ src/assignment/backends/sql.rs | 68 +++++ src/assignment/backends/sql/assignment.rs | 187 +++++++++++++ src/assignment/backends/sql/role.rs | 190 +++++++++++++ src/assignment/error.rs | 57 ++++ src/assignment/mod.rs | 146 ++++++++++ src/assignment/types.rs | 59 ++++ src/assignment/types/assignment.rs | 52 ++++ src/assignment/types/role.rs | 44 +++ src/config.rs | 10 + src/db/entity.rs | 12 + src/error.rs | 7 + src/identity/backends/sql.rs | 2 +- src/lib.rs | 1 + src/plugin_manager.rs | 13 + src/provider.rs | 10 + src/tests/api.rs | 5 + 26 files changed, 1936 insertions(+), 7 deletions(-) create mode 100644 src/api/v3/role/mod.rs create mode 100644 src/api/v3/role/types.rs create mode 100644 src/api/v3/role_assignment/mod.rs create mode 100644 src/api/v3/role_assignment/types.rs create mode 100644 src/assignment/backends.rs create mode 100644 src/assignment/backends/error.rs create mode 100644 src/assignment/backends/sql.rs create mode 100644 src/assignment/backends/sql/assignment.rs create mode 100644 src/assignment/backends/sql/role.rs create mode 100644 src/assignment/error.rs create mode 100644 src/assignment/mod.rs create mode 100644 src/assignment/types.rs create mode 100644 src/assignment/types/assignment.rs create mode 100644 src/assignment/types/role.rs diff --git a/src/api/error.rs b/src/api/error.rs index a4ee3b33..18cec57f 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -20,6 +20,7 @@ use axum::{ use serde_json::json; use thiserror::Error; +use crate::assignment::error::AssignmentProviderError; use crate::identity::error::IdentityProviderError; use crate::resource::error::ResourceProviderError; @@ -53,9 +54,15 @@ pub enum KeystoneApiError { source: TokenError, }, - #[error("internal server error")] + #[error("internal server error: {0}")] InternalError(String), + #[error(transparent)] + AssignmentError { + #[from] + source: AssignmentProviderError, + }, + #[error(transparent)] IdentityError { #[from] @@ -91,7 +98,7 @@ impl IntoResponse for KeystoneApiError { Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), ).into_response() } - KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } => { + KeystoneApiError::IdentityError { .. } | KeystoneApiError::ResourceError { .. } | KeystoneApiError::AssignmentError { .. } => { (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": {"code": StatusCode::INTERNAL_SERVER_ERROR.as_u16(), "message": self.to_string()}})), ).into_response() @@ -106,6 +113,15 @@ impl IntoResponse for KeystoneApiError { } impl KeystoneApiError { + pub fn assignment(source: AssignmentProviderError) -> Self { + match source { + AssignmentProviderError::RoleNotFound(x) => Self::NotFound { + resource: "role".into(), + identifier: x, + }, + _ => Self::AssignmentError { source }, + } + } pub fn identity(source: IdentityProviderError) -> Self { match source { IdentityProviderError::UserNotFound(x) => Self::NotFound { diff --git a/src/api/v3/auth/token/common.rs b/src/api/v3/auth/token/common.rs index 773498e9..66f086fc 100644 --- a/src/api/v3/auth/token/common.rs +++ b/src/api/v3/auth/token/common.rs @@ -125,18 +125,15 @@ mod tests { use std::sync::Arc; use crate::api::v3::auth::token::types::Token; + use crate::assignment::MockAssignmentProvider; use crate::config::Config; - use crate::identity::{MockIdentityProvider, types::User}; use crate::keystone::Service; - use crate::provider::ProviderBuilder; - use crate::resource::{ MockResourceProvider, types::{Domain, Project}, }; - use crate::token::{ DomainScopeToken, MockTokenProvider, ProjectScopeToken, Token as ProviderToken, UnscopedToken, @@ -169,8 +166,10 @@ mod tests { })) }); let token_mock = MockTokenProvider::default(); + let assignment_mock = MockAssignmentProvider::default(); let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock) @@ -220,8 +219,10 @@ mod tests { })) }); let token_mock = MockTokenProvider::default(); + let assignment_mock = MockAssignmentProvider::default(); let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock) @@ -282,8 +283,10 @@ mod tests { })) }); let token_mock = MockTokenProvider::default(); + let assignment_mock = MockAssignmentProvider::default(); let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock) diff --git a/src/api/v3/auth/token/mod.rs b/src/api/v3/auth/token/mod.rs index f0f0fc45..17f82951 100644 --- a/src/api/v3/auth/token/mod.rs +++ b/src/api/v3/auth/token/mod.rs @@ -80,6 +80,7 @@ mod tests { use super::openapi_router; use crate::api::v3::auth::token::types::TokenResponse; + use crate::assignment::MockAssignmentProvider; use crate::config::Config; use crate::identity::{MockIdentityProvider, types::User}; use crate::keystone::Service; @@ -92,6 +93,7 @@ mod tests { async fn test_get() { let db = DatabaseConnection::Disconnected; let config = Config::default(); + let assignment_mock = MockAssignmentProvider::default(); let mut identity_mock = MockIdentityProvider::default(); identity_mock.expect_get_user().returning(|_, id: &'_ str| { Ok(Some(User { @@ -121,6 +123,7 @@ mod tests { let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock) diff --git a/src/api/v3/mod.rs b/src/api/v3/mod.rs index a7bc75e7..ea65b54e 100644 --- a/src/api/v3/mod.rs +++ b/src/api/v3/mod.rs @@ -18,11 +18,15 @@ use crate::keystone::ServiceState; pub mod auth; pub mod group; +pub mod role; +pub mod role_assignment; pub mod user; pub(super) fn openapi_router() -> OpenApiRouter { OpenApiRouter::new() .nest("/auth", auth::openapi_router()) .nest("/groups", group::openapi_router()) + .nest("/role_assignments", role_assignment::openapi_router()) + .nest("/roles", role::openapi_router()) .nest("/users", user::openapi_router()) } diff --git a/src/api/v3/role/mod.rs b/src/api/v3/role/mod.rs new file mode 100644 index 00000000..6f69ddae --- /dev/null +++ b/src/api/v3/role/mod.rs @@ -0,0 +1,319 @@ +// 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 axum::{ + extract::{Path, Query, State}, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::assignment::AssignmentApi; +use crate::keystone::ServiceState; +use types::{Role, RoleList, RoleListParameters, RoleResponse}; + +mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list)) + .routes(routes!(show)) +} + +/// List roles +#[utoipa::path( + get, + path = "/", + params(RoleListParameters), + description = "List roles", + responses( + (status = OK, description = "List of roles", body = RoleList), + (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + tag="roles" +)] +#[tracing::instrument(name = "api::role_list", level = "debug", skip(state))] +async fn list( + Auth(user_auth): Auth, + Query(query): Query, + State(state): State, +) -> Result { + let roles: Vec = state + .provider + .get_assignment_provider() + .list_roles(&state.db, &query.into()) + .await + .map_err(KeystoneApiError::assignment)? + .into_iter() + .map(Into::into) + .collect(); + Ok(RoleList { roles }) +} + +/// Get single role +#[utoipa::path( + get, + path = "/{role_id}", + description = "Get role by ID", + params(), + responses( + (status = OK, description = "Role object", body = RoleResponse), + (status = 404, description = "Role not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + tag="roles" +)] +#[tracing::instrument(name = "api::role_get", level = "debug", skip(state))] +async fn show( + Auth(user_auth): Auth, + Path(role_id): Path, + State(state): State, +) -> Result { + state + .provider + .get_assignment_provider() + .get_role(&state.db, &role_id) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "role".into(), + identifier: role_id, + }) + })? +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use serde_json::json; + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::role::types::{ + Role as ApiRole, //GroupCreate as ApiGroupCreate, GroupCreateRequest, + RoleList, + RoleResponse, + }; + use crate::assignment::{ + MockAssignmentProvider, + types::{Role, RoleListParameters}, + }; + use crate::config::Config; + use crate::identity::MockIdentityProvider; + use crate::keystone::{Service, ServiceState}; + use crate::provider::ProviderBuilder; + use crate::resource::MockResourceProvider; + use crate::token::{MockTokenProvider, Token, UnscopedToken}; + + use crate::tests::api::get_mocked_state_unauthed; + + fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { + let db = DatabaseConnection::Disconnected; + let config = Config::default(); + let mut token_mock = MockTokenProvider::default(); + let resource_mock = MockResourceProvider::default(); + token_mock.expect_validate_token().returning(|_, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + let identity_mock = MockIdentityProvider::default(); + + let provider = ProviderBuilder::default() + .config(config.clone()) + .assignment(assignment_mock) + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .build() + .unwrap(); + + Arc::new(Service::new(config, db, provider).unwrap()) + } + + #[tokio::test] + async fn test_list() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_roles() + .withf(|_: &DatabaseConnection, _: &RoleListParameters| true) + .returning(|_, _| { + Ok(vec![Role { + id: "1".into(), + name: "2".into(), + ..Default::default() + }]) + }); + + let state = get_mocked_state(assignment_mock); + + 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: RoleList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiRole { + id: "1".into(), + name: "2".into(), + // for some reason when deserializing missing value appears still as an empty + // object + extra: Some(json!({})), + ..Default::default() + }], + res.roles + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_roles() + .withf(|_: &DatabaseConnection, qp: &RoleListParameters| { + RoleListParameters { + domain_id: Some("domain".into()), + name: Some("name".into()), + } == *qp + }) + .returning(|_, _| Ok(Vec::new())); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?domain_id=domain&name=name") + .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: RoleList = serde_json::from_slice(&body).unwrap(); + } + + #[tokio::test] + async fn test_list_unauth() { + let state = get_mocked_state_unauthed(); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn test_get() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_get_role() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "foo") + .returning(|_, _| Ok(None)); + + assignment_mock + .expect_get_role() + .withf(|_: &DatabaseConnection, id: &'_ str| id == "bar") + .returning(|_, _| { + Ok(Some(Role { + id: "bar".into(), + ..Default::default() + })) + }); + + let state = get_mocked_state(assignment_mock); + + 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: RoleResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!( + ApiRole { + id: "bar".into(), + extra: Some(json!({})), + ..Default::default() + }, + res.role, + ); + } +} diff --git a/src/api/v3/role/types.rs b/src/api/v3/role/types.rs new file mode 100644 index 00000000..073f5df5 --- /dev/null +++ b/src/api/v3/role/types.rs @@ -0,0 +1,113 @@ +// 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 axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::{IntoParams, ToSchema}; + +use crate::assignment::types; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Role { + /// Role ID + pub id: String, + /// Role domain ID + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_id: Option, + /// Role name + pub name: String, + /// Role description + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct RoleResponse { + /// Role object + pub role: Role, +} + +impl From for Role { + fn from(value: types::Role) -> Self { + Self { + id: value.id, + domain_id: value.domain_id, + name: value.name, + description: value.description, + extra: value.extra, + } + } +} + +impl IntoResponse for RoleResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +impl IntoResponse for types::Role { + fn into_response(self) -> Response { + ( + StatusCode::OK, + Json(RoleResponse { + role: Role::from(self), + }), + ) + .into_response() + } +} + +/// Roles +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct RoleList { + /// Collection of role objects + pub roles: Vec, +} + +impl From> for RoleList { + fn from(value: Vec) -> Self { + let objects: Vec = value.into_iter().map(Role::from).collect(); + Self { roles: objects } + } +} + +impl IntoResponse for RoleList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct RoleListParameters { + /// Filter users by Domain ID + pub domain_id: Option, + /// Filter users by Name + pub name: Option, +} + +impl From for types::RoleListParameters { + fn from(value: RoleListParameters) -> Self { + Self { + domain_id: value.domain_id, + name: value.name, + } + } +} diff --git a/src/api/v3/role_assignment/mod.rs b/src/api/v3/role_assignment/mod.rs new file mode 100644 index 00000000..da720075 --- /dev/null +++ b/src/api/v3/role_assignment/mod.rs @@ -0,0 +1,289 @@ +// 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 axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::api::auth::Auth; +use crate::api::error::KeystoneApiError; +use crate::assignment::AssignmentApi; +use crate::keystone::ServiceState; +use types::{Assignment, AssignmentList, RoleAssignmentListParameters}; + +mod types; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(list)) +} + +/// List role assignments +#[utoipa::path( + get, + path = "/", + params(RoleAssignmentListParameters), + description = "List roles", + responses( + (status = OK, description = "List of role assignments", body = AssignmentList), + (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) + ), + tag="roles" +)] +#[tracing::instrument(name = "api::role_assignment_list", level = "debug", skip(state))] +async fn list( + Auth(user_auth): Auth, + Query(query): Query, + State(state): State, +) -> Result { + let assignments: Result, _> = state + .provider + .get_assignment_provider() + .list_role_assignments(&state.db, &query.try_into()?) + .await + .map_err(KeystoneApiError::assignment)? + .into_iter() + .map(TryInto::try_into) + .collect(); + Ok(AssignmentList { + role_assignments: assignments?, + }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use super::openapi_router; + use crate::api::v3::role_assignment::types::{ + Assignment as ApiAssignment, AssignmentList as ApiAssignmentList, Project, Role, Scope, + User, + }; + use crate::assignment::{ + MockAssignmentProvider, + types::{Assignment, AssignmentType, RoleAssignmentListParameters}, + }; + use crate::config::Config; + use crate::identity::MockIdentityProvider; + use crate::keystone::{Service, ServiceState}; + use crate::provider::ProviderBuilder; + use crate::resource::MockResourceProvider; + use crate::token::{MockTokenProvider, Token, UnscopedToken}; + + fn get_mocked_state(assignment_mock: MockAssignmentProvider) -> ServiceState { + let db = DatabaseConnection::Disconnected; + let config = Config::default(); + let mut token_mock = MockTokenProvider::default(); + let resource_mock = MockResourceProvider::default(); + token_mock.expect_validate_token().returning(|_, _| { + Ok(Token::Unscoped(UnscopedToken { + user_id: "bar".into(), + ..Default::default() + })) + }); + let identity_mock = MockIdentityProvider::default(); + + let provider = ProviderBuilder::default() + .config(config.clone()) + .assignment(assignment_mock) + .identity(identity_mock) + .resource(resource_mock) + .token(token_mock) + .build() + .unwrap(); + + Arc::new(Service::new(config, db, provider).unwrap()) + } + + #[tokio::test] + async fn test_list() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .withf(|_: &DatabaseConnection, _: &RoleAssignmentListParameters| true) + .returning(|_, _| { + Ok(vec![Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + let state = get_mocked_state(assignment_mock); + + 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: ApiAssignmentList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ApiAssignment { + role: Role { id: "role".into() }, + user: Some(User { id: "actor".into() }), + scope: Scope::Project(Project { + id: "target".into() + }), + group: None, + }], + res.role_assignments + ); + } + + #[tokio::test] + async fn test_list_qp() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + role_id: Some("role".into()), + actor_id: Some("user1".into()), + target_id: Some("project1".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + role_id: Some("role".into()), + actor_id: Some("user2".into()), + target_id: Some("domain2".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + assignment_mock + .expect_list_role_assignments() + .withf( + |_: &DatabaseConnection, qp: &RoleAssignmentListParameters| { + RoleAssignmentListParameters { + actor_id: Some("user3".into()), + target_id: Some("project3".into()), + ..Default::default() + } == *qp + }, + ) + .returning(|_, _| { + Ok(vec![Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }]) + }); + + let state = get_mocked_state(assignment_mock); + + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?role.id=role&user.id=user1&scope.project.id=project1") + .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: ApiAssignmentList = serde_json::from_slice(&body).unwrap(); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?role.id=role&user.id=user2&scope.domain.id=domain2") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let response = api + .as_service() + .oneshot( + Request::builder() + .uri("/?group.id=user3&scope.project.id=project3") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/src/api/v3/role_assignment/types.rs b/src/api/v3/role_assignment/types.rs new file mode 100644 index 00000000..eac9ba42 --- /dev/null +++ b/src/api/v3/role_assignment/types.rs @@ -0,0 +1,258 @@ +// 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 axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::api::error::KeystoneApiError; +use crate::assignment::types; + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[builder(setter(strip_option, into))] +pub struct Assignment { + /// Role ID + pub role: Role, + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub group: Option, + pub scope: Scope, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Role { + pub id: String, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct User { + pub id: String, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Group { + pub id: String, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Project { + pub id: String, +} + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Domain { + pub id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Project(Project), + Domain(Domain), +} + +impl TryFrom for Assignment { + type Error = KeystoneApiError; + + fn try_from(value: types::Assignment) -> Result { + let mut builder = AssignmentBuilder::default(); + builder.role(Role { id: value.role_id }); + match value.r#type { + types::AssignmentType::GroupDomain => { + builder.group(Group { + id: value.actor_id.clone(), + }); + builder.scope(Scope::Domain(Domain { + id: value.target_id.clone(), + })); + } + types::AssignmentType::GroupProject => { + builder.group(Group { + id: value.actor_id.clone(), + }); + builder.scope(Scope::Project(Project { + id: value.target_id.clone(), + })); + } + types::AssignmentType::UserDomain => { + builder.user(User { + id: value.actor_id.clone(), + }); + builder.scope(Scope::Domain(Domain { + id: value.target_id.clone(), + })); + } + types::AssignmentType::UserProject => { + builder.user(User { + id: value.actor_id.clone(), + }); + builder.scope(Scope::Project(Project { + id: value.target_id.clone(), + })); + } + } + Ok(builder.build()?) + } +} + +impl From for KeystoneApiError { + fn from(err: AssignmentBuilderError) -> Self { + Self::InternalError(err.to_string()) + } +} + +impl From for KeystoneApiError { + fn from(err: types::RoleAssignmentListParametersBuilderError) -> Self { + Self::InternalError(err.to_string()) + } +} + +/// Assignments +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct AssignmentList { + /// Collection of role assignment objects + pub role_assignments: Vec, +} + +impl IntoResponse for AssignmentList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] +pub struct RoleAssignmentListParameters { + #[serde(rename = "role.id")] + pub role_id: Option, + #[serde(rename = "user.id")] + pub user_id: Option, + #[serde(rename = "group.id")] + pub group_id: Option, + #[serde(rename = "scope.project.id")] + pub project_id: Option, + #[serde(rename = "scope.domain.id")] + pub domain_id: Option, +} + +impl TryFrom for types::RoleAssignmentListParameters { + type Error = KeystoneApiError; + + fn try_from(value: RoleAssignmentListParameters) -> Result { + let mut builder = types::RoleAssignmentListParametersBuilder::default(); + if let Some(val) = &value.role_id { + builder.role_id(val.clone()); + } + if let Some(val) = &value.user_id { + builder.actor_id(val.clone()); + } else if let Some(val) = &value.group_id { + builder.actor_id(val.clone()); + } + if let Some(val) = &value.project_id { + builder.target_id(val.clone()); + } else if let Some(val) = &value.domain_id { + builder.target_id(val.clone()); + } + Ok(builder.build()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assignment::types; + + #[test] + fn test_assignment_conversion() { + assert_eq!( + Assignment { + role: Role { id: "role".into() }, + user: Some(User { id: "actor".into() }), + scope: Scope::Project(Project { + id: "target".into() + }), + group: None, + }, + Assignment::try_from(types::Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: types::AssignmentType::UserProject, + inherited: false, + }) + .unwrap() + ); + assert_eq!( + Assignment { + role: Role { id: "role".into() }, + user: Some(User { id: "actor".into() }), + scope: Scope::Domain(Domain { + id: "target".into() + }), + group: None, + }, + Assignment::try_from(types::Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: types::AssignmentType::UserDomain, + inherited: false, + }) + .unwrap() + ); + assert_eq!( + Assignment { + role: Role { id: "role".into() }, + group: Some(Group { id: "actor".into() }), + scope: Scope::Project(Project { + id: "target".into() + }), + user: None, + }, + Assignment::try_from(types::Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: types::AssignmentType::GroupProject, + inherited: false, + }) + .unwrap() + ); + assert_eq!( + Assignment { + role: Role { id: "role".into() }, + group: Some(Group { id: "actor".into() }), + scope: Scope::Domain(Domain { + id: "target".into() + }), + user: None, + }, + Assignment::try_from(types::Assignment { + role_id: "role".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: types::AssignmentType::GroupDomain, + inherited: false, + }) + .unwrap() + ); + } +} diff --git a/src/assignment/backends.rs b/src/assignment/backends.rs new file mode 100644 index 00000000..a4618644 --- /dev/null +++ b/src/assignment/backends.rs @@ -0,0 +1,16 @@ +// 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 + +pub mod error; +pub mod sql; diff --git a/src/assignment/backends/error.rs b/src/assignment/backends/error.rs new file mode 100644 index 00000000..094c6ecf --- /dev/null +++ b/src/assignment/backends/error.rs @@ -0,0 +1,47 @@ +// 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 thiserror::Error; + +use crate::assignment::types::*; + +#[derive(Error, Debug)] +pub enum AssignmentDatabaseError { + #[error("role {0} not found")] + RoleNotFound(String), + + #[error("data serialization error: {}", source)] + Serde { + #[from] + source: serde_json::Error, + }, + + #[error("error building role assignment data: {}", source)] + AssignmentBuilder { + #[from] + source: AssignmentBuilderError, + }, + + #[error("error building role data: {}", source)] + RoleBuilder { + #[from] + source: RoleBuilderError, + }, + + #[error("database error: {}", source)] + Database { + #[from] + source: sea_orm::DbErr, + }, +} diff --git a/src/assignment/backends/sql.rs b/src/assignment/backends/sql.rs new file mode 100644 index 00000000..110f3059 --- /dev/null +++ b/src/assignment/backends/sql.rs @@ -0,0 +1,68 @@ +// 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 async_trait::async_trait; +use sea_orm::DatabaseConnection; + +use super::super::types::*; +use crate::assignment::AssignmentProviderError; +use crate::config::Config; + +mod assignment; +mod role; + +#[derive(Clone, Debug, Default)] +pub struct SqlBackend { + pub config: Config, +} + +impl SqlBackend {} + +#[async_trait] +impl AssignmentBackend for SqlBackend { + /// Set config + fn set_config(&mut self, config: Config) { + self.config = config; + } + + /// List roles + #[tracing::instrument(level = "debug", skip(self, db))] + async fn list_roles( + &self, + db: &DatabaseConnection, + params: &RoleListParameters, + ) -> Result, AssignmentProviderError> { + Ok(role::list(&self.config, db, params).await?) + } + + /// Get single role by ID + #[tracing::instrument(level = "debug", skip(self, db))] + async fn get_role<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, AssignmentProviderError> { + Ok(role::get(&self.config, db, id).await?) + } + + /// List role assignments + #[tracing::instrument(level = "info", skip(self, db))] + async fn list_assignments( + &self, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError> { + Ok(assignment::list(&self.config, db, params).await?) + } +} diff --git a/src/assignment/backends/sql/assignment.rs b/src/assignment/backends/sql/assignment.rs new file mode 100644 index 00000000..9fdaa8f1 --- /dev/null +++ b/src/assignment/backends/sql/assignment.rs @@ -0,0 +1,187 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; + +use crate::assignment::backends::error::AssignmentDatabaseError; +use crate::assignment::types::*; +use crate::config::Config; +use crate::db::entity::{ + assignment as db_assignment, prelude::Assignment as DbAssignment, + sea_orm_active_enums::Type as DbAssignmentType, +}; + +pub async fn list( + _conf: &Config, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, +) -> Result, AssignmentDatabaseError> { + let mut select = DbAssignment::find(); + + if let Some(val) = ¶ms.role_id { + select = select.filter(db_assignment::Column::RoleId.eq(val)); + } + if let Some(val) = ¶ms.actor_id { + select = select.filter(db_assignment::Column::ActorId.eq(val)); + } + if let Some(val) = ¶ms.target_id { + select = select.filter(db_assignment::Column::TargetId.eq(val)); + } + + let db_entities: Vec = select.all(db).await?; + let results: Result, _> = db_entities + .into_iter() + .map(TryInto::::try_into) + .collect(); + + results +} + +impl TryFrom for Assignment { + type Error = AssignmentDatabaseError; + + fn try_from(value: db_assignment::Model) -> Result { + let mut builder = AssignmentBuilder::default(); + builder.role_id(value.role_id.clone()); + builder.actor_id(value.actor_id.clone()); + builder.target_id(value.target_id.clone()); + builder.inherited(value.inherited); + builder.r#type(value.r#type); + + Ok(builder.build()?) + } +} + +impl From for AssignmentType { + fn from(value: DbAssignmentType) -> Self { + match value { + DbAssignmentType::GroupDomain => Self::GroupDomain, + DbAssignmentType::GroupProject => Self::GroupProject, + DbAssignmentType::UserDomain => Self::UserDomain, + DbAssignmentType::UserProject => Self::UserProject, + } + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use crate::config::Config; + use crate::db::entity::{assignment, sea_orm_active_enums}; + + use super::*; + + fn get_role_assignment_mock(role_id: String) -> assignment::Model { + assignment::Model { + role_id: role_id.clone(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: sea_orm_active_enums::Type::UserProject, + inherited: false, + } + } + + #[tokio::test] + async fn test_list() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_role_assignment_mock("1".into())]]) + .append_query_results([vec![get_role_assignment_mock("1".into())]]) + .append_query_results([vec![get_role_assignment_mock("1".into())]]) + .append_query_results([vec![get_role_assignment_mock("1".into())]]) + .into_connection(); + let config = Config::default(); + assert_eq!( + list(&config, &db, &RoleAssignmentListParameters::default()) + .await + .unwrap(), + vec![Assignment { + role_id: "1".into(), + actor_id: "actor".into(), + target_id: "target".into(), + r#type: AssignmentType::UserProject, + inherited: false, + }] + ); + assert!( + list( + &config, + &db, + &RoleAssignmentListParameters { + role_id: Some("foo".into()), + ..Default::default() + } + ) + .await + .is_ok() + ); + assert!( + list( + &config, + &db, + &RoleAssignmentListParameters { + role_id: Some("foo".into()), + actor_id: Some("actor".into()), + ..Default::default() + } + ) + .await + .is_ok() + ); + assert!( + list( + &config, + &db, + &RoleAssignmentListParameters { + role_id: Some("foo".into()), + actor_id: Some("actor".into()), + target_id: Some("target".into()), + ..Default::default() + } + ) + .await + .is_ok() + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT CAST("assignment"."type" AS text), "assignment"."actor_id", "assignment"."target_id", "assignment"."role_id", "assignment"."inherited" FROM "assignment""#, + [] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT CAST("assignment"."type" AS text), "assignment"."actor_id", "assignment"."target_id", "assignment"."role_id", "assignment"."inherited" FROM "assignment" WHERE "assignment"."role_id" = $1"#, + ["foo".into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT CAST("assignment"."type" AS text), "assignment"."actor_id", "assignment"."target_id", "assignment"."role_id", "assignment"."inherited" FROM "assignment" WHERE "assignment"."role_id" = $1 AND "assignment"."actor_id" = $2"#, + ["foo".into(), "actor".into()] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT CAST("assignment"."type" AS text), "assignment"."actor_id", "assignment"."target_id", "assignment"."role_id", "assignment"."inherited" FROM "assignment" WHERE "assignment"."role_id" = $1 AND "assignment"."actor_id" = $2 AND "assignment"."target_id" = $3"#, + ["foo".into(), "actor".into(), "target".into()] + ), + ] + ); + } +} diff --git a/src/assignment/backends/sql/role.rs b/src/assignment/backends/sql/role.rs new file mode 100644 index 00000000..3fa09655 --- /dev/null +++ b/src/assignment/backends/sql/role.rs @@ -0,0 +1,190 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use sea_orm::DatabaseConnection; +use sea_orm::entity::*; +use sea_orm::query::*; +use serde_json::Value; + +use crate::assignment::backends::error::AssignmentDatabaseError; +use crate::assignment::types::*; +use crate::config::Config; +use crate::db::entity::{prelude::Role as DbRole, role as db_role}; + +static NULL_DOMAIN_ID: &str = "<>"; + +pub async fn get( + _conf: &Config, + db: &DatabaseConnection, + id: &str, +) -> Result, AssignmentDatabaseError> { + let role_select = DbRole::find_by_id(id); + + let entry: Option = role_select.one(db).await?; + entry.map(TryInto::try_into).transpose() +} + +pub async fn list( + _conf: &Config, + db: &DatabaseConnection, + params: &RoleListParameters, +) -> Result, AssignmentDatabaseError> { + let mut select = DbRole::find(); + + if let Some(domain_id) = ¶ms.domain_id { + select = select.filter(db_role::Column::DomainId.eq(domain_id)); + } + if let Some(name) = ¶ms.name { + select = select.filter(db_role::Column::Name.eq(name)); + } + + let db_roles: Vec = select.all(db).await?; + let results: Result, _> = db_roles + .into_iter() + .map(TryInto::::try_into) + .collect(); + + results +} + +impl TryFrom for Role { + type Error = AssignmentDatabaseError; + + fn try_from(value: db_role::Model) -> Result { + let mut builder = RoleBuilder::default(); + builder.id(value.id.clone()); + builder.name(value.name.clone()); + if value.domain_id != NULL_DOMAIN_ID { + builder.domain_id(value.domain_id.clone()); + } + if let Some(description) = &value.description { + builder.description(description.clone()); + } + if let Some(extra) = &value.extra { + builder.extra(serde_json::from_str::(extra).unwrap()); + } + + Ok(builder.build()?) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + + use crate::config::Config; + use crate::db::entity::role; + + use super::*; + + fn get_role_mock(id: String) -> role::Model { + role::Model { + id: id.clone(), + domain_id: "foo_domain".into(), + name: "foo".into(), + ..Default::default() + } + } + + #[tokio::test] + async fn test_get() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([ + // First query result - select user itself + vec![get_role_mock("1".into())], + ]) + .into_connection(); + let config = Config::default(); + assert_eq!( + get(&config, &db, "1").await.unwrap().unwrap(), + Role { + id: "1".into(), + domain_id: Some("foo_domain".into()), + name: "foo".to_owned(), + ..Default::default() + } + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "role"."id", "role"."name", "role"."extra", "role"."domain_id", "role"."description" FROM "role" WHERE "role"."id" = $1 LIMIT $2"#, + ["1".into(), 1u64.into()] + ),] + ); + } + + #[tokio::test] + async fn test_list() { + // Create MockDatabase with mock query results + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([ + // First query result - select user itself + vec![get_role_mock("1".into())], + ]) + .append_query_results([ + // First query result - select user itself + vec![get_role_mock("1".into())], + ]) + .append_query_results([ + // First query result - select user itself + vec![get_role_mock("1".into())], + ]) + .into_connection(); + let config = Config::default(); + assert!( + list(&config, &db, &RoleListParameters::default()) + .await + .is_ok() + ); + assert_eq!( + list( + &config, + &db, + &RoleListParameters { + name: Some("foo".into()), + domain_id: Some("foo_domain".into()) + } + ) + .await + .unwrap(), + vec![Role { + id: "1".into(), + domain_id: Some("foo_domain".into()), + name: "foo".to_owned(), + ..Default::default() + }] + ); + + // Checking transaction log + assert_eq!( + db.into_transaction_log(), + [ + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "role"."id", "role"."name", "role"."extra", "role"."domain_id", "role"."description" FROM "role""#, + [] + ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "role"."id", "role"."name", "role"."extra", "role"."domain_id", "role"."description" FROM "role" WHERE "role"."domain_id" = $1 AND "role"."name" = $2"#, + ["foo_domain".into(), "foo".into()] + ), + ] + ); + } +} diff --git a/src/assignment/error.rs b/src/assignment/error.rs new file mode 100644 index 00000000..87bb0be0 --- /dev/null +++ b/src/assignment/error.rs @@ -0,0 +1,57 @@ +// 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 thiserror::Error; + +use crate::assignment::backends::error::*; +use crate::assignment::types::RoleBuilderError; + +#[derive(Error, Debug)] +pub enum AssignmentProviderError { + /// Unsupported driver + #[error("unsupported driver {0}")] + UnsupportedDriver(String), + + /// Identity provider error + #[error("data serialization error: {}", source)] + Serde { + #[from] + source: serde_json::Error, + }, + + #[error("role {0} not found")] + RoleNotFound(String), + + /// Assignment provider error + #[error("assignment provider database error: {}", source)] + AssignmentDatabaseError { + #[from] + source: AssignmentDatabaseError, + }, + + #[error("building role data: {}", source)] + RoleBuilderError { + #[from] + source: RoleBuilderError, + }, +} + +impl AssignmentProviderError { + pub fn database(source: AssignmentDatabaseError) -> Self { + match source { + AssignmentDatabaseError::RoleNotFound(x) => Self::RoleNotFound(x), + _ => Self::AssignmentDatabaseError { source }, + } + } +} diff --git a/src/assignment/mod.rs b/src/assignment/mod.rs new file mode 100644 index 00000000..f4e61b98 --- /dev/null +++ b/src/assignment/mod.rs @@ -0,0 +1,146 @@ +// 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 async_trait::async_trait; +#[cfg(test)] +use mockall::mock; +use sea_orm::DatabaseConnection; + +pub mod backends; +pub mod error; +pub(crate) mod types; + +use crate::assignment::backends::sql::SqlBackend; +use crate::assignment::error::AssignmentProviderError; +use crate::assignment::types::{ + Assignment, AssignmentBackend, Role, RoleAssignmentListParameters, RoleListParameters, +}; +use crate::config::Config; +use crate::plugin_manager::PluginManager; + +#[derive(Clone, Debug)] +pub struct AssignmentProvider { + backend_driver: Box, +} + +#[async_trait] +pub trait AssignmentApi: Send + Sync + Clone { + /// List Roles + async fn list_roles( + &self, + db: &DatabaseConnection, + params: &RoleListParameters, + ) -> Result, AssignmentProviderError>; + + async fn get_role<'a>( + &self, + db: &DatabaseConnection, + role_id: &'a str, + ) -> Result, AssignmentProviderError>; + + async fn list_role_assignments( + &self, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError>; +} + +#[cfg(test)] +mock! { + pub AssignmentProvider { + pub fn new(cfg: &Config, plugin_manager: &PluginManager) -> Result; + } + + #[async_trait] + impl AssignmentApi for AssignmentProvider { + async fn list_roles( + &self, + db: &DatabaseConnection, + params: &RoleListParameters, + ) -> Result, AssignmentProviderError>; + + async fn get_role<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, AssignmentProviderError>; + + async fn list_role_assignments( + &self, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError>; + } + + impl Clone for AssignmentProvider { + fn clone(&self) -> Self; + } +} + +impl AssignmentProvider { + pub fn new( + config: &Config, + plugin_manager: &PluginManager, + ) -> Result { + let mut backend_driver = if let Some(driver) = + plugin_manager.get_assignment_backend(config.assignment.driver.clone()) + { + driver.clone() + } else { + match config.assignment.driver.as_str() { + "sql" => Box::new(SqlBackend::default()), + _ => { + return Err(AssignmentProviderError::UnsupportedDriver( + config.assignment.driver.clone(), + )); + } + } + }; + backend_driver.set_config(config.clone()); + Ok(Self { backend_driver }) + } +} + +#[async_trait] +impl AssignmentApi for AssignmentProvider { + /// List roles + #[tracing::instrument(level = "info", skip(self, db))] + async fn list_roles( + &self, + db: &DatabaseConnection, + params: &RoleListParameters, + ) -> Result, AssignmentProviderError> { + self.backend_driver.list_roles(db, params).await + } + + /// Get single role + #[tracing::instrument(level = "info", skip(self, db))] + async fn get_role<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, AssignmentProviderError> { + self.backend_driver.get_role(db, id).await + } + + /// List role assignments + #[tracing::instrument(level = "info", skip(self, db))] + async fn list_role_assignments( + &self, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError> { + self.backend_driver.list_assignments(db, params).await + } +} diff --git a/src/assignment/types.rs b/src/assignment/types.rs new file mode 100644 index 00000000..5c80f7d4 --- /dev/null +++ b/src/assignment/types.rs @@ -0,0 +1,59 @@ +// 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 + +pub mod assignment; +pub mod role; + +use async_trait::async_trait; +use dyn_clone::DynClone; +use sea_orm::DatabaseConnection; + +use crate::assignment::AssignmentProviderError; +use crate::config::Config; + +pub use crate::assignment::types::assignment::{ + Assignment, AssignmentBuilder, AssignmentBuilderError, AssignmentType, + RoleAssignmentListParameters, RoleAssignmentListParametersBuilder, + RoleAssignmentListParametersBuilderError, +}; +pub use crate::assignment::types::role::{Role, RoleBuilder, RoleBuilderError, RoleListParameters}; + +#[async_trait] +pub trait AssignmentBackend: DynClone + Send + Sync + std::fmt::Debug { + /// Set config + fn set_config(&mut self, config: Config); + + /// List Roles + async fn list_roles( + &self, + db: &DatabaseConnection, + params: &RoleListParameters, + ) -> Result, AssignmentProviderError>; + + /// Get single role by ID + async fn get_role<'a>( + &self, + db: &DatabaseConnection, + id: &'a str, + ) -> Result, AssignmentProviderError>; + + /// List Role assignments + async fn list_assignments( + &self, + db: &DatabaseConnection, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError>; +} + +dyn_clone::clone_trait_object!(AssignmentBackend); diff --git a/src/assignment/types/assignment.rs b/src/assignment/types/assignment.rs new file mode 100644 index 00000000..67488c82 --- /dev/null +++ b/src/assignment/types/assignment.rs @@ -0,0 +1,52 @@ +// 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 derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize)] +#[builder(setter(strip_option, into))] +pub struct Assignment { + /// The role ID. + pub role_id: String, + /// The actor id. + pub actor_id: String, + /// The target id. + pub target_id: String, + /// The assignment type + pub r#type: AssignmentType, + /// Inherited flag + pub inherited: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub enum AssignmentType { + GroupDomain, + GroupProject, + UserDomain, + UserProject, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(strip_option, into))] +pub struct RoleAssignmentListParameters { + #[builder(default)] + pub role_id: Option, + #[builder(default)] + pub actor_id: Option, + #[builder(default)] + pub target_id: Option, + #[builder(default)] + pub r#type: Option, +} diff --git a/src/assignment/types/role.rs b/src/assignment/types/role.rs new file mode 100644 index 00000000..78a6cb7d --- /dev/null +++ b/src/assignment/types/role.rs @@ -0,0 +1,44 @@ +// 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 derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(strip_option, into))] +pub struct Role { + /// The role ID. + pub id: String, + /// The role name. + pub name: String, + /// The role domain_id. + #[builder(default)] + pub domain_id: Option, + /// The role description + #[builder(default)] + pub description: Option, + /// Additional role properties + #[builder(default)] + pub extra: Option, +} + +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[builder(setter(strip_option, into))] +pub struct RoleListParameters { + /// Filter roles by the domain + pub domain_id: Option, + /// Filter roles by the name attribute + pub name: Option, +} diff --git a/src/config.rs b/src/config.rs index 872d9b7c..3c2e764a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,10 @@ pub struct Config { #[serde(rename = "DEFAULT")] pub default: Option, /// + /// Assignments (roles) related configuration + #[serde(default)] + pub assignment: AssignmentSection, + /// Auth #[serde(default)] pub auth: AuthSection, @@ -98,6 +102,11 @@ impl DatabaseSection { } } +#[derive(Debug, Default, Deserialize, Clone)] +pub struct AssignmentSection { + pub driver: String, +} + #[derive(Debug, Default, Deserialize, Clone)] pub struct IdentitySection { #[serde(default = "default_identity_driver")] @@ -162,6 +171,7 @@ impl Config { .set_default("identity.max_password_length", "4096")? .set_default("fernet_tokens.key_repository", "/etc/keystone/fernet-keys/")? .set_default("fernet_tokens.max_active_keys", "3")? + .set_default("assignment.driver", "sql")? .set_default("resource.driver", "sql")?; if std::path::Path::new(&path).is_file() { builder = builder.add_source(File::from(path).format(FileFormat::Ini)); diff --git a/src/db/entity.rs b/src/db/entity.rs index 48952011..42293be8 100644 --- a/src/db/entity.rs +++ b/src/db/entity.rs @@ -67,6 +67,18 @@ pub mod user_group_membership; pub mod user_option; pub mod whitelisted_config; +impl Default for role::Model { + fn default() -> Self { + Self { + description: None, + domain_id: String::new(), + extra: None, + id: String::new(), + name: String::new(), + } + } +} + impl Default for user::Model { fn default() -> Self { Self { diff --git a/src/error.rs b/src/error.rs index 930aff0d..f98d5ba0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,12 +14,19 @@ use thiserror::Error; +use crate::assignment::error::*; use crate::identity::error::*; use crate::resource::error::*; use crate::token::TokenProviderError; #[derive(Debug, Error)] pub enum KeystoneError { + #[error(transparent)] + AssignmentError { + #[from] + source: AssignmentProviderError, + }, + #[error(transparent)] IdentityError { #[from] diff --git a/src/identity/backends/sql.rs b/src/identity/backends/sql.rs index 3f5df991..65ce6658 100644 --- a/src/identity/backends/sql.rs +++ b/src/identity/backends/sql.rs @@ -303,8 +303,8 @@ mod tests { use chrono::Local; use sea_orm::{DatabaseBackend, MockDatabase, Transaction}; + use crate::config::Config; use crate::db::entity::{local_user, password, user, user_option}; - use crate::identity::Config; use super::*; diff --git a/src/lib.rs b/src/lib.rs index af4629c3..b4d59a34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 pub mod api; +pub mod assignment; pub mod config; pub(crate) mod db; pub mod error; diff --git a/src/plugin_manager.rs b/src/plugin_manager.rs index b78c52b2..86b29b66 100644 --- a/src/plugin_manager.rs +++ b/src/plugin_manager.rs @@ -14,6 +14,7 @@ use std::collections::HashMap; +use crate::assignment::types::AssignmentBackend; use crate::identity::types::IdentityBackend; use crate::resource::types::ResourceBackend; @@ -21,8 +22,11 @@ use crate::resource::types::ResourceBackend; /// service start #[derive(Clone, Debug, Default)] pub struct PluginManager { + /// Assignments backend plugin + assignment_backends: HashMap>, /// Identity backend plugins identity_backends: HashMap>, + /// Resource backend plugins resource_backends: HashMap>, } @@ -37,6 +41,15 @@ impl PluginManager { .insert(name.as_ref().to_string(), plugin); } + /// Get registered assignment backend + #[allow(clippy::borrowed_box)] + pub fn get_assignment_backend>( + &self, + name: S, + ) -> Option<&Box> { + self.assignment_backends.get(name.as_ref()) + } + /// Get registered identity backend #[allow(clippy::borrowed_box)] pub fn get_identity_backend>( diff --git a/src/provider.rs b/src/provider.rs index 57899441..5ee99240 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -14,6 +14,9 @@ use derive_builder::Builder; use mockall_double::double; +use crate::assignment::AssignmentApi; +#[double] +use crate::assignment::AssignmentProvider; use crate::config::Config; use crate::error::KeystoneError; use crate::identity::IdentityApi; @@ -38,6 +41,7 @@ use crate::token::TokenProvider; #[builder(pattern = "owned")] pub struct Provider { pub config: Config, + assignment: AssignmentProvider, identity: IdentityProvider, resource: ResourceProvider, token: TokenProvider, @@ -45,18 +49,24 @@ pub struct Provider { impl Provider { pub fn new(cfg: Config, plugin_manager: PluginManager) -> Result { + let assignment_provider = AssignmentProvider::new(&cfg, &plugin_manager)?; let identity_provider = IdentityProvider::new(&cfg, &plugin_manager)?; let resource_provider = ResourceProvider::new(&cfg, &plugin_manager)?; let token_provider = TokenProvider::new(&cfg)?; Ok(Self { config: cfg, + assignment: assignment_provider, identity: identity_provider, resource: resource_provider, token: token_provider, }) } + pub fn get_assignment_provider(&self) -> &impl AssignmentApi { + &self.assignment + } + pub fn get_identity_provider(&self) -> &impl IdentityApi { &self.identity } diff --git a/src/tests/api.rs b/src/tests/api.rs index ad89130f..2545f965 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -15,6 +15,7 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; +use crate::assignment::MockAssignmentProvider; use crate::config::Config; use crate::identity::MockIdentityProvider; use crate::keystone::{Service, ServiceState}; @@ -25,6 +26,7 @@ use crate::token::{MockTokenProvider, Token, TokenProviderError, UnscopedToken}; pub(crate) fn get_mocked_state_unauthed() -> ServiceState { let db = DatabaseConnection::Disconnected; let config = Config::default(); + let assignment_mock = MockAssignmentProvider::default(); let identity_mock = MockIdentityProvider::default(); let resource_mock = MockResourceProvider::default(); let mut token_mock = MockTokenProvider::default(); @@ -34,6 +36,7 @@ pub(crate) fn get_mocked_state_unauthed() -> ServiceState { let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock) @@ -54,9 +57,11 @@ pub(crate) fn get_mocked_state(identity_mock: MockIdentityProvider) -> ServiceSt ..Default::default() })) }); + let assignment_mock = MockAssignmentProvider::default(); let provider = ProviderBuilder::default() .config(config.clone()) + .assignment(assignment_mock) .identity(identity_mock) .resource(resource_mock) .token(token_mock)