refactor(user_roles): implement parent group info based role APIs (#8896)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Kanika Bansal
2025-08-22 18:38:23 +05:30
committed by GitHub
parent 0ba5d54349
commit e3c46b7de7
11 changed files with 414 additions and 102 deletions

View File

@ -89,6 +89,7 @@ pub async fn get_authorization_info_with_group_tag(
pub async fn get_parent_group_info(
state: SessionState,
user_from_token: auth::UserFromToken,
request: role_api::GetParentGroupsInfoQueryParams,
) -> UserResponse<Vec<role_api::ParentGroupInfo>> {
let role_info = roles::RoleInfo::from_role_id_org_id_tenant_id(
&state,
@ -102,24 +103,34 @@ pub async fn get_parent_group_info(
.await
.to_not_found_response(UserErrors::InvalidRoleId)?;
let parent_groups = ParentGroup::get_descriptions_for_groups(
role_info.get_entity_type(),
PermissionGroup::iter().collect(),
)
.unwrap_or_default()
.into_iter()
.map(|(parent_group, description)| role_api::ParentGroupInfo {
name: parent_group.clone(),
description,
scopes: PermissionGroup::iter()
.filter_map(|group| (group.parent() == parent_group).then_some(group.scope()))
// TODO: Remove this hashset conversion when merhant access
// and organization access groups are removed
.collect::<HashSet<_>>()
let entity_type = request
.entity_type
.unwrap_or_else(|| role_info.get_entity_type());
if role_info.get_entity_type() < entity_type {
return Err(report!(UserErrors::InvalidRoleOperation)).attach_printable(format!(
"Invalid operation, requestor entity type = {} cannot access entity type = {}",
role_info.get_entity_type(),
entity_type
));
}
let parent_groups =
ParentGroup::get_descriptions_for_groups(entity_type, PermissionGroup::iter().collect())
.unwrap_or_default()
.into_iter()
.collect(),
})
.collect::<Vec<_>>();
.map(|(parent_group, description)| role_api::ParentGroupInfo {
name: parent_group.clone(),
description,
scopes: PermissionGroup::iter()
.filter_map(|group| (group.parent() == parent_group).then_some(group.scope()))
// TODO: Remove this hashset conversion when merchant access
// and organization access groups are removed
.collect::<HashSet<_>>()
.into_iter()
.collect(),
})
.collect::<Vec<_>>();
Ok(ApplicationResponse::Json(parent_groups))
}

View File

@ -157,6 +157,111 @@ pub async fn create_role(
))
}
pub async fn create_role_v2(
state: SessionState,
user_from_token: UserFromToken,
req: role_api::CreateRoleV2Request,
_req_state: ReqState,
) -> UserResponse<role_api::RoleInfoResponseWithParentsGroup> {
let now = common_utils::date_time::now();
let user_entity_type = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?
.get_entity_type();
let role_entity_type = req.entity_type.unwrap_or(EntityType::Merchant);
if matches!(role_entity_type, EntityType::Organization) {
return Err(report!(UserErrors::InvalidRoleOperation))
.attach_printable("User trying to create org level custom role");
}
let requestor_entity_from_role_scope = EntityType::from(req.role_scope);
if requestor_entity_from_role_scope < role_entity_type {
return Err(report!(UserErrors::InvalidRoleOperation)).attach_printable(format!(
"User is trying to create role of type {role_entity_type} and scope {requestor_entity_from_role_scope}",
));
}
let max_from_scope_and_entity = cmp::max(requestor_entity_from_role_scope, role_entity_type);
if user_entity_type < max_from_scope_and_entity {
return Err(report!(UserErrors::InvalidRoleOperation)).attach_printable(format!(
"{user_entity_type} is trying to create of scope {requestor_entity_from_role_scope} and of type {role_entity_type}",
));
}
let role_name = RoleName::new(req.role_name.clone())?;
let permission_groups =
utils::user_role::parent_group_info_request_to_permission_groups(&req.parent_groups)?;
utils::user_role::validate_role_groups(&permission_groups)?;
utils::user_role::validate_role_name(
&state,
&role_name,
&user_from_token.merchant_id,
&user_from_token.org_id,
user_from_token
.tenant_id
.as_ref()
.unwrap_or(&state.tenant.tenant_id),
&user_from_token.profile_id,
&role_entity_type,
)
.await?;
let (org_id, merchant_id, profile_id) = match role_entity_type {
EntityType::Organization | EntityType::Tenant => (user_from_token.org_id, None, None),
EntityType::Merchant => (
user_from_token.org_id,
Some(user_from_token.merchant_id),
None,
),
EntityType::Profile => (
user_from_token.org_id,
Some(user_from_token.merchant_id),
Some(user_from_token.profile_id),
),
};
let role = state
.global_store
.insert_role(RoleNew {
role_id: generate_id_with_default_len("role"),
role_name: role_name.get_role_name(),
merchant_id,
org_id,
groups: permission_groups,
scope: req.role_scope,
entity_type: role_entity_type,
created_by: user_from_token.user_id.clone(),
last_modified_by: user_from_token.user_id,
created_at: now,
last_modified_at: now,
profile_id,
tenant_id: user_from_token.tenant_id.unwrap_or(state.tenant.tenant_id),
})
.await
.to_duplicate_response(UserErrors::RoleNameAlreadyExists)?;
let response_parent_groups =
utils::user_role::permission_groups_to_parent_group_info(&role.groups, role.entity_type);
Ok(ApplicationResponse::Json(
role_api::RoleInfoResponseWithParentsGroup {
role_id: role.role_id,
role_name: role.role_name,
role_scope: role.scope,
entity_type: role.entity_type,
parent_groups: response_parent_groups,
},
))
}
pub async fn get_role_with_groups(
state: SessionState,
user_from_token: UserFromToken,
@ -227,7 +332,7 @@ pub async fn get_parent_info_for_role(
.get_permission_groups()
.iter()
.filter_map(|group| (group.parent() == parent_group).then_some(group.scope()))
// TODO: Remove this hashset conversion when merhant access
// TODO: Remove this hashset conversion when merchant access
// and organization access groups are removed
.collect::<HashSet<_>>()
.into_iter()
@ -331,8 +436,8 @@ pub async fn update_role(
pub async fn list_roles_with_info(
state: SessionState,
user_from_token: UserFromToken,
request: role_api::ListRolesRequest,
) -> UserResponse<Vec<role_api::RoleInfoResponseNew>> {
request: role_api::ListRolesQueryParams,
) -> UserResponse<role_api::ListRolesResponse> {
let user_role_info = user_from_token
.get_role_info_from_db(&state)
.await
@ -397,25 +502,61 @@ pub async fn list_roles_with_info(
role_info_vec.extend(custom_roles.into_iter().map(roles::RoleInfo::from));
let list_role_info_response = role_info_vec
.into_iter()
.filter_map(|role_info| {
let is_lower_entity = user_role_entity >= role_info.get_entity_type();
let request_filter = request.entity_type.map_or(true, |entity_type| {
entity_type == role_info.get_entity_type()
});
if request.groups == Some(true) {
let list_role_info_response = role_info_vec
.into_iter()
.filter_map(|role_info| {
let is_lower_entity = user_role_entity >= role_info.get_entity_type();
let request_filter = request.entity_type.map_or(true, |entity_type| {
entity_type == role_info.get_entity_type()
});
(is_lower_entity && request_filter).then_some(role_api::RoleInfoResponseNew {
role_id: role_info.get_role_id().to_string(),
role_name: role_info.get_role_name().to_string(),
groups: role_info.get_permission_groups().to_vec(),
entity_type: role_info.get_entity_type(),
scope: role_info.get_scope(),
(is_lower_entity && request_filter).then_some({
let permission_groups = role_info.get_permission_groups();
let parent_groups = utils::user_role::permission_groups_to_parent_group_info(
&permission_groups,
role_info.get_entity_type(),
);
role_api::RoleInfoResponseWithParentsGroup {
role_id: role_info.get_role_id().to_string(),
role_name: role_info.get_role_name().to_string(),
entity_type: role_info.get_entity_type(),
parent_groups,
role_scope: role_info.get_scope(),
}
})
})
})
.collect::<Vec<_>>();
.collect::<Vec<_>>();
Ok(ApplicationResponse::Json(list_role_info_response))
Ok(ApplicationResponse::Json(
role_api::ListRolesResponse::WithParentGroups(list_role_info_response),
))
}
// TODO: To be deprecated
else {
let list_role_info_response = role_info_vec
.into_iter()
.filter_map(|role_info| {
let is_lower_entity = user_role_entity >= role_info.get_entity_type();
let request_filter = request.entity_type.map_or(true, |entity_type| {
entity_type == role_info.get_entity_type()
});
(is_lower_entity && request_filter).then_some(role_api::RoleInfoResponseNew {
role_id: role_info.get_role_id().to_string(),
role_name: role_info.get_role_name().to_string(),
groups: role_info.get_permission_groups().to_vec(),
entity_type: role_info.get_entity_type(),
scope: role_info.get_scope(),
})
})
.collect::<Vec<_>>();
Ok(ApplicationResponse::Json(
role_api::ListRolesResponse::WithGroups(list_role_info_response),
))
}
}
pub async fn list_roles_at_entity_level(

View File

@ -2722,45 +2722,51 @@ impl User {
}
// Role information
route =
route.service(
web::scope("/role")
.service(
web::resource("")
.route(web::get().to(user_role::get_role_from_token))
.route(web::post().to(user_role::create_role)),
)
.service(web::resource("/v2").route(
web::get().to(user_role::get_groups_and_resources_for_role_from_token),
))
// TODO: To be deprecated
.service(
web::resource("/v2/list")
.route(web::get().to(user_role::list_roles_with_info)),
)
.service(
web::scope("/list")
.service(
web::resource("")
.route(web::get().to(user_role::list_roles_with_info)),
)
.service(web::resource("/invite").route(
route = route.service(
web::scope("/role")
.service(
web::resource("")
.route(web::get().to(user_role::get_role_from_token))
// TODO: To be deprecated
.route(web::post().to(user_role::create_role)),
)
.service(
web::resource("/v2")
.route(web::post().to(user_role::create_role_v2))
.route(
web::get().to(user_role::get_groups_and_resources_for_role_from_token),
),
)
// TODO: To be deprecated
.service(
web::resource("/v2/list").route(web::get().to(user_role::list_roles_with_info)),
)
.service(
web::scope("/list")
.service(
web::resource("").route(web::get().to(user_role::list_roles_with_info)),
)
.service(
web::resource("/invite").route(
web::get().to(user_role::list_invitable_roles_at_entity_level),
))
.service(web::resource("/update").route(
),
)
.service(
web::resource("/update").route(
web::get().to(user_role::list_updatable_roles_at_entity_level),
)),
)
.service(
web::resource("/{role_id}")
.route(web::get().to(user_role::get_role))
.route(web::put().to(user_role::update_role)),
)
.service(
web::resource("/{role_id}/v2")
.route(web::get().to(user_role::get_parent_info_for_role)),
),
);
),
),
)
.service(
web::resource("/{role_id}")
.route(web::get().to(user_role::get_role))
.route(web::put().to(user_role::update_role)),
)
.service(
web::resource("/{role_id}/v2")
.route(web::get().to(user_role::get_parent_info_for_role)),
),
);
#[cfg(feature = "dummy_connector")]
{

View File

@ -331,6 +331,7 @@ impl From<Flow> for ApiIdentifier {
| Flow::AcceptInvitationsPreAuth
| Flow::DeleteUserRole
| Flow::CreateRole
| Flow::CreateRoleV2
| Flow::UpdateRole
| Flow::UserFromEmail
| Flow::ListUsersInLineage => Self::UserRole,

View File

@ -74,7 +74,7 @@ pub async fn get_groups_and_resources_for_role_from_token(
))
.await
}
// TODO: To be deprecated
pub async fn create_role(
state: web::Data<AppState>,
req: HttpRequest,
@ -95,6 +95,26 @@ pub async fn create_role(
.await
}
pub async fn create_role_v2(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<role_api::CreateRoleV2Request>,
) -> HttpResponse {
let flow = Flow::CreateRoleV2;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
json_payload.into_inner(),
role_core::create_role_v2,
&auth::JWTAuth {
permission: Permission::MerchantUserWrite,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_role(
state: web::Data<AppState>,
req: HttpRequest,
@ -274,6 +294,7 @@ pub async fn get_role_information(
pub async fn get_parent_group_info(
state: web::Data<AppState>,
http_req: HttpRequest,
query: web::Query<role_api::GetParentGroupsInfoQueryParams>,
) -> HttpResponse {
let flow = Flow::GetParentGroupInfo;
@ -281,9 +302,9 @@ pub async fn get_parent_group_info(
flow,
state.clone(),
&http_req,
(),
|state, user_from_token, _, _| async move {
user_role_core::get_parent_group_info(state, user_from_token).await
query.into_inner(),
|state, user_from_token, request, _| async move {
user_role_core::get_parent_group_info(state, user_from_token, request).await
},
&auth::JWTAuth {
permission: Permission::ProfileUserRead,
@ -317,7 +338,7 @@ pub async fn list_users_in_lineage(
pub async fn list_roles_with_info(
state: web::Data<AppState>,
req: HttpRequest,
query: web::Query<role_api::ListRolesRequest>,
query: web::Query<role_api::ListRolesQueryParams>,
) -> HttpResponse {
let flow = Flow::ListRolesV2;

View File

@ -119,6 +119,7 @@ pub trait ParentGroupExt {
entity_type: EntityType,
groups: Vec<PermissionGroup>,
) -> Option<HashMap<ParentGroup, String>>;
fn get_available_scopes(&self) -> Vec<PermissionScope>;
}
impl ParentGroupExt for ParentGroup {
@ -143,24 +144,26 @@ impl ParentGroupExt for ParentGroup {
) -> Option<HashMap<Self, String>> {
let descriptions_map = Self::iter()
.filter_map(|parent| {
let scopes = groups
.iter()
.filter(|group| group.parent() == parent)
.map(|group| group.scope())
.max()?;
let resources = parent
if !groups.iter().any(|group| group.parent() == parent) {
return None;
}
let filtered_resources: Vec<_> = parent
.resources()
.iter()
.into_iter()
.filter(|res| res.entities().iter().any(|entity| entity <= &entity_type))
.collect();
if filtered_resources.is_empty() {
return None;
}
let description = filtered_resources
.iter()
.map(|res| permissions::get_resource_name(*res, entity_type))
.collect::<Option<Vec<_>>>()?
.join(", ");
Some((
parent,
format!("{} {}", permissions::get_scope_name(scopes), resources),
))
Some((parent, description))
})
.collect::<HashMap<_, _>>();
@ -169,6 +172,13 @@ impl ParentGroupExt for ParentGroup {
.not()
.then_some(descriptions_map)
}
fn get_available_scopes(&self) -> Vec<PermissionScope> {
PermissionGroup::iter()
.filter(|group| group.parent() == *self)
.map(|group| group.scope())
.collect()
}
}
pub static OPERATIONS: [Resource; 8] = [

View File

@ -1,6 +1,10 @@
use std::{cmp, collections::HashSet};
use std::{
cmp,
collections::{HashMap, HashSet},
};
use common_enums::{EntityType, PermissionGroup};
use api_models::user_role::role as role_api;
use common_enums::{EntityType, ParentGroup, PermissionGroup};
use common_utils::id_type;
use diesel_models::{
enums::{UserRoleVersion, UserStatus},
@ -10,6 +14,7 @@ use diesel_models::{
use error_stack::{report, Report, ResultExt};
use router_env::logger;
use storage_impl::errors::StorageError;
use strum::IntoEnumIterator;
use crate::{
consts,
@ -19,7 +24,11 @@ use crate::{
user_role::{ListUserRolesByOrgIdPayload, ListUserRolesByUserIdPayload},
},
routes::SessionState,
services::authorization::{self as authz, roles},
services::authorization::{
self as authz,
permission_groups::{ParentGroupExt, PermissionGroupExt},
roles,
},
types::domain,
};
@ -508,3 +517,68 @@ pub fn get_min_entity(
Ok(cmp::min(user_entity, filter_entity))
}
pub fn parent_group_info_request_to_permission_groups(
parent_groups: &[role_api::ParentGroupInfoRequest],
) -> Result<Vec<PermissionGroup>, UserErrors> {
parent_groups
.iter()
.try_fold(Vec::new(), |mut permission_groups, parent_group| {
let scopes = &parent_group.scopes;
if scopes.is_empty() {
return Err(UserErrors::InvalidRoleOperation);
}
let available_scopes = parent_group.name.get_available_scopes();
if !scopes.iter().all(|scope| available_scopes.contains(scope)) {
return Err(UserErrors::InvalidRoleOperation);
}
let groups = PermissionGroup::iter()
.filter(|group| {
group.parent() == parent_group.name && scopes.contains(&group.scope())
})
.collect::<Vec<_>>();
permission_groups.extend(groups);
Ok(permission_groups)
})
}
pub fn permission_groups_to_parent_group_info(
permission_groups: &[PermissionGroup],
entity_type: EntityType,
) -> Vec<role_api::ParentGroupInfo> {
let parent_groups_map: HashMap<ParentGroup, Vec<common_enums::PermissionScope>> =
permission_groups
.iter()
.fold(HashMap::new(), |mut acc, group| {
let parent = group.parent();
let scope = group.scope();
acc.entry(parent).or_default().push(scope);
acc
});
parent_groups_map
.into_iter()
.filter_map(|(name, scopes)| {
let unique_scopes = scopes
.into_iter()
.collect::<HashSet<_>>()
.into_iter()
.collect();
let description =
ParentGroup::get_descriptions_for_groups(entity_type, permission_groups.to_vec())
.and_then(|descriptions| descriptions.get(&name).cloned())?;
Some(role_api::ParentGroupInfo {
name,
description,
scopes: unique_scopes,
})
})
.collect()
}