feat(user): create apis for custom role (#3763)

Co-authored-by: Mani Chandra Dulam <mani.dchandra@juspay.in>
Co-authored-by: Mani Chandra <84711804+ThisIsMani@users.noreply.github.com>
This commit is contained in:
Apoorv Dixit
2024-02-22 19:55:47 +05:30
committed by GitHub
parent 2e7d30a4ad
commit 58809ab1f9
17 changed files with 410 additions and 156 deletions

View File

@ -1,8 +1,11 @@
use common_utils::events::{ApiEventMetric, ApiEventsType};
use crate::user_role::{
AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest, GetRoleRequest,
ListRolesResponse, RoleInfoResponse, TransferOrgOwnershipRequest, UpdateUserRoleRequest,
role::{
CreateRoleRequest, GetRoleRequest, ListRolesResponse, RoleInfoResponse, UpdateRoleRequest,
},
AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest,
TransferOrgOwnershipRequest, UpdateUserRoleRequest,
};
common_utils::impl_misc_api_event_type!(
@ -13,5 +16,7 @@ common_utils::impl_misc_api_event_type!(
UpdateUserRoleRequest,
AcceptInvitationRequest,
DeleteUserRoleRequest,
TransferOrgOwnershipRequest
TransferOrgOwnershipRequest,
CreateRoleRequest,
UpdateRoleRequest
);

View File

@ -1,23 +1,8 @@
use common_enums::RoleScope;
use common_utils::pii;
use crate::user::DashboardEntryResponse;
#[derive(Debug, serde::Serialize)]
pub struct ListRolesResponse(pub Vec<RoleInfoResponse>);
#[derive(Debug, serde::Serialize)]
pub struct RoleInfoResponse {
pub role_id: String,
pub permissions: Vec<Permission>,
pub role_name: String,
pub role_scope: RoleScope,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct GetRoleRequest {
pub role_id: String,
}
pub mod role;
#[derive(Debug, serde::Serialize)]
pub enum Permission {

View File

@ -0,0 +1,30 @@
use common_enums::{PermissionGroup, RoleScope};
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct CreateRoleRequest {
pub role_name: String,
pub groups: Vec<PermissionGroup>,
pub role_scope: RoleScope,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct UpdateRoleRequest {
pub groups: Option<Vec<PermissionGroup>>,
pub role_name: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct ListRolesResponse(pub Vec<RoleInfoResponse>);
#[derive(Debug, serde::Serialize)]
pub struct RoleInfoResponse {
pub role_id: String,
pub permissions: Vec<super::Permission>,
pub role_name: String,
pub role_scope: RoleScope,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct GetRoleRequest {
pub role_id: String,
}

View File

@ -46,35 +46,25 @@ pub struct RoleUpdateInternal {
}
pub enum RoleUpdate {
UpdateGroup {
groups: Vec<enums::PermissionGroup>,
last_modified_by: String,
},
UpdateRoleName {
role_name: String,
UpdateDetails {
groups: Option<Vec<enums::PermissionGroup>>,
role_name: Option<String>,
last_modified_at: PrimitiveDateTime,
last_modified_by: String,
},
}
impl From<RoleUpdate> for RoleUpdateInternal {
fn from(value: RoleUpdate) -> Self {
let last_modified_at = common_utils::date_time::now();
match value {
RoleUpdate::UpdateGroup {
RoleUpdate::UpdateDetails {
groups,
last_modified_by,
} => Self {
groups: Some(groups),
role_name: None,
last_modified_at,
last_modified_by,
},
RoleUpdate::UpdateRoleName {
role_name,
last_modified_by,
last_modified_at,
} => Self {
groups: None,
role_name: Some(role_name),
groups,
role_name,
last_modified_at,
last_modified_by,
},

View File

@ -9,3 +9,4 @@ pub const ROLE_ID_MERCHANT_DEVELOPER: &str = "merchant_developer";
pub const ROLE_ID_MERCHANT_OPERATOR: &str = "merchant_operator";
pub const ROLE_ID_MERCHANT_CUSTOMER_SUPPORT: &str = "merchant_customer_support";
pub const INTERNAL_USER_MERCHANT_ID: &str = "juspay000";
pub const MAX_ROLE_NAME_LENGTH: usize = 64;

View File

@ -62,6 +62,10 @@ pub enum UserErrors {
RoleNotFound,
#[error("InvalidRoleOperationWithMessage")]
InvalidRoleOperationWithMessage(String),
#[error("RoleNameParsingError")]
RoleNameParsingError,
#[error("RoleNameAlreadyExists")]
RoleNameAlreadyExists,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -159,6 +163,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::InvalidRoleOperationWithMessage(_) => {
AER::BadRequest(ApiError::new(sub_code, 33, self.get_error_message(), None))
}
Self::RoleNameParsingError => {
AER::BadRequest(ApiError::new(sub_code, 34, self.get_error_message(), None))
}
Self::RoleNameAlreadyExists => {
AER::BadRequest(ApiError::new(sub_code, 35, self.get_error_message(), None))
}
}
}
}
@ -193,6 +203,8 @@ impl UserErrors {
Self::MaxInvitationsError => "Maximum invite count per request exceeded",
Self::RoleNotFound => "Role Not Found",
Self::InvalidRoleOperationWithMessage(error_message) => error_message,
Self::RoleNameParsingError => "Invalid Role Name",
Self::RoleNameAlreadyExists => "Role name already exists",
}
}
}

View File

@ -17,6 +17,8 @@ use crate::{
utils,
};
pub mod role;
pub async fn get_authorization_info(
_state: AppState,
) -> UserResponse<user_role_api::AuthorizationInfoResponse> {
@ -30,98 +32,6 @@ pub async fn get_authorization_info(
))
}
pub async fn list_invitable_roles(
state: AppState,
user_from_token: auth::UserFromToken,
) -> UserResponse<user_role_api::ListRolesResponse> {
let predefined_roles_map = roles::predefined_roles::PREDEFINED_ROLES
.iter()
.filter(|(_, role_info)| role_info.is_invitable())
.map(|(role_id, role_info)| user_role_api::RoleInfoResponse {
permissions: role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect(),
role_id: role_id.to_string(),
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
});
let custom_roles_map = state
.store
.list_all_roles(&user_from_token.merchant_id, &user_from_token.org_id)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.map(roles::RoleInfo::from)
.filter(|role_info| role_info.is_invitable())
.map(|role_info| user_role_api::RoleInfoResponse {
permissions: role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect(),
role_id: role_info.get_role_id().to_string(),
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
});
Ok(ApplicationResponse::Json(user_role_api::ListRolesResponse(
predefined_roles_map.chain(custom_roles_map).collect(),
)))
}
pub async fn get_role(
state: AppState,
user_from_token: auth::UserFromToken,
role: user_role_api::GetRoleRequest,
) -> UserResponse<user_role_api::RoleInfoResponse> {
let role_info = roles::get_role_info_from_role_id(
&state,
&role.role_id,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await
.to_not_found_response(UserErrors::InvalidRoleId)?;
if role_info.is_internal() {
return Err(UserErrors::InvalidRoleId.into());
}
let permissions = role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect();
Ok(ApplicationResponse::Json(user_role_api::RoleInfoResponse {
permissions,
role_id: role.role_id,
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
}))
}
pub async fn get_role_from_token(
state: AppState,
user_from_token: auth::UserFromToken,
) -> UserResponse<Vec<user_role_api::Permission>> {
let role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let permissions = role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect();
Ok(ApplicationResponse::Json(permissions))
}
pub async fn update_user_role(
state: AppState,
user_from_token: auth::UserFromToken,

View File

@ -0,0 +1,223 @@
use api_models::user_role::{
role::{self as role_api},
Permission,
};
use common_enums::RoleScope;
use common_utils::generate_id_with_default_len;
use diesel_models::role::{RoleNew, RoleUpdate};
use error_stack::ResultExt;
use crate::{
consts,
core::errors::{StorageErrorExt, UserErrors, UserResponse},
routes::AppState,
services::{
authentication::UserFromToken,
authorization::roles::{self, predefined_roles::PREDEFINED_ROLES},
ApplicationResponse,
},
types::domain::user::RoleName,
utils,
};
pub async fn get_role_from_token(
state: AppState,
user_from_token: UserFromToken,
) -> UserResponse<Vec<Permission>> {
let role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let permissions = role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect();
Ok(ApplicationResponse::Json(permissions))
}
pub async fn create_role(
state: AppState,
user_from_token: UserFromToken,
req: role_api::CreateRoleRequest,
) -> UserResponse<()> {
let now = common_utils::date_time::now();
let role_name = RoleName::new(req.role_name)?.get_role_name();
if req.groups.is_empty() {
return Err(UserErrors::InvalidRoleOperation.into())
.attach_printable("Role groups cannot be empty");
}
if matches!(req.role_scope, RoleScope::Organization)
&& user_from_token.role_id != consts::user_role::ROLE_ID_ORGANIZATION_ADMIN
{
return Err(UserErrors::InvalidRoleOperation.into())
.attach_printable("Non org admin user creating org level role");
}
utils::user_role::is_role_name_already_present_for_merchant(
&state,
&role_name,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await?;
state
.store
.insert_role(RoleNew {
role_id: generate_id_with_default_len("role"),
role_name,
merchant_id: user_from_token.merchant_id,
org_id: user_from_token.org_id,
groups: req.groups,
scope: req.role_scope,
created_by: user_from_token.user_id.clone(),
last_modified_by: user_from_token.user_id,
created_at: now,
last_modified_at: now,
})
.await
.to_duplicate_response(UserErrors::RoleNameAlreadyExists)?;
Ok(ApplicationResponse::StatusOk)
}
pub async fn list_invitable_roles(
state: AppState,
user_from_token: UserFromToken,
) -> UserResponse<role_api::ListRolesResponse> {
let predefined_roles_map = PREDEFINED_ROLES
.iter()
.filter(|(_, role_info)| role_info.is_invitable())
.map(|(role_id, role_info)| role_api::RoleInfoResponse {
permissions: role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect(),
role_id: role_id.to_string(),
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
});
let custom_roles_map = state
.store
.list_all_roles(&user_from_token.merchant_id, &user_from_token.org_id)
.await
.change_context(UserErrors::InternalServerError)?
.into_iter()
.map(roles::RoleInfo::from)
.filter(|role_info| role_info.is_invitable())
.map(|role_info| role_api::RoleInfoResponse {
permissions: role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect(),
role_id: role_info.get_role_id().to_string(),
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
});
Ok(ApplicationResponse::Json(role_api::ListRolesResponse(
predefined_roles_map.chain(custom_roles_map).collect(),
)))
}
pub async fn get_role(
state: AppState,
user_from_token: UserFromToken,
role: role_api::GetRoleRequest,
) -> UserResponse<role_api::RoleInfoResponse> {
let role_info = roles::get_role_info_from_role_id(
&state,
&role.role_id,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await
.to_not_found_response(UserErrors::InvalidRoleId)?;
if role_info.is_internal() {
return Err(UserErrors::InvalidRoleId.into());
}
let permissions = role_info
.get_permissions_set()
.into_iter()
.map(Into::into)
.collect();
Ok(ApplicationResponse::Json(role_api::RoleInfoResponse {
permissions,
role_id: role.role_id,
role_name: role_info.get_role_name().to_string(),
role_scope: role_info.get_scope(),
}))
}
pub async fn update_role(
state: AppState,
user_from_token: UserFromToken,
req: role_api::UpdateRoleRequest,
role_id: &str,
) -> UserResponse<()> {
let role_name = req
.role_name
.map(RoleName::new)
.transpose()?
.map(RoleName::get_role_name);
if let Some(ref role_name) = role_name {
utils::user_role::is_role_name_already_present_for_merchant(
&state,
role_name,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await?;
}
let role_info = roles::get_role_info_from_role_id(
&state,
role_id,
&user_from_token.merchant_id,
&user_from_token.org_id,
)
.await
.to_not_found_response(UserErrors::InvalidRoleOperation)?;
if matches!(role_info.get_scope(), RoleScope::Organization)
&& user_from_token.role_id != consts::user_role::ROLE_ID_ORGANIZATION_ADMIN
{
return Err(UserErrors::InvalidRoleOperation.into())
.attach_printable("Non org admin user creating org level role");
}
if let Some(ref groups) = req.groups {
if groups.is_empty() {
return Err(UserErrors::InvalidRoleOperation.into())
.attach_printable("role groups cannot be empty");
}
}
state
.store
.update_role_by_role_id(
role_id,
RoleUpdate::UpdateDetails {
groups: req.groups,
role_name,
last_modified_at: common_utils::date_time::now(),
last_modified_by: user_from_token.user_id,
},
)
.await
.to_duplicate_response(UserErrors::RoleNameAlreadyExists)?;
Ok(ApplicationResponse::StatusOk)
}

View File

@ -200,29 +200,21 @@ impl RoleInterface for MockDb {
role_update: storage::RoleUpdate,
) -> CustomResult<storage::Role, errors::StorageError> {
let mut roles = self.roles.lock().await;
let last_modified_at = common_utils::date_time::now();
roles
.iter_mut()
.find(|role| role.role_id == role_id)
.map(|role| {
*role = match role_update {
storage::RoleUpdate::UpdateGroup {
storage::RoleUpdate::UpdateDetails {
groups,
last_modified_by,
} => storage::Role {
groups,
last_modified_by,
last_modified_at,
..role.to_owned()
},
storage::RoleUpdate::UpdateRoleName {
role_name,
last_modified_by,
} => storage::Role {
role_name,
last_modified_at,
last_modified_by,
} => storage::Role {
groups: groups.unwrap_or(role.groups.to_owned()),
role_name: role_name.unwrap_or(role.role_name.to_owned()),
last_modified_by,
last_modified_at,
..role.to_owned()
},
};

View File

@ -1052,9 +1052,17 @@ impl User {
// Role information
route = route.service(
web::scope("/role")
.service(web::resource("").route(web::get().to(get_role_from_token)))
.service(
web::resource("")
.route(web::get().to(get_role_from_token))
.route(web::post().to(create_role)),
)
.service(web::resource("/list").route(web::get().to(list_all_roles)))
.service(web::resource("/{role_id}").route(web::get().to(get_role))),
.service(
web::resource("/{role_id}")
.route(web::get().to(get_role))
.route(web::put().to(update_role)),
),
);
#[cfg(feature = "dummy_connector")]

View File

@ -29,6 +29,7 @@ pub enum ApiIdentifier {
Forex,
RustLockerMigration,
Gsm,
Role,
User,
UserRole,
ConnectorOnboarding,
@ -200,7 +201,9 @@ impl From<Flow> for ApiIdentifier {
| Flow::GetAuthorizationInfo
| Flow::AcceptInvitation
| Flow::DeleteUserRole
| Flow::TransferOrgOwnership => Self::UserRole,
| Flow::TransferOrgOwnership
| Flow::CreateRole
| Flow::UpdateRole => Self::UserRole,
Flow::GetActionUrl | Flow::SyncOnboardingStatus | Flow::ResetTrackingId => {
Self::ConnectorOnboarding

View File

@ -7,7 +7,7 @@ use crate::{
core::{api_locking, user_role as user_role_core},
services::{
api,
authentication::{self as auth, UserFromToken},
authentication::{self as auth},
authorization::permissions::Permission,
},
};
@ -29,6 +29,38 @@ pub async fn get_authorization_info(
.await
}
pub async fn get_role_from_token(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::GetRoleFromToken;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
(),
|state, user, _| user_role_core::role::get_role_from_token(state, user),
&auth::DashboardNoPermissionAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn create_role(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_role_api::role::CreateRoleRequest>,
) -> HttpResponse {
let flow = Flow::CreateRole;
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
json_payload.into_inner(),
user_role_core::role::create_role,
&auth::JWTAuth(Permission::UsersWrite),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn list_all_roles(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::ListRoles;
Box::pin(api::server_wrap(
@ -36,7 +68,7 @@ pub async fn list_all_roles(state: web::Data<AppState>, req: HttpRequest) -> Htt
state.clone(),
&req,
(),
|state, user, _| user_role_core::list_invitable_roles(state, user),
|state, user, _| user_role_core::role::list_invitable_roles(state, user),
&auth::JWTAuth(Permission::UsersRead),
api_locking::LockAction::NotApplicable,
))
@ -49,7 +81,7 @@ pub async fn get_role(
path: web::Path<String>,
) -> HttpResponse {
let flow = Flow::GetRole;
let request_payload = user_role_api::GetRoleRequest {
let request_payload = user_role_api::role::GetRoleRequest {
role_id: path.into_inner(),
};
Box::pin(api::server_wrap(
@ -57,22 +89,29 @@ pub async fn get_role(
state.clone(),
&req,
request_payload,
user_role_core::get_role,
user_role_core::role::get_role,
&auth::JWTAuth(Permission::UsersRead),
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_role_from_token(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
let flow = Flow::GetRoleFromToken;
pub async fn update_role(
state: web::Data<AppState>,
req: HttpRequest,
json_payload: web::Json<user_role_api::role::UpdateRoleRequest>,
path: web::Path<String>,
) -> HttpResponse {
let flow = Flow::UpdateRole;
let role_id = path.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
(),
|state, user: UserFromToken, _| user_role_core::get_role_from_token(state, user),
&auth::DashboardNoPermissionAuth,
json_payload.into_inner(),
|state, user, req| user_role_core::role::update_role(state, user, req, &role_id),
&auth::JWTAuth(Permission::UsersWrite),
api_locking::LockAction::NotApplicable,
))
.await

View File

@ -926,3 +926,23 @@ impl ForeignFrom<UserStatus> for user_role_api::UserStatus {
}
}
}
#[derive(Clone)]
pub struct RoleName(String);
impl RoleName {
pub fn new(name: String) -> UserResult<Self> {
let is_empty_or_whitespace = name.trim().is_empty();
let is_too_long = name.graphemes(true).count() > consts::user_role::MAX_ROLE_NAME_LENGTH;
if is_empty_or_whitespace || is_too_long || name.contains(' ') {
Err(UserErrors::RoleNameParsingError.into())
} else {
Ok(Self(name.to_lowercase()))
}
}
pub fn get_role_name(self) -> String {
self.0
}
}

View File

@ -1,6 +1,11 @@
use api_models::user_role as user_role_api;
use error_stack::ResultExt;
use crate::services::authorization::permissions::Permission;
use crate::{
core::errors::{UserErrors, UserResult},
routes::AppState,
services::authorization::permissions::Permission,
};
impl From<Permission> for user_role_api::Permission {
fn from(value: Permission) -> Self {
@ -34,3 +39,24 @@ impl From<Permission> for user_role_api::Permission {
}
}
}
pub async fn is_role_name_already_present_for_merchant(
state: &AppState,
role_name: &str,
merchant_id: &str,
org_id: &str,
) -> UserResult<()> {
let role_name_list: Vec<String> = state
.store
.list_all_roles(merchant_id, org_id)
.await
.change_context(UserErrors::InternalServerError)?
.iter()
.map(|role| role.role_name.to_owned())
.collect();
if role_name_list.contains(&role_name.to_string()) {
return Err(UserErrors::RoleNameAlreadyExists.into());
}
Ok(())
}

View File

@ -363,6 +363,10 @@ pub enum Flow {
UpdateUserAccountDetails,
/// Accept user invitation
AcceptInvitation,
/// Create Role
CreateRole,
/// Update Role
UpdateRole,
}
///

View File

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP INDEX role_name_org_id_org_scope_index;
DROP INDEX role_name_merchant_id_merchant_scope_index;

View File

@ -0,0 +1,3 @@
-- Your SQL goes here
CREATE UNIQUE INDEX role_name_org_id_org_scope_index ON roles(org_id, role_name) WHERE scope='organization';
CREATE UNIQUE INDEX role_name_merchant_id_merchant_scope_index ON roles(merchant_id, role_name) WHERE scope='merchant';