From e1ac81d851950c921b386d0bb7bd41d10848cf71 Mon Sep 17 00:00:00 2001 From: konac-hamza Date: Tue, 24 Mar 2026 02:01:13 +0300 Subject: [PATCH 1/2] feat: Add api to list user roles on project --- crates/core/src/assignment/mock.rs | 9 + crates/core/src/assignment/mod.rs | 14 + crates/core/src/assignment/service.rs | 206 ++++++++++++ .../core/src/assignment/types/provider_api.rs | 8 + .../v3/role_assignment/project/user/role.rs | 8 +- .../role_assignment/project/user/role/list.rs | 304 ++++++++++++++++++ 6 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs diff --git a/crates/core/src/assignment/mock.rs b/crates/core/src/assignment/mock.rs index 05313ec4..ecd13edf 100644 --- a/crates/core/src/assignment/mock.rs +++ b/crates/core/src/assignment/mock.rs @@ -18,6 +18,7 @@ use crate::assignment::AssignmentApi; use crate::assignment::AssignmentProviderError; use crate::assignment::types::*; use crate::keystone::ServiceState; +use crate::role::types::*; mock! { pub AssignmentProvider {} @@ -41,5 +42,13 @@ mock! { state: &ServiceState, params: Assignment, ) -> Result<(), AssignmentProviderError>; + + // List user roles on project + async fn list_user_roles_on_project( + &self, + state: &ServiceState, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError>; + } } diff --git a/crates/core/src/assignment/mod.rs b/crates/core/src/assignment/mod.rs index 7e5411f8..421c1206 100644 --- a/crates/core/src/assignment/mod.rs +++ b/crates/core/src/assignment/mod.rs @@ -49,6 +49,7 @@ use openstack_keystone_config::Config; use crate::assignment::service::AssignmentService; use crate::keystone::ServiceState; use crate::plugin_manager::PluginManagerApi; +use crate::role::types::Role; use types::*; pub use error::AssignmentProviderError; @@ -104,6 +105,19 @@ impl AssignmentApi for AssignmentProvider { } } + // List user roles on project + #[tracing::instrument(level = "info", skip(self, state))] + async fn list_user_roles_on_project( + &self, + state: &ServiceState, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError> { + match self { + Self::Service(provider) => provider.list_user_roles_on_project(state, params).await, + #[cfg(any(test, feature = "mock"))] + Self::Mock(provider) => provider.list_user_roles_on_project(state, params).await, + } + } /// Revoke grant #[tracing::instrument(level = "info", skip(self, state))] async fn revoke_grant( diff --git a/crates/core/src/assignment/service.rs b/crates/core/src/assignment/service.rs index 9eedf74f..463fa937 100644 --- a/crates/core/src/assignment/service.rs +++ b/crates/core/src/assignment/service.rs @@ -77,6 +77,44 @@ impl AssignmentApi for AssignmentService { Ok(assignments) } + /// List user roles on project + #[tracing::instrument(level = "info", skip(self, state))] + async fn list_user_roles_on_project( + &self, + state: &ServiceState, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError> { + // Get assignments for the user on project + let assignments = self.backend_driver.list_assignments(state, params).await?; + + // If no assignments, return empty list + if assignments.is_empty() { + return Ok(Vec::new()); + } + + // Extract unique role IDs from assignments + let unique_role_ids: std::collections::BTreeSet = + assignments.iter().map(|a| a.role_id.clone()).collect(); + + // Fetch all roles and build a map + let all_roles: std::collections::BTreeMap = state + .provider + .get_role_provider() + .list_roles(state, &RoleListParameters::default()) + .await? + .into_iter() + .map(|role| (role.id.clone(), role)) + .collect(); + + // Return only the roles that are in the assignments + let roles: Vec = unique_role_ids + .into_iter() + .filter_map(|role_id| all_roles.get(&role_id).cloned()) + .collect(); + + Ok(roles) + } + /// Revoke grant async fn revoke_grant( &self, @@ -290,4 +328,172 @@ mod tests { assert!(provider.revoke_grant(&state, assignment).await.is_ok()); } + + #[tokio::test] + async fn test_list_user_roles_on_project_no_roles() { + let mut role_mock = MockRoleProvider::default(); + role_mock.expect_list_roles().returning(|_, _| Ok(vec![])); + let state = get_mocked_state(None, Some(Provider::mocked_builder().mock_role(role_mock))); + + let mut backend = MockAssignmentBackend::default(); + backend + .expect_list_assignments() + .returning(|_, _| Ok(vec![])); + + let provider = AssignmentService { + backend_driver: Arc::new(backend), + }; + + let roles = provider + .list_user_roles_on_project( + &state, + &RoleAssignmentListParameters { + user_id: Some("user_id".into()), + project_id: Some("project_id".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!( + roles.len(), + 0, + "Should return empty list when no assignments" + ); + } + + #[tokio::test] + async fn test_list_user_roles_on_project_single_role() { + let mut role_mock = MockRoleProvider::default(); + role_mock.expect_list_roles().returning(|_, _| { + Ok(vec![ + RoleBuilder::default() + .id("role_id_1") + .name("role_name_1") + .build() + .unwrap(), + ]) + }); + let state = get_mocked_state(None, Some(Provider::mocked_builder().mock_role(role_mock))); + + let mut backend = MockAssignmentBackend::default(); + backend + .expect_list_assignments() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id == Some("user_id".into()) + && params.project_id == Some("project_id".into()) + }) + .returning(|_, _| { + Ok(vec![ + AssignmentBuilder::default() + .actor_id("user_id") + .role_id("role_id_1") + .target_id("project_id") + .r#type(AssignmentType::UserProject) + .build() + .unwrap(), + ]) + }); + + let provider = AssignmentService { + backend_driver: Arc::new(backend), + }; + + let roles = provider + .list_user_roles_on_project( + &state, + &RoleAssignmentListParameters { + user_id: Some("user_id".into()), + project_id: Some("project_id".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(roles.len(), 1, "Should return one role"); + assert_eq!(roles[0].id, "role_id_1"); + assert_eq!(roles[0].name, "role_name_1"); + } + + #[tokio::test] + async fn test_list_user_roles_on_project_multiple_roles() { + let mut role_mock = MockRoleProvider::default(); + role_mock.expect_list_roles().returning(|_, _| { + Ok(vec![ + RoleBuilder::default() + .id("role_id_1") + .name("role_name_1") + .build() + .unwrap(), + RoleBuilder::default() + .id("role_id_2") + .name("role_name_2") + .build() + .unwrap(), + RoleBuilder::default() + .id("role_id_3") + .name("role_name_3") + .build() + .unwrap(), + ]) + }); + let state = get_mocked_state(None, Some(Provider::mocked_builder().mock_role(role_mock))); + + let mut backend = MockAssignmentBackend::default(); + backend + .expect_list_assignments() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id == Some("user_id".into()) + && params.project_id == Some("project_id".into()) + }) + .returning(|_, _| { + Ok(vec![ + AssignmentBuilder::default() + .actor_id("user_id") + .role_id("role_id_1") + .target_id("project_id") + .r#type(AssignmentType::UserProject) + .build() + .unwrap(), + AssignmentBuilder::default() + .actor_id("user_id") + .role_id("role_id_2") + .target_id("project_id") + .r#type(AssignmentType::UserProject) + .build() + .unwrap(), + AssignmentBuilder::default() + .actor_id("user_id") + .role_id("role_id_3") + .target_id("project_id") + .r#type(AssignmentType::UserProject) + .build() + .unwrap(), + ]) + }); + + let provider = AssignmentService { + backend_driver: Arc::new(backend), + }; + + let roles = provider + .list_user_roles_on_project( + &state, + &RoleAssignmentListParameters { + user_id: Some("user_id".into()), + project_id: Some("project_id".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + + assert_eq!(roles.len(), 3, "Should return three roles"); + let role_ids: Vec = roles.iter().map(|r| r.id.clone()).collect(); + assert!(role_ids.contains(&"role_id_1".to_string())); + assert!(role_ids.contains(&"role_id_2".to_string())); + assert!(role_ids.contains(&"role_id_3".to_string())); + } } diff --git a/crates/core/src/assignment/types/provider_api.rs b/crates/core/src/assignment/types/provider_api.rs index 9eb07de5..24c121f0 100644 --- a/crates/core/src/assignment/types/provider_api.rs +++ b/crates/core/src/assignment/types/provider_api.rs @@ -17,6 +17,7 @@ use async_trait::async_trait; use super::assignment::*; use crate::assignment::AssignmentProviderError; use crate::keystone::ServiceState; +use crate::role::types::Role; /// The trait covering [`Role`](crate::role::types::Role) assignments between /// `actors` and `objects`. @@ -49,4 +50,11 @@ pub trait AssignmentApi: Send + Sync { state: &ServiceState, params: Assignment, ) -> Result<(), AssignmentProviderError>; + + /// List user roles on project + async fn list_user_roles_on_project( + &self, + state: &ServiceState, + params: &RoleAssignmentListParameters, + ) -> Result, AssignmentProviderError>; } diff --git a/crates/keystone/src/api/v3/role_assignment/project/user/role.rs b/crates/keystone/src/api/v3/role_assignment/project/user/role.rs index c972d76f..4ad9c6d1 100644 --- a/crates/keystone/src/api/v3/role_assignment/project/user/role.rs +++ b/crates/keystone/src/api/v3/role_assignment/project/user/role.rs @@ -18,8 +18,14 @@ use crate::keystone::ServiceState; mod check; mod grant; +mod list; mod revoke; pub(crate) fn openapi_router() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(check::check, grant::grant, revoke::revoke)) + OpenApiRouter::new().routes(routes!( + check::check, + grant::grant, + revoke::revoke, + list::list + )) } diff --git a/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs b/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs new file mode 100644 index 00000000..07b04afc --- /dev/null +++ b/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs @@ -0,0 +1,304 @@ +// 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 user role: list. +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; + +use crate::api::error::KeystoneApiError; +use crate::api::v3::role::types::{Role, RoleList}; +use crate::keystone::ServiceState; +use crate::{ + api::auth::Auth, + assignment::{AssignmentApi, types::RoleAssignmentListParameters}, +}; + +/// Check whether user has role assignment on project. +/// +/// Validates that a user has a role on a project. +#[utoipa::path( + get, + path = "/projects/{project_id}/users/{user_id}/roles", + operation_id = "/project/user/role:list", + params( + ("project_id" = String, Path, description = "The project ID."), + ("user_id" = String, Path, description = "The user ID.") + ), + responses( + (status = OK, description = "Grants has been listed."), + ), + security(("x-auth" = [])), + tag="role_assignments" +)] +#[tracing::instrument( + name = "api::project_user_role_list", + level = "debug", + skip(state, user_auth), + err(Debug) +)] +pub(super) async fn list( + Auth(user_auth): Auth, + Path((project_id, user_id)): Path<(String, String)>, + State(state): State, +) -> Result { + let query_params = RoleAssignmentListParameters { + user_id: Some(user_id.clone()), + project_id: Some(project_id.clone()), + effective: Some(true), + include_names: Some(false), + ..Default::default() + }; + + let roles: Vec = state + .provider + .get_assignment_provider() + .list_user_roles_on_project(&state, &query_params) + .await? + .into_iter() + .map(Into::into) + .collect(); + + Ok((StatusCode::OK, Json(RoleList { roles })).into_response()) +} +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + use tower_http::trace::TraceLayer; + use tracing_test::traced_test; + + use crate::api::tests::get_mocked_state; + use crate::api::v3::role_assignment::openapi_router; + use crate::assignment::{MockAssignmentProvider, types::*}; + use crate::identity::{MockIdentityProvider, types::*}; + use crate::provider::Provider; + use crate::resource::MockResourceProvider; + use crate::role::{MockRoleProvider, types::*}; + + #[tokio::test] + #[traced_test] + async fn test_list_no_roles() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_user_roles_on_project() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id.as_ref().is_some_and(|x| x == "user_id") + && params + .project_id + .as_ref() + .is_some_and(|x| x == "project_id") + && params.effective.is_some_and(|x| x) + }) + .returning(|_, _| Ok(vec![])); + + let role_mock = MockRoleProvider::default(); + let resource_mock = MockResourceProvider::default(); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + let state = get_mocked_state(provider_builder, true, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + #[traced_test] + async fn test_list_single_role() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_user_roles_on_project() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id.as_ref().is_some_and(|x| x == "user_id") + && params + .project_id + .as_ref() + .is_some_and(|x| x == "project_id") + && params.effective.is_some_and(|x| x) + }) + .returning(|_, _| { + Ok(vec![ + RoleBuilder::default() + .id("role_id") + .name("role_name") + .build() + .unwrap(), + ]) + }); + + let role_mock = MockRoleProvider::default(); + let resource_mock = MockResourceProvider::default(); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + let state = get_mocked_state(provider_builder, true, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + #[traced_test] + async fn test_list_multiple_roles() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock + .expect_list_user_roles_on_project() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id.as_ref().is_some_and(|x| x == "user_id") + && params + .project_id + .as_ref() + .is_some_and(|x| x == "project_id") + && params.effective.is_some_and(|x| x) + }) + .returning(|_, _| { + Ok(vec![ + RoleBuilder::default() + .id("role_id_1") + .name("role_name_1") + .build() + .unwrap(), + RoleBuilder::default() + .id("role_id_2") + .name("role_name_2") + .build() + .unwrap(), + RoleBuilder::default() + .id("role_id_3") + .name("role_name_3") + .build() + .unwrap(), + ]) + }); + + let role_mock = MockRoleProvider::default(); + let resource_mock = MockResourceProvider::default(); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + let state = get_mocked_state(provider_builder, true, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} From eaef3835072596864caae1823ad2d1598af51841 Mon Sep 17 00:00:00 2001 From: konac-hamza Date: Tue, 24 Mar 2026 23:44:48 +0300 Subject: [PATCH 2/2] feat: Add policy to api --- .../role_assignment/project/user/role/list.rs | 260 +++++++++++++++++- policy/project/user/role/list.rego | 33 +++ policy/project/user/role/list_test.rego | 18 ++ 3 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 policy/project/user/role/list.rego create mode 100644 policy/project/user/role/list_test.rego diff --git a/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs b/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs index 07b04afc..b8ea9a00 100644 --- a/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs +++ b/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs @@ -20,12 +20,17 @@ use axum::{ response::IntoResponse, }; +use serde_json::json; +use tracing::info; + use crate::api::error::KeystoneApiError; use crate::api::v3::role::types::{Role, RoleList}; use crate::keystone::ServiceState; use crate::{ api::auth::Auth, assignment::{AssignmentApi, types::RoleAssignmentListParameters}, + identity::IdentityApi, + resource::ResourceApi, }; /// Check whether user has role assignment on project. @@ -40,7 +45,8 @@ use crate::{ ("user_id" = String, Path, description = "The user ID.") ), responses( - (status = OK, description = "Grants has been listed."), + (status = OK, description = "Roles listed successfully.", body = RoleList), + (status = FORBIDDEN, description = "User does not have permission to list roles."), ), security(("x-auth" = [])), tag="role_assignments" @@ -56,6 +62,50 @@ pub(super) async fn list( Path((project_id, user_id)): Path<(String, String)>, State(state): State, ) -> Result { + // Get project and user for policy enforcement + let (project, user) = tokio::join!( + state + .provider + .get_resource_provider() + .get_project(&state, &project_id), + state + .provider + .get_identity_provider() + .get_user(&state, &user_id) + ); + + let project = project?.ok_or_else(|| { + info!("Project {} was not found", project_id); + KeystoneApiError::NotFound { + resource: "project".into(), + identifier: project_id.clone(), + } + })?; + + let user = user?.ok_or_else(|| { + info!("User {} was not found", user_id); + KeystoneApiError::NotFound { + resource: "user".into(), + identifier: user_id.clone(), + } + })?; + + // Enforce policy + state + .policy_enforcer + .enforce( + "identity/project/user/role/list", + &user_auth, + json!({ + "user": user, + "project": project, + "target": user // The user being queried + }), + None, + ) + .await?; + + // Get roles let query_params = RoleAssignmentListParameters { user_id: Some(user_id.clone()), project_id: Some(project_id.clone()), @@ -75,6 +125,7 @@ pub(super) async fn list( Ok((StatusCode::OK, Json(RoleList { roles })).into_response()) } + #[cfg(test)] mod tests { use axum::{ @@ -90,12 +141,12 @@ mod tests { use crate::assignment::{MockAssignmentProvider, types::*}; use crate::identity::{MockIdentityProvider, types::*}; use crate::provider::Provider; - use crate::resource::MockResourceProvider; + use crate::resource::{MockResourceProvider, types::Project}; use crate::role::{MockRoleProvider, types::*}; #[tokio::test] #[traced_test] - async fn test_list_no_roles() { + async fn test_list_no_roles_allowed() { let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -126,13 +177,24 @@ mod tests { .returning(|_, _| Ok(vec![])); let role_mock = MockRoleProvider::default(); - let resource_mock = MockResourceProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); let provider_builder = Provider::mocked_builder() .mock_assignment(assignment_mock) .mock_identity(identity_mock) .mock_resource(resource_mock) .mock_role(role_mock); + // Policy enforcement allowed let state = get_mocked_state(provider_builder, true, None, None); let mut api = openapi_router() .layer(TraceLayer::new_for_http()) @@ -156,7 +218,7 @@ mod tests { #[tokio::test] #[traced_test] - async fn test_list_single_role() { + async fn test_list_single_role_allowed() { let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -195,7 +257,17 @@ mod tests { }); let role_mock = MockRoleProvider::default(); - let resource_mock = MockResourceProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); let provider_builder = Provider::mocked_builder() .mock_assignment(assignment_mock) @@ -225,7 +297,7 @@ mod tests { #[tokio::test] #[traced_test] - async fn test_list_multiple_roles() { + async fn test_list_multiple_roles_allowed() { let mut identity_mock = MockIdentityProvider::default(); identity_mock .expect_get_user() @@ -274,7 +346,17 @@ mod tests { }); let role_mock = MockRoleProvider::default(); - let resource_mock = MockResourceProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); let provider_builder = Provider::mocked_builder() .mock_assignment(assignment_mock) @@ -301,4 +383,166 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test] + #[traced_test] + async fn test_list_policy_forbidden() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); + + let assignment_mock = MockAssignmentProvider::default(); + let role_mock = MockRoleProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + // Policy enforcement NOT allowed + let state = get_mocked_state(provider_builder, false, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + #[traced_test] + async fn test_list_user_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| Ok(None)); + + let assignment_mock = MockAssignmentProvider::default(); + let role_mock = MockRoleProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "project_domain_id".into(), + ..Default::default() + })) + }); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + let state = get_mocked_state(provider_builder, true, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + #[traced_test] + async fn test_list_project_not_found() { + let mut identity_mock = MockIdentityProvider::default(); + identity_mock + .expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("user_domain_id") + .enabled(true) + .name("name") + .build() + .unwrap(), + )) + }); + + let assignment_mock = MockAssignmentProvider::default(); + let role_mock = MockRoleProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, _| Ok(None)); + + let provider_builder = Provider::mocked_builder() + .mock_assignment(assignment_mock) + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_role(role_mock); + let state = get_mocked_state(provider_builder, true, None, None); + let mut api = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state.clone()); + + let response = api + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + .header("x-auth-token", "foo") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } diff --git a/policy/project/user/role/list.rego b/policy/project/user/role/list.rego new file mode 100644 index 00000000..5c072d56 --- /dev/null +++ b/policy/project/user/role/list.rego @@ -0,0 +1,33 @@ +package identity.project.user.role.list + +import data.identity +import data.identity.assignment + +# List roles granted to a user on a project + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "reader" in input.credentials.roles + input.credentials.scope == "system" +} + +allow if { + "reader" in input.credentials.roles + assignment.project_user_role_domain_matches +} + +violation contains {"field": "role", "msg": "listing user roles on a project requires admin or reader role."} if { + not "admin" in input.credentials.roles + not "reader" in input.credentials.roles +} + +violation contains {"field": "scope", "msg": "reader role requires system scope or domain scope matching the user and project domain."} if { + "reader" in input.credentials.roles + input.credentials.scope != "system" + not assignment.project_user_role_domain_matches +} diff --git a/policy/project/user/role/list_test.rego b/policy/project/user/role/list_test.rego new file mode 100644 index 00000000..29019064 --- /dev/null +++ b/policy/project/user/role/list_test.rego @@ -0,0 +1,18 @@ +package test_project_user_role_list + +import data.identity.project.user.role.list + +test_allowed if { + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": ["reader"], "scope": "system"}} + list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}} + list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["member"]}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "bar"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "bar"}, "role": {"domain_id": "bar"}}} +}