From e7da5ad020c1cfa9a42e9d86dc461c00a630470b Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Wed, 20 May 2026 21:36:53 +0000 Subject: [PATCH 1/6] feat:Add api to list roles --- crates/api-types/src/v3/role_assignment.rs | 10 ++ .../api-types/src/v3/role_assignment_conv.rs | 11 ++ .../v3/role_assignment/project/user/role.rs | 8 +- .../role_assignment/project/user/role/list.rs | 128 ++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs diff --git a/crates/api-types/src/v3/role_assignment.rs b/crates/api-types/src/v3/role_assignment.rs index eb8f3202..ad736f9e 100644 --- a/crates/api-types/src/v3/role_assignment.rs +++ b/crates/api-types/src/v3/role_assignment.rs @@ -175,3 +175,13 @@ pub struct RoleAssignmentListParameters { #[serde(default)] pub include_names: Option, } + +/// Role assignments - List of roles. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[cfg_attr(feature = "validate", derive(validator::Validate))] +pub struct RoleAssignmentRoleList { + /// Collection of role objects from assignments. + #[cfg_attr(feature = "validate", validate(nested))] + pub roles: Vec, +} diff --git a/crates/api-types/src/v3/role_assignment_conv.rs b/crates/api-types/src/v3/role_assignment_conv.rs index e1cea679..1df12d17 100644 --- a/crates/api-types/src/v3/role_assignment_conv.rs +++ b/crates/api-types/src/v3/role_assignment_conv.rs @@ -104,6 +104,17 @@ impl TryFrom } } +impl TryFrom for api_types::Role { + type Error = KeystoneApiError; + + fn try_from(value: provider_types::Assignment) -> Result { + Ok(api_types::Role { + id: value.role_id, + name: value.role_name, + }) + } +} + #[cfg(test)] mod tests { use crate::v3::role_assignment::*; 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..937d413b --- /dev/null +++ b/crates/keystone/src/api/v3/role_assignment/project/user/role/list.rs @@ -0,0 +1,128 @@ +// 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 serde_json::json; +use tracing::info; + + +use openstack_keystone_api_types::v3::role_assignment::{Role, RoleAssignmentRoleList}; +use openstack_keystone_core_types::assignment::RoleAssignmentListParameters; + +use crate::api::error::KeystoneApiError; +use crate::keystone::ServiceState; +use crate::{ + api::auth::Auth, assignment::AssignmentApi, identity::IdentityApi, resource::ResourceApi}; + +/// List the roles that a user has on a project. +/// +/// List the roles that a user has on a project. +#[utoipa::path( + head, + 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 = "List of roles", example = json!([])), + (status = 404, description = "User or project not found", example = json!(KeystoneApiError::NotFound(String::from("id = 1")))) + ), + 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() + }; + // Use join instead of try_join to have more constant latency preventing timing + // attacks. + let (user, project, assignments) = tokio::join!( + state + .provider + .get_identity_provider() + .get_user(&state, &user_id), + state + .provider + .get_resource_provider() + .get_project(&state, &project_id), + state + .provider + .get_assignment_provider() + .list_role_assignments(&state, &query_params) + ); + let user = user?.ok_or_else(|| { + info!("User {} was not found", user_id); + KeystoneApiError::NotFound { + resource: "grant".into(), + identifier: "".into(), + } + })?; + let project = project?.ok_or_else(|| { + info!("Project {} was not found", project_id); + KeystoneApiError::NotFound { + resource: "grant".into(), + identifier: "".into(), + } + })?; + // let role = role?.ok_or_else(|| { + // info!("Role {} was not found", role_id); + // KeystoneApiError::NotFound { + // resource: "grant".into(), + // identifier: "".into(), + // } + // })?; + + state + .policy_enforcer + .enforce( + "identity/project/user/role/list", + &user_auth, + json!({"user": user, "project": project}), + None, + ) + .await?; + + // let grants: Vec = assignments?.into_iter().collect(); + + let roles: Vec = assignments? + .into_iter() + .map(|a| a.try_into()) + .collect::, _>>()?; + + Ok((StatusCode::OK, Json(RoleAssignmentRoleList { roles })).into_response()) +} From 4aa5e4683bd29fea4cd051e016cfb84060650f06 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Thu, 21 May 2026 21:52:40 +0000 Subject: [PATCH 2/6] feat: Add rego files --- .../role_assignment/project/user/role/list.rs | 263 +++++++++++++++++- policy/resource/project/user/role/list.rego | 27 ++ .../resource/project/user/role/list_test.rego | 19 ++ 3 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 policy/resource/project/user/role/list.rego create mode 100644 policy/resource/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 937d413b..0679e4e7 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 @@ -36,7 +36,7 @@ use crate::{ /// /// List the roles that a user has on a project. #[utoipa::path( - head, + get, path = "/projects/{project_id}/users/{user_id}/roles", operation_id = "/project/user/role:list", params( @@ -71,7 +71,7 @@ pub(super) async fn list( }; // Use join instead of try_join to have more constant latency preventing timing // attacks. - let (user, project, assignments) = tokio::join!( + let (user,project, assignments) = tokio::join!( state .provider .get_identity_provider() @@ -99,14 +99,6 @@ pub(super) async fn list( identifier: "".into(), } })?; - // let role = role?.ok_or_else(|| { - // info!("Role {} was not found", role_id); - // KeystoneApiError::NotFound { - // resource: "grant".into(), - // identifier: "".into(), - // } - // })?; - state .policy_enforcer .enforce( @@ -126,3 +118,254 @@ pub(super) async fn list( Ok((StatusCode::OK, Json(RoleAssignmentRoleList { 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 openstack_keystone_core_types::assignment::RoleAssignmentListParameters; + use openstack_keystone_core_types::identity::*; + use openstack_keystone_core_types::resource::*; + + use crate::api::tests::get_mocked_state; + use crate::api::v3::role_assignment::openapi_router; + use crate::assignment::MockAssignmentProvider; + use crate::identity::MockIdentityProvider; + use crate::provider::Provider; + use crate::resource::MockResourceProvider; + + fn user_mock(mock: &mut MockIdentityProvider) { + mock.expect_get_user() + .withf(|_, id: &'_ str| id == "user_id") + .returning(|_, _| { + Ok(Some( + UserResponseBuilder::default() + .id("user_id") + .domain_id("domain_id") + .enabled(true) + .name("uname") + .build() + .unwrap(), + )) + }); + } + + fn project_mock(mock: &mut MockResourceProvider) { + mock.expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, id: &'_ str| { + Ok(Some(Project { + id: id.to_string(), + domain_id: "domain_id".into(), + ..Default::default() + })) + }); + } + + fn assignment_mock_empty(mock: &mut MockAssignmentProvider) { + mock.expect_list_role_assignments() + .withf(|_, params: &RoleAssignmentListParameters| { + params.user_id.as_deref() == Some("user_id") + && params.project_id.as_deref() == Some("project_id") + && params.effective == Some(true) + && params.include_names == Some(false) + }) + .returning(|_, _| Ok(vec![])); + } + + #[tokio::test] + async fn test_list_success() { + let mut identity_mock = MockIdentityProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + let mut assignment_mock = MockAssignmentProvider::default(); + + user_mock(&mut identity_mock); + project_mock(&mut resource_mock); + assignment_mock_empty(&mut assignment_mock); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_assignment(assignment_mock), + true, + None, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .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] + async fn test_list_forbidden() { + let mut identity_mock = MockIdentityProvider::default(); + let mut resource_mock = MockResourceProvider::default(); + let mut assignment_mock = MockAssignmentProvider::default(); + + user_mock(&mut identity_mock); + project_mock(&mut resource_mock); + // join! fetches assignments before policy enforcement — mock is required + assignment_mock_empty(&mut assignment_mock); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_assignment(assignment_mock), + false, // policy denies + None, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .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] + async fn test_list_unauthorized() { + let state = get_mocked_state(Provider::mocked_builder(), true, None, None).await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .as_service() + .oneshot( + Request::builder() + .method("GET") + .uri("/projects/project_id/users/user_id/roles") + // no x-auth-token header + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[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)); // user not found + + let mut resource_mock = MockResourceProvider::default(); + project_mock(&mut resource_mock); + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock_empty(&mut assignment_mock); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_assignment(assignment_mock), + true, + None, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .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(); + user_mock(&mut identity_mock); + + let mut resource_mock = MockResourceProvider::default(); + resource_mock + .expect_get_project() + .withf(|_, pid: &'_ str| pid == "project_id") + .returning(|_, _| Ok(None)); // project not found + + let mut assignment_mock = MockAssignmentProvider::default(); + assignment_mock_empty(&mut assignment_mock); + + let state = get_mocked_state( + Provider::mocked_builder() + .mock_identity(identity_mock) + .mock_resource(resource_mock) + .mock_assignment(assignment_mock), + true, + None, + None, + ) + .await; + + let response = openapi_router() + .layer(TraceLayer::new_for_http()) + .with_state(state) + .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); + } +} \ No newline at end of file diff --git a/policy/resource/project/user/role/list.rego b/policy/resource/project/user/role/list.rego new file mode 100644 index 00000000..1dff0ec2 --- /dev/null +++ b/policy/resource/project/user/role/list.rego @@ -0,0 +1,27 @@ +# METADATA +# description: Policy for listing roles of a user in a project +package identity.project.user.role.list + +import data.identity +import data.identity.assignment + +default allow := false + +allow if { + "admin" in input.credentials.roles +} + +allow if { + "manager" in input.credentials.roles + assignment.project_role_domain_matches +} + +allow if { + input.credentials.user_id == input.target.user_id +} + +violation contains {"field": "user_id", "msg": "listing roles requires admin, a domain-scoped manager, or the requesting user to match the target user."} if { + not "admin" in input.credentials.roles + not "manager" in input.credentials.roles + input.credentials.user_id != input.target.user_id +} \ No newline at end of file diff --git a/policy/resource/project/user/role/list_test.rego b/policy/resource/project/user/role/list_test.rego new file mode 100644 index 00000000..3ceafb89 --- /dev/null +++ b/policy/resource/project/user/role/list_test.rego @@ -0,0 +1,19 @@ +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": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}} + list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + list.allow with input as {"credentials": {"roles": ["reader"], "user_id": "u1"}, "target": {"user_id": "u1"}} +} + +test_forbidden if { + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["reader"], "user_id": "u1"}, "target": {"user_id": "u2"}} + not list.allow with input as {"credentials": {"roles": ["member"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"]}, "target": {"user_id": "u1"}} +} \ No newline at end of file From a2855083951cc92d5947eaa65814f855ba97082e Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Thu, 21 May 2026 22:00:03 +0000 Subject: [PATCH 3/6] fix: Fix format --- .../src/api/v3/role_assignment/project/user/role/list.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 0679e4e7..86ad25b0 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 @@ -23,14 +23,14 @@ use axum::{ use serde_json::json; use tracing::info; - use openstack_keystone_api_types::v3::role_assignment::{Role, RoleAssignmentRoleList}; use openstack_keystone_core_types::assignment::RoleAssignmentListParameters; use crate::api::error::KeystoneApiError; use crate::keystone::ServiceState; use crate::{ - api::auth::Auth, assignment::AssignmentApi, identity::IdentityApi, resource::ResourceApi}; + api::auth::Auth, assignment::AssignmentApi, identity::IdentityApi, resource::ResourceApi, +}; /// List the roles that a user has on a project. /// @@ -71,7 +71,7 @@ pub(super) async fn list( }; // Use join instead of try_join to have more constant latency preventing timing // attacks. - let (user,project, assignments) = tokio::join!( + let (user, project, assignments) = tokio::join!( state .provider .get_identity_provider() @@ -119,7 +119,6 @@ pub(super) async fn list( Ok((StatusCode::OK, Json(RoleAssignmentRoleList { roles })).into_response()) } - #[cfg(test)] mod tests { use axum::{ @@ -368,4 +367,4 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } -} \ No newline at end of file +} From 3a1a669512c031c499cae882d921db0f3359bec0 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Mon, 1 Jun 2026 21:34:03 +0000 Subject: [PATCH 4/6] feat: Update tests with ValidatedSecurityContext --- .../role_assignment/project/user/role/list.rs | 62 ++++++++++--------- policy/resource/project/user/role/list.rego | 17 +++-- .../resource/project/user/role/list_test.rego | 23 +++---- 3 files changed, 53 insertions(+), 49 deletions(-) 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 86ad25b0..91e39a91 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 @@ -62,13 +62,24 @@ pub(super) async fn list( Path((project_id, user_id)): Path<(String, String)>, State(state): State, ) -> Result { + state + .policy_enforcer + .enforce( + "identity/project/user/role/list", + &user_auth, + json!({"project_id": project_id, "user_id": user_id}), + None, + ) + .await?; + let query_params = RoleAssignmentListParameters { user_id: Some(user_id.clone()), project_id: Some(project_id.clone()), - effective: Some(true), + effective: Some(false), include_names: Some(false), ..Default::default() }; + // Use join instead of try_join to have more constant latency preventing timing // attacks. let (user, project, assignments) = tokio::join!( @@ -85,31 +96,21 @@ pub(super) async fn list( .get_assignment_provider() .list_role_assignments(&state, &query_params) ); - let user = user?.ok_or_else(|| { + user?.ok_or_else(|| { info!("User {} was not found", user_id); KeystoneApiError::NotFound { resource: "grant".into(), identifier: "".into(), } })?; - let project = project?.ok_or_else(|| { + + project?.ok_or_else(|| { info!("Project {} was not found", project_id); KeystoneApiError::NotFound { resource: "grant".into(), identifier: "".into(), } })?; - state - .policy_enforcer - .enforce( - "identity/project/user/role/list", - &user_auth, - json!({"user": user, "project": project}), - None, - ) - .await?; - - // let grants: Vec = assignments?.into_iter().collect(); let roles: Vec = assignments? .into_iter() @@ -133,7 +134,7 @@ mod tests { use openstack_keystone_core_types::identity::*; use openstack_keystone_core_types::resource::*; - use crate::api::tests::get_mocked_state; + use crate::api::tests::{get_mocked_state, test_fixture_scoped}; use crate::api::v3::role_assignment::openapi_router; use crate::assignment::MockAssignmentProvider; use crate::identity::MockIdentityProvider; @@ -173,7 +174,7 @@ mod tests { .withf(|_, params: &RoleAssignmentListParameters| { params.user_id.as_deref() == Some("user_id") && params.project_id.as_deref() == Some("project_id") - && params.effective == Some(true) + && params.effective == Some(false) && params.include_names == Some(false) }) .returning(|_, _| Ok(vec![])); @@ -196,10 +197,11 @@ mod tests { .mock_assignment(assignment_mock), true, None, - None, ) .await; + let vsc = test_fixture_scoped(); + let response = openapi_router() .layer(TraceLayer::new_for_http()) .with_state(state) @@ -208,7 +210,7 @@ mod tests { Request::builder() .method("GET") .uri("/projects/project_id/users/user_id/roles") - .header("x-auth-token", "foo") + .extension(vsc) .body(Body::empty()) .unwrap(), ) @@ -226,7 +228,6 @@ mod tests { user_mock(&mut identity_mock); project_mock(&mut resource_mock); - // join! fetches assignments before policy enforcement — mock is required assignment_mock_empty(&mut assignment_mock); let state = get_mocked_state( @@ -236,10 +237,11 @@ mod tests { .mock_assignment(assignment_mock), false, // policy denies None, - None, ) .await; + let vsc = test_fixture_scoped(); + let response = openapi_router() .layer(TraceLayer::new_for_http()) .with_state(state) @@ -248,7 +250,7 @@ mod tests { Request::builder() .method("GET") .uri("/projects/project_id/users/user_id/roles") - .header("x-auth-token", "foo") + .extension(vsc) .body(Body::empty()) .unwrap(), ) @@ -260,7 +262,7 @@ mod tests { #[tokio::test] async fn test_list_unauthorized() { - let state = get_mocked_state(Provider::mocked_builder(), true, None, None).await; + let state = get_mocked_state(Provider::mocked_builder(), true, None).await; let response = openapi_router() .layer(TraceLayer::new_for_http()) @@ -270,7 +272,7 @@ mod tests { Request::builder() .method("GET") .uri("/projects/project_id/users/user_id/roles") - // no x-auth-token header + // no extension = no auth context = 401 .body(Body::empty()) .unwrap(), ) @@ -287,7 +289,7 @@ mod tests { identity_mock .expect_get_user() .withf(|_, id: &'_ str| id == "user_id") - .returning(|_, _| Ok(None)); // user not found + .returning(|_, _| Ok(None)); let mut resource_mock = MockResourceProvider::default(); project_mock(&mut resource_mock); @@ -302,10 +304,11 @@ mod tests { .mock_assignment(assignment_mock), true, None, - None, ) .await; + let vsc = test_fixture_scoped(); + let response = openapi_router() .layer(TraceLayer::new_for_http()) .with_state(state) @@ -314,7 +317,7 @@ mod tests { Request::builder() .method("GET") .uri("/projects/project_id/users/user_id/roles") - .header("x-auth-token", "foo") + .extension(vsc) .body(Body::empty()) .unwrap(), ) @@ -334,7 +337,7 @@ mod tests { resource_mock .expect_get_project() .withf(|_, pid: &'_ str| pid == "project_id") - .returning(|_, _| Ok(None)); // project not found + .returning(|_, _| Ok(None)); let mut assignment_mock = MockAssignmentProvider::default(); assignment_mock_empty(&mut assignment_mock); @@ -346,10 +349,11 @@ mod tests { .mock_assignment(assignment_mock), true, None, - None, ) .await; + let vsc = test_fixture_scoped(); + let response = openapi_router() .layer(TraceLayer::new_for_http()) .with_state(state) @@ -358,7 +362,7 @@ mod tests { Request::builder() .method("GET") .uri("/projects/project_id/users/user_id/roles") - .header("x-auth-token", "foo") + .extension(vsc) .body(Body::empty()) .unwrap(), ) diff --git a/policy/resource/project/user/role/list.rego b/policy/resource/project/user/role/list.rego index 1dff0ec2..83442855 100644 --- a/policy/resource/project/user/role/list.rego +++ b/policy/resource/project/user/role/list.rego @@ -8,20 +8,19 @@ import data.identity.assignment default allow := false allow if { - "admin" in input.credentials.roles + "admin" in input.credentials.roles } allow if { - "manager" in input.credentials.roles - assignment.project_role_domain_matches + "reader" in input.credentials.roles + input.credentials.system == "all" } allow if { - input.credentials.user_id == input.target.user_id + "reader" in input.credentials.roles + assignment.project_user_role_domain_matches } -violation contains {"field": "user_id", "msg": "listing roles requires admin, a domain-scoped manager, or the requesting user to match the target user."} if { - not "admin" in input.credentials.roles - not "manager" in input.credentials.roles - input.credentials.user_id != input.target.user_id -} \ No newline at end of file +violation contains {"field": "domain_id", "msg": "checking project-user-role assignment requires domain scope matching the domain of all targets."} if { + not assignment.project_user_role_domain_matches +} diff --git a/policy/resource/project/user/role/list_test.rego b/policy/resource/project/user/role/list_test.rego index 3ceafb89..f2faf76f 100644 --- a/policy/resource/project/user/role/list_test.rego +++ b/policy/resource/project/user/role/list_test.rego @@ -3,17 +3,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": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}} - list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} - list.allow with input as {"credentials": {"roles": ["reader"], "user_id": "u1"}, "target": {"user_id": "u1"}} + check.allow with input as {"credentials": {"roles": ["admin"]}} + check.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} + check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}} + check.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": ["reader"], "user_id": "u1"}, "target": {"user_id": "u2"}} - not list.allow with input as {"credentials": {"roles": ["member"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} - not list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not list.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo", "user_id": "u1"}, "target": {"user_id": "u2", "user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo1"}}} - not list.allow with input as {"credentials": {"roles": ["reader"]}, "target": {"user_id": "u1"}} -} \ No newline at end of file + not check.allow with input as {"credentials": {"roles": []}} + not check.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}} + not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} +} From a040c8576a0d52ffea6ee49e81b7ddf5975b5bd3 Mon Sep 17 00:00:00 2001 From: Hamza Konac Date: Mon, 1 Jun 2026 21:37:46 +0000 Subject: [PATCH 5/6] fix: Fix package name --- .../resource/project/user/role/list_test.rego | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/policy/resource/project/user/role/list_test.rego b/policy/resource/project/user/role/list_test.rego index f2faf76f..47aa9bdc 100644 --- a/policy/resource/project/user/role/list_test.rego +++ b/policy/resource/project/user/role/list_test.rego @@ -3,18 +3,18 @@ package test_project_user_role_list import data.identity.project.user.role.list test_allowed if { - check.allow with input as {"credentials": {"roles": ["admin"]}} - check.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} - check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}} - check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + list.allow with input as {"credentials": {"roles": ["admin"]}} + list.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}} + 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 check.allow with input as {"credentials": {"roles": []}} - not check.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}} - not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} - not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": []}} + not list.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} } From 11497478cb3f013c334b0a22b29b37ecd77c793f Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 2 Jun 2026 09:30:26 +0200 Subject: [PATCH 6/6] chore: Minor corrections --- .../role_assignment/project/user/role/list.rs | 27 +++++++++---------- policy/resource/project/user/role/list.rego | 8 ++++++ .../resource/project/user/role/list_test.rego | 12 +++++---- 3 files changed, 27 insertions(+), 20 deletions(-) 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 91e39a91..7b317b5d 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 @@ -32,8 +32,6 @@ use crate::{ api::auth::Auth, assignment::AssignmentApi, identity::IdentityApi, resource::ResourceApi, }; -/// List the roles that a user has on a project. -/// /// List the roles that a user has on a project. #[utoipa::path( get, @@ -56,22 +54,11 @@ use crate::{ 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 { - state - .policy_enforcer - .enforce( - "identity/project/user/role/list", - &user_auth, - json!({"project_id": project_id, "user_id": user_id}), - None, - ) - .await?; - let query_params = RoleAssignmentListParameters { user_id: Some(user_id.clone()), project_id: Some(project_id.clone()), @@ -96,7 +83,7 @@ pub(super) async fn list( .get_assignment_provider() .list_role_assignments(&state, &query_params) ); - user?.ok_or_else(|| { + let user = user?.ok_or_else(|| { info!("User {} was not found", user_id); KeystoneApiError::NotFound { resource: "grant".into(), @@ -104,7 +91,7 @@ pub(super) async fn list( } })?; - project?.ok_or_else(|| { + let project = project?.ok_or_else(|| { info!("Project {} was not found", project_id); KeystoneApiError::NotFound { resource: "grant".into(), @@ -112,6 +99,16 @@ pub(super) async fn list( } })?; + state + .policy_enforcer + .enforce( + "identity/project/user/role/list", + &user_auth, + json!({"user": user, "project": project}), + None, + ) + .await?; + let roles: Vec = assignments? .into_iter() .map(|a| a.try_into()) diff --git a/policy/resource/project/user/role/list.rego b/policy/resource/project/user/role/list.rego index 83442855..7f231209 100644 --- a/policy/resource/project/user/role/list.rego +++ b/policy/resource/project/user/role/list.rego @@ -5,6 +5,14 @@ package identity.project.user.role.list import data.identity import data.identity.assignment +# List direct (non-effective) user roles on the project. +# +# The `input.target` contains resolved project and user objects: +# project: `Project` Resolved Project +# user: `User` Resolved User +# +# The `input.existing` is null +# default allow := false allow if { diff --git a/policy/resource/project/user/role/list_test.rego b/policy/resource/project/user/role/list_test.rego index 47aa9bdc..59b519c9 100644 --- a/policy/resource/project/user/role/list_test.rego +++ b/policy/resource/project/user/role/list_test.rego @@ -12,9 +12,11 @@ test_allowed if { test_forbidden if { not list.allow with input as {"credentials": {"roles": []}} not list.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}} - not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} - not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}} - not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "project_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "bar"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "bar"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "bar"}, "project": {"domain_id": "bar"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}}} + not list.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}}} }