diff --git a/crates/api_models/src/events/user_role.rs b/crates/api_models/src/events/user_role.rs index c8d8fd96a7a6..3ec30d6bd975 100644 --- a/crates/api_models/src/events/user_role.rs +++ b/crates/api_models/src/events/user_role.rs @@ -1,8 +1,8 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use crate::user_role::{ - AcceptInvitationRequest, AuthorizationInfoResponse, GetRoleRequest, ListRolesResponse, - RoleInfoResponse, UpdateUserRoleRequest, + AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest, GetRoleRequest, + ListRolesResponse, RoleInfoResponse, UpdateUserRoleRequest, }; common_utils::impl_misc_api_event_type!( @@ -11,5 +11,6 @@ common_utils::impl_misc_api_event_type!( GetRoleRequest, AuthorizationInfoResponse, UpdateUserRoleRequest, - AcceptInvitationRequest + AcceptInvitationRequest, + DeleteUserRoleRequest ); diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs index d2548935f62a..e8c9b777c7f1 100644 --- a/crates/api_models/src/user_role.rs +++ b/crates/api_models/src/user_role.rs @@ -1,3 +1,5 @@ +use common_utils::pii; + use crate::user::DashboardEntryResponse; #[derive(Debug, serde::Serialize)] @@ -101,3 +103,8 @@ pub struct AcceptInvitationRequest { } pub type AcceptInvitationResponse = DashboardEntryResponse; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct DeleteUserRoleRequest { + pub email: pii::Email, +} diff --git a/crates/diesel_models/src/query/dashboard_metadata.rs b/crates/diesel_models/src/query/dashboard_metadata.rs index 678bcc2fd1f6..b1cb034eb1f6 100644 --- a/crates/diesel_models/src/query/dashboard_metadata.rs +++ b/crates/diesel_models/src/query/dashboard_metadata.rs @@ -104,4 +104,18 @@ impl DashboardMetadata { ) .await } + + pub async fn delete_user_scoped_dashboard_metadata_by_merchant_id( + conn: &PgPooledConn, + user_id: String, + merchant_id: String, + ) -> StorageResult { + generics::generic_delete::<::Table, _>( + conn, + dsl::user_id + .eq(user_id) + .and(dsl::merchant_id.eq(merchant_id)), + ) + .await + } } diff --git a/crates/diesel_models/src/query/user_role.rs b/crates/diesel_models/src/query/user_role.rs index 6b408038ef55..e67eba64c7cd 100644 --- a/crates/diesel_models/src/query/user_role.rs +++ b/crates/diesel_models/src/query/user_role.rs @@ -54,9 +54,18 @@ impl UserRole { .await } - pub async fn delete_by_user_id(conn: &PgPooledConn, user_id: String) -> StorageResult { - generics::generic_delete::<::Table, _>(conn, dsl::user_id.eq(user_id)) - .await + pub async fn delete_by_user_id_merchant_id( + conn: &PgPooledConn, + user_id: String, + merchant_id: String, + ) -> StorageResult { + generics::generic_delete::<::Table, _>( + conn, + dsl::user_id + .eq(user_id) + .and(dsl::merchant_id.eq(merchant_id)), + ) + .await } pub async fn list_by_user_id(conn: &PgPooledConn, user_id: String) -> StorageResult> { diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 330e02cd5471..f4000755b3ec 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -54,6 +54,8 @@ pub enum UserErrors { MerchantIdParsingError, #[error("ChangePasswordError")] ChangePasswordError, + #[error("InvalidDeleteOperation")] + InvalidDeleteOperation, } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -157,6 +159,12 @@ impl common_utils::errors::ErrorSwitch AER::BadRequest(ApiError::new( + sub_code, + 30, + "Delete Operation Not Supported", + None, + )), } } } diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs index 245f8d246d23..742c281b89ad 100644 --- a/crates/router/src/core/user_role.rs +++ b/crates/router/src/core/user_role.rs @@ -1,6 +1,7 @@ use api_models::user_role as user_role_api; use diesel_models::{enums::UserStatus, user_role::UserRoleUpdate}; use error_stack::ResultExt; +use masking::ExposeInterface; use router_env::logger; use crate::{ @@ -11,6 +12,7 @@ use crate::{ authorization::{info, predefined_permissions}, ApplicationResponse, }, + types::domain, utils, }; @@ -161,3 +163,88 @@ pub async fn accept_invitation( Ok(ApplicationResponse::StatusOk) } + +pub async fn delete_user_role( + state: AppState, + user_from_token: auth::UserFromToken, + request: user_role_api::DeleteUserRoleRequest, +) -> UserResponse<()> { + let user_from_db: domain::UserFromStorage = state + .store + .find_user_by_email( + domain::UserEmail::from_pii_email(request.email)? + .get_secret() + .expose() + .as_str(), + ) + .await + .map_err(|e| { + if e.current_context().is_db_not_found() { + e.change_context(UserErrors::InvalidRoleOperation) + .attach_printable("User not found in records") + } else { + e.change_context(UserErrors::InternalServerError) + } + })? + .into(); + + if user_from_db.get_user_id() == user_from_token.user_id { + return Err(UserErrors::InvalidDeleteOperation.into()) + .attach_printable("User deleting himself"); + } + + let user_roles = state + .store + .list_user_roles_by_user_id(user_from_db.get_user_id()) + .await + .change_context(UserErrors::InternalServerError)?; + + match user_roles + .iter() + .find(|&role| role.merchant_id == user_from_token.merchant_id.as_str()) + { + Some(user_role) => { + if !predefined_permissions::is_role_deletable(&user_role.role_id) { + return Err(UserErrors::InvalidRoleId.into()) + .attach_printable("Deletion not allowed for users with specific role id"); + } + } + None => { + return Err(UserErrors::InvalidDeleteOperation.into()) + .attach_printable("User is not associated with the merchant"); + } + }; + + if user_roles.len() > 1 { + state + .store + .delete_user_role_by_user_id_merchant_id( + user_from_db.get_user_id(), + user_from_token.merchant_id.as_str(), + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Error while deleting user role")?; + + Ok(ApplicationResponse::StatusOk) + } else { + state + .store + .delete_user_by_user_id(user_from_db.get_user_id()) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Error while deleting user entry")?; + + state + .store + .delete_user_role_by_user_id_merchant_id( + user_from_db.get_user_id(), + user_from_token.merchant_id.as_str(), + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Error while deleting user role")?; + + Ok(ApplicationResponse::StatusOk) + } +} diff --git a/crates/router/src/db/dashboard_metadata.rs b/crates/router/src/db/dashboard_metadata.rs index ec24b4ed07da..8e2ac0b6ad3f 100644 --- a/crates/router/src/db/dashboard_metadata.rs +++ b/crates/router/src/db/dashboard_metadata.rs @@ -36,6 +36,12 @@ pub trait DashboardMetadataInterface { org_id: &str, data_keys: Vec, ) -> CustomResult, errors::StorageError>; + + async fn delete_user_scoped_dashboard_metadata_by_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult; } #[async_trait::async_trait] @@ -111,6 +117,21 @@ impl DashboardMetadataInterface for Store { .map_err(Into::into) .into_report() } + async fn delete_user_scoped_dashboard_metadata_by_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + storage::DashboardMetadata::delete_user_scoped_dashboard_metadata_by_merchant_id( + &conn, + user_id.to_owned(), + merchant_id.to_owned(), + ) + .await + .map_err(Into::into) + .into_report() + } } #[async_trait::async_trait] @@ -246,4 +267,31 @@ impl DashboardMetadataInterface for MockDb { } Ok(query_result) } + async fn delete_user_scoped_dashboard_metadata_by_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { + let mut dashboard_metadata = self.dashboard_metadata.lock().await; + + let initial_len = dashboard_metadata.len(); + + dashboard_metadata.retain(|metadata_inner| { + !(metadata_inner + .user_id + .clone() + .map(|user_id_inner| user_id_inner == user_id) + .unwrap_or(false) + && metadata_inner.merchant_id == merchant_id) + }); + + if dashboard_metadata.len() == initial_len { + return Err(errors::StorageError::ValueNotFound(format!( + "No user available for user_id = {user_id} and merchant id = {merchant_id}" + )) + .into()); + } + + Ok(true) + } } diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index 8398c153156d..e88d59ea9f39 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -1955,9 +1955,14 @@ impl UserRoleInterface for KafkaStore { .update_user_role_by_user_id_merchant_id(user_id, merchant_id, update) .await } - - async fn delete_user_role(&self, user_id: &str) -> CustomResult { - self.diesel_store.delete_user_role(user_id).await + async fn delete_user_role_by_user_id_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { + self.diesel_store + .delete_user_role_by_user_id_merchant_id(user_id, merchant_id) + .await } async fn list_user_roles_by_user_id( @@ -2017,6 +2022,16 @@ impl DashboardMetadataInterface for KafkaStore { .find_merchant_scoped_dashboard_metadata(merchant_id, org_id, data_keys) .await } + + async fn delete_user_scoped_dashboard_metadata_by_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { + self.diesel_store + .delete_user_scoped_dashboard_metadata_by_merchant_id(user_id, merchant_id) + .await + } } #[async_trait::async_trait] diff --git a/crates/router/src/db/user_role.rs b/crates/router/src/db/user_role.rs index d8938f9683da..f02e6d60b3bc 100644 --- a/crates/router/src/db/user_role.rs +++ b/crates/router/src/db/user_role.rs @@ -32,8 +32,11 @@ pub trait UserRoleInterface { merchant_id: &str, update: storage::UserRoleUpdate, ) -> CustomResult; - - async fn delete_user_role(&self, user_id: &str) -> CustomResult; + async fn delete_user_role_by_user_id_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult; async fn list_user_roles_by_user_id( &self, @@ -100,12 +103,20 @@ impl UserRoleInterface for Store { .into_report() } - async fn delete_user_role(&self, user_id: &str) -> CustomResult { + async fn delete_user_role_by_user_id_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { let conn = connection::pg_connection_write(self).await?; - storage::UserRole::delete_by_user_id(&conn, user_id.to_owned()) - .await - .map_err(Into::into) - .into_report() + storage::UserRole::delete_by_user_id_merchant_id( + &conn, + user_id.to_owned(), + merchant_id.to_owned(), + ) + .await + .map_err(Into::into) + .into_report() } async fn list_user_roles_by_user_id( @@ -230,11 +241,17 @@ impl UserRoleInterface for MockDb { ) } - async fn delete_user_role(&self, user_id: &str) -> CustomResult { + async fn delete_user_role_by_user_id_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { let mut user_roles = self.user_roles.lock().await; let user_role_index = user_roles .iter() - .position(|user_role| user_role.user_id == user_id) + .position(|user_role| { + user_role.user_id == user_id && user_role.merchant_id == merchant_id + }) .ok_or(errors::StorageError::ValueNotFound(format!( "No user available for user_id = {user_id}" )))?; @@ -286,8 +303,14 @@ impl UserRoleInterface for super::KafkaStore { ) -> CustomResult { self.diesel_store.find_user_role_by_user_id(user_id).await } - async fn delete_user_role(&self, user_id: &str) -> CustomResult { - self.diesel_store.delete_user_role(user_id).await + async fn delete_user_role_by_user_id_merchant_id( + &self, + user_id: &str, + merchant_id: &str, + ) -> CustomResult { + self.diesel_store + .delete_user_role_by_user_id_merchant_id(user_id, merchant_id) + .await } async fn list_user_roles_by_user_id( &self, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 4345109a6724..5922eeb9feeb 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -928,7 +928,8 @@ impl User { web::resource("/data") .route(web::get().to(get_multiple_dashboard_metadata)) .route(web::post().to(set_dashboard_metadata)), - ); + ) + .service(web::resource("/user/delete").route(web::delete().to(delete_user_role))); #[cfg(feature = "dummy_connector")] { diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 1c967222dc7f..30348513c2b7 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -176,6 +176,7 @@ impl From for ApiIdentifier { | Flow::ForgotPassword | Flow::ResetPassword | Flow::InviteUser + | Flow::DeleteUser | Flow::UserSignUpWithMerchantId | Flow::VerifyEmail | Flow::VerifyEmailRequest diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs index 73b1ef1b01da..f83134e58251 100644 --- a/crates/router/src/routes/user_role.rs +++ b/crates/router/src/routes/user_role.rs @@ -115,3 +115,21 @@ pub async fn accept_invitation( )) .await } + +pub async fn delete_user_role( + state: web::Data, + req: HttpRequest, + payload: web::Json, +) -> HttpResponse { + let flow = Flow::DeleteUser; + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + payload.into_inner(), + user_role_core::delete_user_role, + &auth::JWTAuth(Permission::UsersWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/services/authorization/predefined_permissions.rs b/crates/router/src/services/authorization/predefined_permissions.rs index c489f1fc9638..6fe0ddcc3605 100644 --- a/crates/router/src/services/authorization/predefined_permissions.rs +++ b/crates/router/src/services/authorization/predefined_permissions.rs @@ -9,6 +9,7 @@ pub struct RoleInfo { permissions: Vec, name: Option<&'static str>, is_invitable: bool, + is_deletable: bool, } impl RoleInfo { @@ -63,6 +64,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: None, is_invitable: false, + is_deletable: false, }, ); roles.insert( @@ -87,6 +89,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: None, is_invitable: false, + is_deletable: false, }, ); @@ -126,6 +129,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("Organization Admin"), is_invitable: false, + is_deletable: false, }, ); @@ -165,6 +169,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("Admin"), is_invitable: true, + is_deletable: true, }, ); roles.insert( @@ -189,6 +194,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("View Only"), is_invitable: true, + is_deletable: true, }, ); roles.insert( @@ -214,6 +220,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("IAM"), is_invitable: true, + is_deletable: true, }, ); roles.insert( @@ -239,6 +246,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("Developer"), is_invitable: true, + is_deletable: true, }, ); roles.insert( @@ -269,6 +277,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("Operator"), is_invitable: true, + is_deletable: true, }, ); roles.insert( @@ -291,6 +300,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy> = Lazy: ], name: Some("Customer Support"), is_invitable: true, + is_deletable: true, }, ); roles @@ -307,3 +317,9 @@ pub fn is_role_invitable(role_id: &str) -> bool { .get(role_id) .map_or(false, |role_info| role_info.is_invitable) } + +pub fn is_role_deletable(role_id: &str) -> bool { + PREDEFINED_PERMISSIONS + .get(role_id) + .map_or(false, |role_info| role_info.is_deletable) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index ba323ebc5e3f..84f2e3e12674 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -321,6 +321,8 @@ pub enum Flow { ResetPassword, /// Invite users InviteUser, + /// Delete user + DeleteUser, /// Incremental Authorization flow PaymentsIncrementalAuthorization, /// Get action URL for connector onboarding