From 661d052261256725cab03ce72b5e098d67de1844 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 9 Jan 2026 20:44:02 +0100 Subject: [PATCH] feat: Add /auth/projects API Cover api to list all projects the user is having access to. --- policy/auth/project/list.rego | 17 + policy/auth/project/list_test.rego | 12 + policy/auth/token/revoke.rego | 1 - policy/auth/token/show.rego | 1 - policy/auth/token/show_test.rego | 1 - policy/identity.rego | 7 +- policy/project/user/role/check.rego | 4 +- src/api/v3/auth/mod.rs | 5 +- src/api/v3/auth/project/list.rs | 291 ++++++++++++++++++ src/api/v3/auth/project/mod.rs | 23 ++ src/api/v3/project/mod.rs | 2 + src/api/v3/project/types.rs | 85 +++++ .../backend/sql/identity_provider.rs | 8 +- src/federation/backend/sql/mapping.rs | 16 +- src/resource/backend.rs | 10 +- src/resource/backend/sql.rs | 9 + src/resource/backend/sql/project/list.rs | 151 +++++++++ src/resource/backend/sql/project/mod.rs | 55 +++- src/resource/mock.rs | 8 +- src/resource/mod.rs | 45 ++- src/resource/types.rs | 4 +- src/resource/types/project.rs | 53 +++- src/resource/types/provider_api.rs | 9 +- 23 files changed, 745 insertions(+), 72 deletions(-) create mode 100644 policy/auth/project/list.rego create mode 100644 policy/auth/project/list_test.rego create mode 100644 src/api/v3/auth/project/list.rs create mode 100644 src/api/v3/auth/project/mod.rs create mode 100644 src/api/v3/project/types.rs create mode 100644 src/resource/backend/sql/project/list.rs diff --git a/policy/auth/project/list.rego b/policy/auth/project/list.rego new file mode 100644 index 00000000..bee8e866 --- /dev/null +++ b/policy/auth/project/list.rego @@ -0,0 +1,17 @@ +package identity.auth.project.list + +import data.identity + +# List projects the user is having access to + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "reader" in input.credentials.roles + input.credentials.system_scope != null + "all" == input.credentials.system_scope +} diff --git a/policy/auth/project/list_test.rego b/policy/auth/project/list_test.rego new file mode 100644 index 00000000..910c69e2 --- /dev/null +++ b/policy/auth/project/list_test.rego @@ -0,0 +1,12 @@ +package test_auth_project_list + +import data.identity.auth.project.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": ["reader"], "system_scope": "all"}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": ["reader"], "system_scope": "not_all"}} +} diff --git a/policy/auth/token/revoke.rego b/policy/auth/token/revoke.rego index d66eceb6..09a837cc 100644 --- a/policy/auth/token/revoke.rego +++ b/policy/auth/token/revoke.rego @@ -23,4 +23,3 @@ allow if { allow if { identity.token_subject } - diff --git a/policy/auth/token/show.rego b/policy/auth/token/show.rego index bac9617c..daa989bd 100644 --- a/policy/auth/token/show.rego +++ b/policy/auth/token/show.rego @@ -23,4 +23,3 @@ allow if { allow if { identity.token_subject } - diff --git a/policy/auth/token/show_test.rego b/policy/auth/token/show_test.rego index 2d638699..869149d3 100644 --- a/policy/auth/token/show_test.rego +++ b/policy/auth/token/show_test.rego @@ -1,4 +1,3 @@ - package test_auth_token_show import data.identity.auth.token.show diff --git a/policy/identity.rego b/policy/identity.rego index 098e1ddd..ff58f850 100644 --- a/policy/identity.rego +++ b/policy/identity.rego @@ -12,7 +12,6 @@ global_idp if { input.target.domain_id == null } - own_idp if { input.target.domain_id != null input.target.domain_id == input.credentials.domain_id @@ -57,14 +56,14 @@ global_role if { own_role if { input.target.role.domain_id != null - input.credentials.domain_id == input.target.role.domain_id + input.credentials.domain_id == input.target.role.domain_id } # Domain role or the global role. own_role_or_global_role if { - global_role + global_role } own_role_or_global_role if { - own_role + own_role } diff --git a/policy/project/user/role/check.rego b/policy/project/user/role/check.rego index e2fafc1d..66d89788 100644 --- a/policy/project/user/role/check.rego +++ b/policy/project/user/role/check.rego @@ -12,7 +12,7 @@ allow if { allow if { "reader" in input.credentials.roles - input.credentials.system == "all" + input.credentials.system == "all" } allow if { @@ -21,7 +21,7 @@ allow if { input.target.user.domain_id != null input.credentials.domain_id == input.target.user.domain_id input.credentials.domain_id == input.target.project.domain_id - identity.own_role_or_global_role + identity.own_role_or_global_role } # violation contains {"field": "domain_id", "msg": "checking project-user-role assignment requires domain scope."} if { diff --git a/src/api/v3/auth/mod.rs b/src/api/v3/auth/mod.rs index a802a737..99e5afce 100644 --- a/src/api/v3/auth/mod.rs +++ b/src/api/v3/auth/mod.rs @@ -16,8 +16,11 @@ use utoipa_axum::router::OpenApiRouter; use crate::keystone::ServiceState; +pub mod project; pub mod token; pub(super) fn openapi_router() -> OpenApiRouter { - OpenApiRouter::new().nest("/tokens", token::openapi_router()) + OpenApiRouter::new() + .nest("/tokens", token::openapi_router()) + .nest("/projects", project::openapi_router()) } diff --git a/src/api/v3/auth/project/list.rs b/src/api/v3/auth/project/list.rs new file mode 100644 index 00000000..d5a6ad2b --- /dev/null +++ b/src/api/v3/auth/project/list.rs @@ -0,0 +1,291 @@ +// 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 +//! Get available project scopes + +use axum::{extract::State, response::IntoResponse}; +use mockall_double::double; +use serde_json::Value; +use std::collections::HashSet; + +use crate::api::v3::project::types::{ProjectShort, ProjectShortList}; +use crate::api::{auth::Auth, error::KeystoneApiError}; +use crate::assignment::{ + AssignmentApi, + types::{AssignmentType, RoleAssignmentListParameters}, +}; +use crate::keystone::ServiceState; +#[double] +use crate::policy::Policy; +use crate::resource::{ResourceApi, types::ProjectListParameters}; + +/// Get available project scopes. +/// +/// This call returns the list of projects that are available to be scoped to +/// based on the X-Auth-Token provided in the request. +#[utoipa::path( + get, + path = "/", + responses( + (status = OK, description = "Project list", body = ProjectShortList), + ), + tag="auth" +)] +#[tracing::instrument( + name = "api::v3::auth::project::list", + level = "debug", + skip(state, user_auth, policy) +)] +pub(super) async fn list( + Auth(user_auth): Auth, + mut policy: Policy, + State(state): State, +) -> Result { + policy + .enforce("identity/auth/project/list", &user_auth, Value::Null, None) + .await?; + + let project_ids: HashSet = state + .provider + .get_assignment_provider() + .list_role_assignments( + &state, + &RoleAssignmentListParameters { + user_id: Some(user_auth.user_id().clone()), + effective: Some(true), + include_names: Some(false), + ..Default::default() + }, + ) + .await? + .into_iter() + .filter(|assignment| { + assignment.r#type == AssignmentType::UserProject + || assignment.r#type == AssignmentType::GroupProject + }) + .map(|assignment| assignment.target_id.clone()) + .collect(); + + let projects: Vec = state + .provider + .get_resource_provider() + .list_projects( + &state, + &ProjectListParameters { + ids: Some(project_ids), + ..Default::default() + }, + ) + .await? + .into_iter() + .map(Into::into) + .collect(); + + Ok(ProjectShortList { projects }) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use http_body_util::BodyExt; // for `collect` + use sea_orm::DatabaseConnection; + use std::collections::HashSet; + use std::sync::Arc; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + use tower_http::trace::TraceLayer; + + use crate::assignment::{MockAssignmentProvider, types::*}; + use crate::config::Config; + use crate::keystone::{Service, ServiceState}; + use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult}; + use crate::provider::{Provider, ProviderBuilder}; + use crate::resource::{ + MockResourceProvider, + types::{Project as ProviderProject, ProjectListParameters}, + }; + use crate::token::{MockTokenProvider, Token, UnscopedPayload}; + + use super::super::openapi_router; + use super::*; + + pub(super) fn get_mocked_state( + provider_builder: ProviderBuilder, + policy_allowed: bool, + ) -> ServiceState { + let mut token_mock = MockTokenProvider::default(); + token_mock.expect_validate_token().returning(|_, _, _, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + token_mock + .expect_expand_token_information() + .returning(|_, _| { + Ok(Token::Unscoped(UnscopedPayload { + user_id: "bar".into(), + ..Default::default() + })) + }); + + let provider = provider_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(); + 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(), + ) + } + + #[tokio::test] + async fn test_list() { + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_role_assignments() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id.as_ref().is_some_and(|x| x == "bar") + && params.effective.is_some_and(|x| x) + && params.include_names.is_some_and(|x| !x) + }) + .returning(|_, _| { + Ok(vec![ + Assignment { + role_id: "role_id".into(), + role_name: Some("rn".into()), + actor_id: "user_id".into(), + target_id: "p1".into(), + r#type: AssignmentType::UserProject, + inherited: false, + implied_via: None, + }, + Assignment { + role_id: "role_id".into(), + role_name: Some("rn".into()), + actor_id: "group_id".into(), + target_id: "p2".into(), + r#type: AssignmentType::GroupProject, + inherited: false, + implied_via: None, + }, + Assignment { + role_id: "role_id".into(), + role_name: Some("rn".into()), + actor_id: "user_id".into(), + target_id: "d1".into(), + r#type: AssignmentType::UserDomain, + inherited: false, + implied_via: None, + }, + ]) + }); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_list_projects() + .withf(|_, params: &ProjectListParameters| { + params + .ids + .as_ref() + .is_some_and(|x| *x == HashSet::from(["p1".to_string(), "p2".to_string()])) + }) + .returning(|_, _| { + Ok(vec![ + ProviderProject { + description: None, + domain_id: "did".into(), + enabled: true, + extra: None, + id: "p1".into(), + name: "p1_name".into(), + parent_id: None, + }, + ProviderProject { + description: None, + domain_id: "did".into(), + enabled: true, + extra: None, + id: "p2".into(), + name: "p2_name".into(), + parent_id: None, + }, + ]) + }); + + let provider_builder = Provider::mocked_builder() + .assignment(assignment_mock) + .resource(resource_mock); + let state = get_mocked_state(provider_builder, true); + + 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: ProjectShortList = serde_json::from_slice(&body).unwrap(); + assert_eq!( + vec![ + ProjectShort { + domain_id: "did".into(), + enabled: true, + id: "p1".into(), + name: "p1_name".into(), + }, + ProjectShort { + domain_id: "did".into(), + enabled: true, + id: "p2".into(), + name: "p2_name".into(), + }, + ], + res.projects + ); + } +} diff --git a/src/api/v3/auth/project/mod.rs b/src/api/v3/auth/project/mod.rs new file mode 100644 index 00000000..1f419e4a --- /dev/null +++ b/src/api/v3/auth/project/mod.rs @@ -0,0 +1,23 @@ +// 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 +//! Projects the user has access to. +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::keystone::ServiceState; + +mod list; + +pub(crate) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(list::list)) +} diff --git a/src/api/v3/project/mod.rs b/src/api/v3/project/mod.rs index e51a9a82..bec21cbe 100644 --- a/src/api/v3/project/mod.rs +++ b/src/api/v3/project/mod.rs @@ -17,6 +17,8 @@ use utoipa_axum::router::OpenApiRouter; use crate::keystone::ServiceState; +pub mod types; + /// OpenApi specification for the project API. #[derive(OpenApi)] #[openapi( diff --git a/src/api/v3/project/types.rs b/src/api/v3/project/types.rs new file mode 100644 index 00000000..8d9b2035 --- /dev/null +++ b/src/api/v3/project/types.rs @@ -0,0 +1,85 @@ +// 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 +//! Project API types + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::Validate; + +use crate::error::BuilderError; +use crate::resource::types as provider_types; + +/// Short Project representation. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema, Validate)] +#[builder(build_fn(error = "BuilderError"))] +#[builder(setter(strip_option, into))] +pub struct ProjectShort { + /// The ID of the domain for the project. + #[validate(length(min = 1, max = 64))] + pub domain_id: String, + + /// If set to true, project is enabled. If set to false, project is + /// disabled. + pub enabled: bool, + + /// The ID for the project. + #[validate(length(min = 1, max = 64))] + pub id: String, + + /// The name of the project. + #[validate(length(min = 1, max = 255))] + pub name: String, +} + +/// List of projects. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema, Validate)] +pub struct ProjectShortList { + /// Collection of project objects. + #[validate(nested)] + pub projects: Vec, +} + +impl IntoResponse for ProjectShortList { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +impl From for ProjectShort { + fn from(value: provider_types::Project) -> Self { + Self { + domain_id: value.domain_id, + enabled: value.enabled, + id: value.id, + name: value.name, + } + } +} + +impl From<&provider_types::Project> for ProjectShort { + fn from(value: &provider_types::Project) -> Self { + Self { + domain_id: value.domain_id.clone(), + enabled: value.enabled, + id: value.id.clone(), + name: value.name.clone(), + } + } +} diff --git a/src/federation/backend/sql/identity_provider.rs b/src/federation/backend/sql/identity_provider.rs index 0372b2d9..fcf62460 100644 --- a/src/federation/backend/sql/identity_provider.rs +++ b/src/federation/backend/sql/identity_provider.rs @@ -34,7 +34,7 @@ impl TryFrom for IdentityProvider { fn try_from(value: db_federated_identity_provider::Model) -> Result { let mut builder = IdentityProviderBuilder::default(); builder.id(value.id.clone()); - builder.name(value.name.clone()); + builder.name(value.name); builder.enabled(value.enabled); if let Some(val) = &value.domain_id { builder.domain_id(val); @@ -67,11 +67,11 @@ impl TryFrom for IdentityProvider { if let Some(val) = &value.bound_issuer { builder.bound_issuer(val); } - if let Some(val) = &value.provider_config { - builder.provider_config(val.clone()); + if let Some(val) = value.provider_config { + builder.provider_config(val); } if let Some(val) = &value.default_mapping_name { - builder.default_mapping_name(val.clone()); + builder.default_mapping_name(val); } Ok(builder.build()?) } diff --git a/src/federation/backend/sql/mapping.rs b/src/federation/backend/sql/mapping.rs index 68fc4cf7..75b2c399 100644 --- a/src/federation/backend/sql/mapping.rs +++ b/src/federation/backend/sql/mapping.rs @@ -54,8 +54,8 @@ impl TryFrom for Mapping { fn try_from(value: db_federated_mapping::Model) -> Result { let mut builder = MappingBuilder::default(); builder.id(value.id.clone()); - builder.name(value.name.clone()); - builder.idp_id(value.idp_id.clone()); + builder.name(value.name); + builder.idp_id(value.idp_id); if let Some(val) = &value.domain_id { builder.domain_id(val); } @@ -69,8 +69,8 @@ impl TryFrom for Mapping { { builder.allowed_redirect_uris(Vec::from_iter(val.split(",").map(Into::into))); } - builder.user_id_claim(value.user_id_claim.clone()); - builder.user_name_claim(value.user_name_claim.clone()); + builder.user_id_claim(value.user_id_claim); + builder.user_name_claim(value.user_name_claim); if let Some(val) = &value.domain_id_claim { builder.domain_id_claim(val); } @@ -85,8 +85,8 @@ impl TryFrom for Mapping { if let Some(val) = &value.bound_subject { builder.bound_subject(val); } - if let Some(val) = &value.bound_claims { - builder.bound_claims(val.clone()); + if let Some(val) = value.bound_claims { + builder.bound_claims(val); } if let Some(val) = &value.oidc_scopes && !val.is_empty() @@ -94,10 +94,10 @@ impl TryFrom for Mapping { builder.oidc_scopes(Vec::from_iter(val.split(",").map(Into::into))); } if let Some(val) = &value.token_project_id { - builder.token_project_id(val.clone()); + builder.token_project_id(val); } if let Some(val) = &value.token_restriction_id { - builder.token_restriction_id(val.clone()); + builder.token_restriction_id(val); } Ok(builder.build()?) } diff --git a/src/resource/backend.rs b/src/resource/backend.rs index f0c2a8de..e23648f3 100644 --- a/src/resource/backend.rs +++ b/src/resource/backend.rs @@ -19,8 +19,7 @@ pub mod sql; use crate::keystone::ServiceState; use crate::resource::ResourceProviderError; -use crate::resource::types::Domain; -use crate::resource::types::Project; +use crate::resource::types::*; #[cfg_attr(test, mockall::automock)] #[async_trait] @@ -60,4 +59,11 @@ pub trait ResourceBackend: Send + Sync { state: &ServiceState, project_id: &'a str, ) -> Result>, ResourceProviderError>; + + /// List projects. + async fn list_projects( + &self, + state: &ServiceState, + params: &ProjectListParameters, + ) -> Result, ResourceProviderError>; } diff --git a/src/resource/backend/sql.rs b/src/resource/backend/sql.rs index 26d7b679..70370190 100644 --- a/src/resource/backend/sql.rs +++ b/src/resource/backend/sql.rs @@ -72,6 +72,15 @@ impl ResourceBackend for SqlBackend { ) -> Result>, ResourceProviderError> { Ok(project::get_project_parents(&state.db, project_id).await?) } + + /// List projects. + async fn list_projects( + &self, + state: &ServiceState, + params: &ProjectListParameters, + ) -> Result, ResourceProviderError> { + Ok(project::list(&state.db, params).await?) + } } //#[cfg(test)] diff --git a/src/resource/backend/sql/project/list.rs b/src/resource/backend/sql/project/list.rs new file mode 100644 index 00000000..46596105 --- /dev/null +++ b/src/resource/backend/sql/project/list.rs @@ -0,0 +1,151 @@ +// 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 sea_orm::{Cursor, SelectModel}; + +use crate::db::entity::{prelude::Project as DbProject, project as db_project}; +use crate::error::DbContextExt; +use crate::resource::backend::error::ResourceDatabaseError; +use crate::resource::types::*; + +/// Prepare the paginated query for listing mappings. +fn get_list_query( + params: &ProjectListParameters, +) -> Result>, ResourceDatabaseError> { + let mut select = DbProject::find().filter(db_project::Column::IsDomain.eq(false)); + + if let Some(val) = ¶ms.domain_id { + select = select.filter(db_project::Column::DomainId.eq(val)); + } + + if let Some(val) = ¶ms.name { + select = select.filter(db_project::Column::Name.eq(val)); + } + + if let Some(val) = ¶ms.ids + && !val.is_empty() + { + select = select.filter(db_project::Column::Id.is_in(val)); + } + + Ok(select.cursor_by(db_project::Column::Id)) +} + +pub async fn list( + db: &DatabaseConnection, + params: &ProjectListParameters, +) -> Result, ResourceDatabaseError> { + get_list_query(params)? + .all(db) + .await + .context("listing projects")? + .into_iter() + .map(TryInto::try_into) + .collect() +} + +#[cfg(test)] +mod tests { + use sea_orm::{DatabaseBackend, MockDatabase, QueryOrder, Transaction, sea_query::*}; + + use super::super::tests::*; + use super::*; + + #[tokio::test] + async fn test_query_all() { + assert_eq!( + r#"SELECT "project"."id", "project"."name", "project"."extra", "project"."description", "project"."enabled", "project"."domain_id", "project"."parent_id", "project"."is_domain" FROM "project" WHERE "project"."is_domain" = FALSE"#, + QueryOrder::query(&mut get_list_query(&ProjectListParameters::default()).unwrap()) + .to_string(PostgresQueryBuilder) + ); + } + + #[tokio::test] + async fn test_query_domain_id() { + assert!( + QueryOrder::query( + &mut get_list_query(&ProjectListParameters { + domain_id: Some("did".into()), + ..Default::default() + }) + .unwrap() + ) + .to_string(PostgresQueryBuilder) + .contains("\"project\".\"domain_id\" = 'did'") + ); + } + + #[tokio::test] + async fn test_query_name() { + assert!( + QueryOrder::query( + &mut get_list_query(&ProjectListParameters { + name: Some("name".into()), + ..Default::default() + }) + .unwrap() + ) + .to_string(PostgresQueryBuilder) + .contains("\"project\".\"name\" = 'name'") + ); + } + + #[tokio::test] + async fn test_query_ids() { + let q = QueryOrder::query( + &mut get_list_query(&ProjectListParameters { + ids: Some(std::collections::HashSet::from([ + "1".to_string(), + "2".to_string(), + ])), + ..Default::default() + }) + .unwrap(), + ) + .to_string(PostgresQueryBuilder); + assert!(q.contains("\"project\".\"id\" IN ('"), "{}", q); + } + + #[tokio::test] + async fn test_list() { + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![get_project_mock("pid1")]]) + .into_connection(); + + assert_eq!( + list(&db, &ProjectListParameters::default()).await.unwrap(), + vec![Project { + description: None, + domain_id: "did".into(), + enabled: true, + extra: None, + id: "pid1".into(), + name: "name".into(), + parent_id: None, + }] + ); + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "project"."id", "project"."name", "project"."extra", "project"."description", "project"."enabled", "project"."domain_id", "project"."parent_id", "project"."is_domain" FROM "project" WHERE "project"."is_domain" = $1 ORDER BY "project"."id" ASC"#, + [false.into()] + ),] + ); + } +} diff --git a/src/resource/backend/sql/project/mod.rs b/src/resource/backend/sql/project/mod.rs index 673ab98f..eb5dc8dd 100644 --- a/src/resource/backend/sql/project/mod.rs +++ b/src/resource/backend/sql/project/mod.rs @@ -15,16 +15,17 @@ use serde_json::Value; use tracing::error; mod get; +mod list; mod tree; pub use get::get_project; pub use get::get_project_by_name; +pub use list::list; pub use tree::get_project_parents; use crate::db::entity::project as db_project; use crate::resource::backend::error::ResourceDatabaseError; -use crate::resource::types::Project; -use crate::resource::types::ProjectBuilder; +use crate::resource::types::{Project, ProjectBuilder}; impl TryFrom for Project { type Error = ResourceDatabaseError; @@ -35,25 +36,45 @@ impl TryFrom for Project { if let Some(parent_id) = &value.parent_id { project_builder.parent_id(parent_id); } - project_builder.name(value.name.clone()); - project_builder.domain_id(value.domain_id.clone()); + project_builder.name(value.name); + project_builder.domain_id(value.domain_id); if let Some(description) = &value.description { - project_builder.description(description.clone()); + project_builder.description(description); } - project_builder.enabled(value.enabled.unwrap_or(false)); - if let Some(extra) = &value.extra { - project_builder.extra( - serde_json::from_str::(extra) - .inspect_err(|e| { - error!( - "failed to deserialize project [id: {}] extra properties: {e}", - value.id - ) - }) - .unwrap_or_default(), - ); + // python keystone defaults to project/domain being enabled when the column is + // unset. + project_builder.enabled(value.enabled.unwrap_or(true)); + if let Some(extra) = &value.extra + && extra != "{}" + { + match serde_json::from_str::(extra) { + Ok(extras) => { + project_builder.extra(extras); + } + Err(e) => { + error!("failed to deserialize project extra: {e}"); + } + }; } Ok(project_builder.build()?) } } + +#[cfg(test)] +mod tests { + use super::*; + + pub(super) fn get_project_mock>(id: S) -> db_project::Model { + db_project::Model { + description: None, + domain_id: "did".into(), + enabled: Some(true), + extra: Some("{}".to_string()), + id: id.into(), + is_domain: false, + name: "name".into(), + parent_id: None, + } + } +} diff --git a/src/resource/mock.rs b/src/resource/mock.rs index ddfd61f8..c1756fce 100644 --- a/src/resource/mock.rs +++ b/src/resource/mock.rs @@ -19,7 +19,7 @@ use crate::config::Config; use crate::keystone::ServiceState; use crate::plugin_manager::PluginManager; use crate::resource::error::ResourceProviderError; -use crate::resource::types::{Domain, Project, ResourceApi}; +use crate::resource::types::*; #[cfg(test)] mock! { @@ -59,5 +59,11 @@ mock! { state: &ServiceState, project_id: &'a str, ) -> Result>, ResourceProviderError>; + + async fn list_projects( + &self, + state: &ServiceState, + params: &ProjectListParameters, + ) -> Result, ResourceProviderError>; } } diff --git a/src/resource/mod.rs b/src/resource/mod.rs index b3aa68cd..50a35b04 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -44,7 +44,7 @@ use crate::keystone::ServiceState; use crate::plugin_manager::PluginManager; use crate::resource::backend::{ResourceBackend, sql::SqlBackend}; use crate::resource::error::ResourceProviderError; -use crate::resource::types::{Domain, Project}; +use crate::resource::types::*; #[cfg(test)] pub use mock::MockResourceProvider; @@ -79,7 +79,7 @@ impl ResourceProvider { #[async_trait] impl ResourceApi for ResourceProvider { - /// Get single domain + /// Get single domain. #[tracing::instrument(level = "info", skip(self, state))] async fn get_domain<'a>( &self, @@ -89,19 +89,7 @@ impl ResourceApi for ResourceProvider { self.backend_driver.get_domain(state, domain_id).await } - /// Get single domain by its name - #[tracing::instrument(level = "info", skip(self, state))] - async fn find_domain_by_name<'a>( - &self, - state: &ServiceState, - domain_name: &'a str, - ) -> Result, ResourceProviderError> { - self.backend_driver - .get_domain_by_name(state, domain_name) - .await - } - - /// Get single project + /// Get single project. #[tracing::instrument(level = "info", skip(self, state))] async fn get_project<'a>( &self, @@ -111,7 +99,7 @@ impl ResourceApi for ResourceProvider { self.backend_driver.get_project(state, project_id).await } - /// Get single project by Name and Domain ID + /// Get single project by Name and Domain ID. #[tracing::instrument(level = "info", skip(self, state))] async fn get_project_by_name<'a>( &self, @@ -124,7 +112,8 @@ impl ResourceApi for ResourceProvider { .await } - /// Get project parents + /// Get project parents. + #[tracing::instrument(level = "info", skip(self, state))] async fn get_project_parents<'a>( &self, state: &ServiceState, @@ -134,4 +123,26 @@ impl ResourceApi for ResourceProvider { .get_project_parents(state, project_id) .await } + + /// Get single domain by its name. + #[tracing::instrument(level = "info", skip(self, state))] + async fn find_domain_by_name<'a>( + &self, + state: &ServiceState, + domain_name: &'a str, + ) -> Result, ResourceProviderError> { + self.backend_driver + .get_domain_by_name(state, domain_name) + .await + } + + /// List projects. + #[tracing::instrument(level = "info", skip(self, state))] + async fn list_projects( + &self, + state: &ServiceState, + params: &ProjectListParameters, + ) -> Result, ResourceProviderError> { + self.backend_driver.list_projects(state, params).await + } } diff --git a/src/resource/types.rs b/src/resource/types.rs index 454e5eca..ae442e00 100644 --- a/src/resource/types.rs +++ b/src/resource/types.rs @@ -16,6 +16,6 @@ mod domain; mod project; mod provider_api; -pub use crate::resource::types::domain::{Domain, DomainBuilder}; -pub use crate::resource::types::project::{Project, ProjectBuilder}; +pub use crate::resource::types::domain::*; +pub use crate::resource::types::project::*; pub use provider_api::*; diff --git a/src/resource/types/project.rs b/src/resource/types/project.rs index 3f872eae..20d82f17 100644 --- a/src/resource/types/project.rs +++ b/src/resource/types/project.rs @@ -15,29 +15,62 @@ use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; +use validator::Validate; use crate::error::BuilderError; -#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] #[builder(build_fn(error = "BuilderError"))] #[builder(setter(strip_option, into))] pub struct Project { - /// The project ID. - pub id: String, - /// The ID of the parent for the project. + /// The description of the project. #[builder(default)] - pub parent_id: Option, - /// The project name. - pub name: String, + #[validate(length(min = 1, max = 255))] + pub description: Option, + /// The project domain_id. + #[validate(length(min = 1, max = 64))] pub domain_id: String, + /// If set to true, project is enabled. If set to false, project is /// disabled. pub enabled: bool, - /// The description of the project. - #[builder(default)] - pub description: Option, + /// Additional project properties. #[builder(default)] pub extra: Option, + + /// The project ID. + #[validate(length(min = 1, max = 255))] + pub id: String, + + /// The project name. + #[validate(length(min = 1, max = 255))] + pub name: String, + + /// The ID of the parent for the project. + #[builder(default)] + #[validate(length(min = 1, max = 64))] + pub parent_id: Option, +} + +/// Project listing parameters. +#[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize, Validate)] +#[builder(build_fn(error = "BuilderError"))] +pub struct ProjectListParameters { + /// Filter project by the domain. + #[builder(default)] + #[validate(length(max = 64))] + pub domain_id: Option, + + /// Filter projects by the id attribute. Items are treated as `IN[]`. + #[builder(default)] + #[validate(length(min = 1, max = 64))] + pub ids: Option>, + + /// Filter projects by the name attribute. + #[builder(default)] + #[validate(length(max = 255))] + pub name: Option, } diff --git a/src/resource/types/provider_api.rs b/src/resource/types/provider_api.rs index d9f77f4d..0ba4b323 100644 --- a/src/resource/types/provider_api.rs +++ b/src/resource/types/provider_api.rs @@ -17,7 +17,7 @@ use async_trait::async_trait; use crate::keystone::ServiceState; use crate::resource::ResourceProviderError; use crate::resource::types::domain::Domain; -use crate::resource::types::project::Project; +use crate::resource::types::project::*; #[async_trait] pub trait ResourceApi: Send + Sync { @@ -52,4 +52,11 @@ pub trait ResourceApi: Send + Sync { state: &ServiceState, project_id: &'a str, ) -> Result>, ResourceProviderError>; + + /// List projects. + async fn list_projects( + &self, + state: &ServiceState, + params: &ProjectListParameters, + ) -> Result, ResourceProviderError>; }