mirror of
				https://github.com/juspay/hyperswitch.git
				synced 2025-11-01 02:57:02 +08:00 
			
		
		
		
	feat(user_role): Add APIs for user roles (#3013)
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
		| @ -7,6 +7,7 @@ pub mod payouts; | ||||
| pub mod refund; | ||||
| pub mod routing; | ||||
| pub mod user; | ||||
| pub mod user_role; | ||||
|  | ||||
| use common_utils::{ | ||||
|     events::{ApiEventMetric, ApiEventsType}, | ||||
|  | ||||
| @ -5,6 +5,7 @@ use crate::user::{ | ||||
|         GetMetaDataRequest, GetMetaDataResponse, GetMultipleMetaDataPayload, SetMetaDataRequest, | ||||
|     }, | ||||
|     ChangePasswordRequest, ConnectAccountRequest, ConnectAccountResponse, | ||||
|     CreateInternalUserRequest, SwitchMerchantIdRequest, UserMerchantCreate, | ||||
| }; | ||||
|  | ||||
| impl ApiEventMetric for ConnectAccountResponse { | ||||
| @ -23,5 +24,8 @@ common_utils::impl_misc_api_event_type!( | ||||
|     GetMultipleMetaDataPayload, | ||||
|     GetMetaDataResponse, | ||||
|     GetMetaDataRequest, | ||||
|     SetMetaDataRequest | ||||
|     SetMetaDataRequest, | ||||
|     SwitchMerchantIdRequest, | ||||
|     CreateInternalUserRequest, | ||||
|     UserMerchantCreate | ||||
| ); | ||||
|  | ||||
							
								
								
									
										14
									
								
								crates/api_models/src/events/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								crates/api_models/src/events/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| use common_utils::events::{ApiEventMetric, ApiEventsType}; | ||||
|  | ||||
| use crate::user_role::{ | ||||
|     AuthorizationInfoResponse, GetRoleRequest, ListRolesResponse, RoleInfoResponse, | ||||
|     UpdateUserRoleRequest, | ||||
| }; | ||||
|  | ||||
| common_utils::impl_misc_api_event_type!( | ||||
|     ListRolesResponse, | ||||
|     RoleInfoResponse, | ||||
|     GetRoleRequest, | ||||
|     AuthorizationInfoResponse, | ||||
|     UpdateUserRoleRequest | ||||
| ); | ||||
| @ -26,6 +26,7 @@ pub mod refunds; | ||||
| pub mod routing; | ||||
| pub mod surcharge_decision_configs; | ||||
| pub mod user; | ||||
| pub mod user_role; | ||||
| pub mod verifications; | ||||
| pub mod verify_connector; | ||||
| pub mod webhooks; | ||||
|  | ||||
| @ -26,3 +26,20 @@ pub struct ChangePasswordRequest { | ||||
|     pub new_password: Secret<String>, | ||||
|     pub old_password: Secret<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct SwitchMerchantIdRequest { | ||||
|     pub merchant_id: String, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, Debug, serde::Serialize)] | ||||
| pub struct CreateInternalUserRequest { | ||||
|     pub name: Secret<String>, | ||||
|     pub email: pii::Email, | ||||
|     pub password: Secret<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct UserMerchantCreate { | ||||
|     pub company_name: String, | ||||
| } | ||||
|  | ||||
							
								
								
									
										82
									
								
								crates/api_models/src/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								crates/api_models/src/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct ListRolesResponse(pub Vec<RoleInfoResponse>); | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct RoleInfoResponse { | ||||
|     pub role_id: &'static str, | ||||
|     pub permissions: Vec<Permission>, | ||||
|     pub role_name: &'static str, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct GetRoleRequest { | ||||
|     pub role_id: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub enum Permission { | ||||
|     PaymentRead, | ||||
|     PaymentWrite, | ||||
|     RefundRead, | ||||
|     RefundWrite, | ||||
|     ApiKeyRead, | ||||
|     ApiKeyWrite, | ||||
|     MerchantAccountRead, | ||||
|     MerchantAccountWrite, | ||||
|     MerchantConnectorAccountRead, | ||||
|     MerchantConnectorAccountWrite, | ||||
|     ForexRead, | ||||
|     RoutingRead, | ||||
|     RoutingWrite, | ||||
|     DisputeRead, | ||||
|     DisputeWrite, | ||||
|     MandateRead, | ||||
|     MandateWrite, | ||||
|     FileRead, | ||||
|     FileWrite, | ||||
|     Analytics, | ||||
|     ThreeDsDecisionManagerWrite, | ||||
|     ThreeDsDecisionManagerRead, | ||||
|     SurchargeDecisionManagerWrite, | ||||
|     SurchargeDecisionManagerRead, | ||||
|     UsersRead, | ||||
|     UsersWrite, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub enum PermissionModule { | ||||
|     Payments, | ||||
|     Refunds, | ||||
|     MerchantAccount, | ||||
|     Forex, | ||||
|     Connectors, | ||||
|     Routing, | ||||
|     Analytics, | ||||
|     Mandates, | ||||
|     Disputes, | ||||
|     Files, | ||||
|     ThreeDsDecisionManager, | ||||
|     SurchargeDecisionManager, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct AuthorizationInfoResponse(pub Vec<ModuleInfo>); | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct ModuleInfo { | ||||
|     pub module: PermissionModule, | ||||
|     pub description: &'static str, | ||||
|     pub permissions: Vec<PermissionInfo>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Serialize)] | ||||
| pub struct PermissionInfo { | ||||
|     pub enum_name: Permission, | ||||
|     pub description: &'static str, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, serde::Deserialize, serde::Serialize)] | ||||
| pub struct UpdateUserRoleRequest { | ||||
|     pub user_id: String, | ||||
|     pub role_id: String, | ||||
| } | ||||
| @ -1,5 +1,6 @@ | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user; | ||||
| pub mod user_role; | ||||
|  | ||||
| // ID generation | ||||
| pub(crate) const ID_LENGTH: usize = 20; | ||||
| @ -64,7 +65,6 @@ pub const JWT_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24 * 2; // 2 days | ||||
|  | ||||
| #[cfg(feature = "email")] | ||||
| pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day | ||||
| pub const ROLE_ID_ORGANIZATION_ADMIN: &str = "org_admin"; | ||||
|  | ||||
| #[cfg(feature = "olap")] | ||||
| pub const VERIFY_CONNECTOR_ID_PREFIX: &str = "conn_verify"; | ||||
|  | ||||
							
								
								
									
										11
									
								
								crates/router/src/consts/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								crates/router/src/consts/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| // User Roles | ||||
| pub const ROLE_ID_INTERNAL_VIEW_ONLY_USER: &str = "internal_view_only"; | ||||
| pub const ROLE_ID_INTERNAL_ADMIN: &str = "internal_admin"; | ||||
| pub const ROLE_ID_MERCHANT_ADMIN: &str = "merchant_admin"; | ||||
| pub const ROLE_ID_ORGANIZATION_ADMIN: &str = "org_admin"; | ||||
| pub const ROLE_ID_MERCHANT_VIEW_ONLY: &str = "merchant_view_only"; | ||||
| pub const ROLE_ID_MERCHANT_IAM_ADMIN: &str = "merchant_iam_admin"; | ||||
| 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"; | ||||
| @ -25,6 +25,8 @@ pub mod routing; | ||||
| pub mod surcharge_decision_config; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user_role; | ||||
| pub mod utils; | ||||
| #[cfg(all(feature = "olap", feature = "kms"))] | ||||
| pub mod verification; | ||||
|  | ||||
| @ -27,16 +27,22 @@ pub enum UserErrors { | ||||
|     MerchantAccountCreationError(String), | ||||
|     #[error("InvalidEmailError")] | ||||
|     InvalidEmailError, | ||||
|     #[error("DuplicateOrganizationId")] | ||||
|     DuplicateOrganizationId, | ||||
|     #[error("MerchantIdNotFound")] | ||||
|     MerchantIdNotFound, | ||||
|     #[error("MetadataAlreadySet")] | ||||
|     MetadataAlreadySet, | ||||
|     #[error("DuplicateOrganizationId")] | ||||
|     DuplicateOrganizationId, | ||||
|     #[error("InvalidRoleId")] | ||||
|     InvalidRoleId, | ||||
|     #[error("InvalidRoleOperation")] | ||||
|     InvalidRoleOperation, | ||||
|     #[error("IpAddressParsingFailed")] | ||||
|     IpAddressParsingFailed, | ||||
|     #[error("InvalidMetadataRequest")] | ||||
|     InvalidMetadataRequest, | ||||
|     #[error("MerchantIdParsingError")] | ||||
|     MerchantIdParsingError, | ||||
| } | ||||
|  | ||||
| impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors { | ||||
| @ -95,6 +101,15 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon | ||||
|                 "An Organization with the id already exists", | ||||
|                 None, | ||||
|             )), | ||||
|             Self::InvalidRoleId => { | ||||
|                 AER::BadRequest(ApiError::new(sub_code, 22, "Invalid Role ID", None)) | ||||
|             } | ||||
|             Self::InvalidRoleOperation => AER::BadRequest(ApiError::new( | ||||
|                 sub_code, | ||||
|                 23, | ||||
|                 "User Role Operation Not Supported", | ||||
|                 None, | ||||
|             )), | ||||
|             Self::IpAddressParsingFailed => { | ||||
|                 AER::InternalServerError(ApiError::new(sub_code, 24, "Something Went Wrong", None)) | ||||
|             } | ||||
| @ -104,6 +119,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon | ||||
|                 "Invalid Metadata Request", | ||||
|                 None, | ||||
|             )), | ||||
|             Self::MerchantIdParsingError => { | ||||
|                 AER::BadRequest(ApiError::new(sub_code, 28, "Invalid Merchant Id", None)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| use api_models::user as api; | ||||
| use diesel_models::enums::UserStatus; | ||||
| use api_models::user as user_api; | ||||
| use diesel_models::{enums::UserStatus, user as storage_user}; | ||||
| use error_stack::{IntoReport, ResultExt}; | ||||
| use masking::{ExposeInterface, Secret}; | ||||
| use router_env::env; | ||||
| @ -9,16 +9,17 @@ use crate::{ | ||||
|     consts, | ||||
|     db::user::UserInterface, | ||||
|     routes::AppState, | ||||
|     services::{authentication::UserFromToken, ApplicationResponse}, | ||||
|     services::{authentication as auth, ApplicationResponse}, | ||||
|     types::domain, | ||||
|     utils, | ||||
| }; | ||||
|  | ||||
| pub mod dashboard_metadata; | ||||
|  | ||||
| pub async fn connect_account( | ||||
|     state: AppState, | ||||
|     request: api::ConnectAccountRequest, | ||||
| ) -> UserResponse<api::ConnectAccountResponse> { | ||||
|     request: user_api::ConnectAccountRequest, | ||||
| ) -> UserResponse<user_api::ConnectAccountResponse> { | ||||
|     let find_user = state | ||||
|         .store | ||||
|         .find_user_by_email(request.email.clone().expose().expose().as_str()) | ||||
| @ -34,7 +35,8 @@ pub async fn connect_account( | ||||
|             .get_jwt_auth_token(state.clone(), user_role.org_id) | ||||
|             .await?; | ||||
|  | ||||
|         return Ok(ApplicationResponse::Json(api::ConnectAccountResponse { | ||||
|         return Ok(ApplicationResponse::Json( | ||||
|             user_api::ConnectAccountResponse { | ||||
|                 token: Secret::new(jwt_token), | ||||
|                 merchant_id: user_role.merchant_id, | ||||
|                 name: user_from_db.get_name(), | ||||
| @ -42,7 +44,8 @@ pub async fn connect_account( | ||||
|                 verification_days_left: None, | ||||
|                 user_role: user_role.role_id, | ||||
|                 user_id: user_from_db.get_user_id().to_string(), | ||||
|         })); | ||||
|             }, | ||||
|         )); | ||||
|     } else if find_user | ||||
|         .map_err(|e| e.current_context().is_db_not_found()) | ||||
|         .err() | ||||
| @ -64,7 +67,7 @@ pub async fn connect_account( | ||||
|         let user_role = new_user | ||||
|             .insert_user_role_in_db( | ||||
|                 state.clone(), | ||||
|                 consts::ROLE_ID_ORGANIZATION_ADMIN.to_string(), | ||||
|                 consts::user_role::ROLE_ID_ORGANIZATION_ADMIN.to_string(), | ||||
|                 UserStatus::Active, | ||||
|             ) | ||||
|             .await?; | ||||
| @ -94,7 +97,8 @@ pub async fn connect_account( | ||||
|             logger::info!(?send_email_result); | ||||
|         } | ||||
|  | ||||
|         return Ok(ApplicationResponse::Json(api::ConnectAccountResponse { | ||||
|         return Ok(ApplicationResponse::Json( | ||||
|             user_api::ConnectAccountResponse { | ||||
|                 token: Secret::new(jwt_token), | ||||
|                 merchant_id: user_role.merchant_id, | ||||
|                 name: user_from_db.get_name(), | ||||
| @ -102,7 +106,8 @@ pub async fn connect_account( | ||||
|                 verification_days_left: None, | ||||
|                 user_role: user_role.role_id, | ||||
|                 user_id: user_from_db.get_user_id().to_string(), | ||||
|         })); | ||||
|             }, | ||||
|         )); | ||||
|     } else { | ||||
|         Err(UserErrors::InternalServerError.into()) | ||||
|     } | ||||
| @ -110,8 +115,8 @@ pub async fn connect_account( | ||||
|  | ||||
| pub async fn change_password( | ||||
|     state: AppState, | ||||
|     request: api::ChangePasswordRequest, | ||||
|     user_from_token: UserFromToken, | ||||
|     request: user_api::ChangePasswordRequest, | ||||
|     user_from_token: auth::UserFromToken, | ||||
| ) -> UserResponse<()> { | ||||
|     let user: domain::UserFromStorage = | ||||
|         UserInterface::find_user_by_id(&*state.store, &user_from_token.user_id) | ||||
| @ -139,3 +144,180 @@ pub async fn change_password( | ||||
|  | ||||
|     Ok(ApplicationResponse::StatusOk) | ||||
| } | ||||
|  | ||||
| pub async fn create_internal_user( | ||||
|     state: AppState, | ||||
|     request: user_api::CreateInternalUserRequest, | ||||
| ) -> UserResponse<()> { | ||||
|     let new_user = domain::NewUser::try_from(request)?; | ||||
|  | ||||
|     let mut store_user: storage_user::UserNew = new_user.clone().try_into()?; | ||||
|     store_user.set_is_verified(true); | ||||
|  | ||||
|     let key_store = state | ||||
|         .store | ||||
|         .get_merchant_key_store_by_merchant_id( | ||||
|             consts::user_role::INTERNAL_USER_MERCHANT_ID, | ||||
|             &state.store.get_master_key().to_vec().into(), | ||||
|         ) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_not_found() { | ||||
|                 e.change_context(UserErrors::MerchantIdNotFound) | ||||
|             } else { | ||||
|                 e.change_context(UserErrors::InternalServerError) | ||||
|             } | ||||
|         })?; | ||||
|  | ||||
|     state | ||||
|         .store | ||||
|         .find_merchant_account_by_merchant_id( | ||||
|             consts::user_role::INTERNAL_USER_MERCHANT_ID, | ||||
|             &key_store, | ||||
|         ) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_not_found() { | ||||
|                 e.change_context(UserErrors::MerchantIdNotFound) | ||||
|             } else { | ||||
|                 e.change_context(UserErrors::InternalServerError) | ||||
|             } | ||||
|         })?; | ||||
|  | ||||
|     state | ||||
|         .store | ||||
|         .insert_user(store_user) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_unique_violation() { | ||||
|                 e.change_context(UserErrors::UserExists) | ||||
|             } else { | ||||
|                 e.change_context(UserErrors::InternalServerError) | ||||
|             } | ||||
|         }) | ||||
|         .map(domain::user::UserFromStorage::from)?; | ||||
|  | ||||
|     new_user | ||||
|         .insert_user_role_in_db( | ||||
|             state, | ||||
|             consts::user_role::ROLE_ID_INTERNAL_VIEW_ONLY_USER.to_string(), | ||||
|             UserStatus::Active, | ||||
|         ) | ||||
|         .await?; | ||||
|  | ||||
|     Ok(ApplicationResponse::StatusOk) | ||||
| } | ||||
|  | ||||
| pub async fn switch_merchant_id( | ||||
|     state: AppState, | ||||
|     request: user_api::SwitchMerchantIdRequest, | ||||
|     user_from_token: auth::UserFromToken, | ||||
| ) -> UserResponse<user_api::ConnectAccountResponse> { | ||||
|     if !utils::user_role::is_internal_role(&user_from_token.role_id) { | ||||
|         let merchant_list = | ||||
|             utils::user_role::get_merchant_ids_for_user(state.clone(), &user_from_token.user_id) | ||||
|                 .await?; | ||||
|         if !merchant_list.contains(&request.merchant_id) { | ||||
|             return Err(UserErrors::InvalidRoleOperation.into()) | ||||
|                 .attach_printable("User doesn't have access to switch"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if user_from_token.merchant_id == request.merchant_id { | ||||
|         return Err(UserErrors::InvalidRoleOperation.into()) | ||||
|             .attach_printable("User switch to same merchant id."); | ||||
|     } | ||||
|  | ||||
|     let user = state | ||||
|         .store | ||||
|         .find_user_by_id(&user_from_token.user_id) | ||||
|         .await | ||||
|         .change_context(UserErrors::InternalServerError)?; | ||||
|  | ||||
|     let key_store = state | ||||
|         .store | ||||
|         .get_merchant_key_store_by_merchant_id( | ||||
|             request.merchant_id.as_str(), | ||||
|             &state.store.get_master_key().to_vec().into(), | ||||
|         ) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_not_found() { | ||||
|                 e.change_context(UserErrors::MerchantIdNotFound) | ||||
|             } else { | ||||
|                 e.change_context(UserErrors::InternalServerError) | ||||
|             } | ||||
|         })?; | ||||
|  | ||||
|     let org_id = state | ||||
|         .store | ||||
|         .find_merchant_account_by_merchant_id(request.merchant_id.as_str(), &key_store) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_not_found() { | ||||
|                 e.change_context(UserErrors::MerchantIdNotFound) | ||||
|             } else { | ||||
|                 e.change_context(UserErrors::InternalServerError) | ||||
|             } | ||||
|         })? | ||||
|         .organization_id; | ||||
|  | ||||
|     let user = domain::UserFromStorage::from(user); | ||||
|     let user_role = state | ||||
|         .store | ||||
|         .find_user_role_by_user_id(user.get_user_id()) | ||||
|         .await | ||||
|         .change_context(UserErrors::InternalServerError)?; | ||||
|  | ||||
|     let token = Box::pin(user.get_jwt_auth_token_with_custom_merchant_id( | ||||
|         state.clone(), | ||||
|         request.merchant_id.clone(), | ||||
|         org_id, | ||||
|     )) | ||||
|     .await? | ||||
|     .into(); | ||||
|  | ||||
|     Ok(ApplicationResponse::Json( | ||||
|         user_api::ConnectAccountResponse { | ||||
|             merchant_id: request.merchant_id, | ||||
|             token, | ||||
|             name: user.get_name(), | ||||
|             email: user.get_email(), | ||||
|             user_id: user.get_user_id().to_string(), | ||||
|             verification_days_left: None, | ||||
|             user_role: user_role.role_id, | ||||
|         }, | ||||
|     )) | ||||
| } | ||||
|  | ||||
| pub async fn create_merchant_account( | ||||
|     state: AppState, | ||||
|     user_from_token: auth::UserFromToken, | ||||
|     req: user_api::UserMerchantCreate, | ||||
| ) -> UserResponse<()> { | ||||
|     let user_from_db: domain::UserFromStorage = | ||||
|         user_from_token.get_user(state.clone()).await?.into(); | ||||
|  | ||||
|     let new_user = domain::NewUser::try_from((user_from_db, req, user_from_token))?; | ||||
|     let new_merchant = new_user.get_new_merchant(); | ||||
|     new_merchant | ||||
|         .create_new_merchant_and_insert_in_db(state.to_owned()) | ||||
|         .await?; | ||||
|  | ||||
|     let role_insertion_res = new_user | ||||
|         .insert_user_role_in_db( | ||||
|             state.clone(), | ||||
|             consts::user_role::ROLE_ID_ORGANIZATION_ADMIN.to_string(), | ||||
|             UserStatus::Active, | ||||
|         ) | ||||
|         .await; | ||||
|     if let Err(e) = role_insertion_res { | ||||
|         let _ = state | ||||
|             .store | ||||
|             .delete_merchant_account_by_merchant_id(new_merchant.get_merchant_id().as_str()) | ||||
|             .await; | ||||
|         return Err(e); | ||||
|     } | ||||
|  | ||||
|     Ok(ApplicationResponse::StatusOk) | ||||
| } | ||||
|  | ||||
							
								
								
									
										101
									
								
								crates/router/src/core/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								crates/router/src/core/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | ||||
| use api_models::user_role as user_role_api; | ||||
| use diesel_models::user_role::UserRoleUpdate; | ||||
| use error_stack::ResultExt; | ||||
|  | ||||
| use crate::{ | ||||
|     core::errors::{UserErrors, UserResponse}, | ||||
|     routes::AppState, | ||||
|     services::{ | ||||
|         authentication::{self as auth}, | ||||
|         authorization::{info, predefined_permissions}, | ||||
|         ApplicationResponse, | ||||
|     }, | ||||
|     utils, | ||||
| }; | ||||
|  | ||||
| pub async fn get_authorization_info( | ||||
|     _state: AppState, | ||||
| ) -> UserResponse<user_role_api::AuthorizationInfoResponse> { | ||||
|     Ok(ApplicationResponse::Json( | ||||
|         user_role_api::AuthorizationInfoResponse( | ||||
|             info::get_authorization_info() | ||||
|                 .into_iter() | ||||
|                 .filter_map(|module| module.try_into().ok()) | ||||
|                 .collect(), | ||||
|         ), | ||||
|     )) | ||||
| } | ||||
|  | ||||
| pub async fn list_roles(_state: AppState) -> UserResponse<user_role_api::ListRolesResponse> { | ||||
|     Ok(ApplicationResponse::Json(user_role_api::ListRolesResponse( | ||||
|         predefined_permissions::PREDEFINED_PERMISSIONS | ||||
|             .iter() | ||||
|             .filter_map(|(role_id, role_info)| { | ||||
|                 utils::user_role::get_role_name_and_permission_response(role_info).map( | ||||
|                     |(permissions, role_name)| user_role_api::RoleInfoResponse { | ||||
|                         permissions, | ||||
|                         role_id, | ||||
|                         role_name, | ||||
|                     }, | ||||
|                 ) | ||||
|             }) | ||||
|             .collect(), | ||||
|     ))) | ||||
| } | ||||
|  | ||||
| pub async fn get_role( | ||||
|     _state: AppState, | ||||
|     role: user_role_api::GetRoleRequest, | ||||
| ) -> UserResponse<user_role_api::RoleInfoResponse> { | ||||
|     let info = predefined_permissions::PREDEFINED_PERMISSIONS | ||||
|         .get_key_value(role.role_id.as_str()) | ||||
|         .and_then(|(role_id, role_info)| { | ||||
|             utils::user_role::get_role_name_and_permission_response(role_info).map( | ||||
|                 |(permissions, role_name)| user_role_api::RoleInfoResponse { | ||||
|                     permissions, | ||||
|                     role_id, | ||||
|                     role_name, | ||||
|                 }, | ||||
|             ) | ||||
|         }) | ||||
|         .ok_or(UserErrors::InvalidRoleId)?; | ||||
|  | ||||
|     Ok(ApplicationResponse::Json(info)) | ||||
| } | ||||
|  | ||||
| pub async fn update_user_role( | ||||
|     state: AppState, | ||||
|     user_from_token: auth::UserFromToken, | ||||
|     req: user_role_api::UpdateUserRoleRequest, | ||||
| ) -> UserResponse<()> { | ||||
|     let merchant_id = user_from_token.merchant_id; | ||||
|     let role_id = req.role_id.clone(); | ||||
|     utils::user_role::validate_role_id(role_id.as_str())?; | ||||
|  | ||||
|     if user_from_token.user_id == req.user_id { | ||||
|         return Err(UserErrors::InvalidRoleOperation.into()) | ||||
|             .attach_printable("Admin User Changing their role"); | ||||
|     } | ||||
|  | ||||
|     state | ||||
|         .store | ||||
|         .update_user_role_by_user_id_merchant_id( | ||||
|             req.user_id.as_str(), | ||||
|             merchant_id.as_str(), | ||||
|             UserRoleUpdate::UpdateRole { | ||||
|                 role_id, | ||||
|                 modified_by: user_from_token.user_id, | ||||
|             }, | ||||
|         ) | ||||
|         .await | ||||
|         .map_err(|e| { | ||||
|             if e.current_context().is_db_not_found() { | ||||
|                 return e | ||||
|                     .change_context(UserErrors::InvalidRoleOperation) | ||||
|                     .attach_printable("UserId MerchantId not found"); | ||||
|             } | ||||
|             e.change_context(UserErrors::InternalServerError) | ||||
|         })?; | ||||
|  | ||||
|     Ok(ApplicationResponse::StatusOk) | ||||
| } | ||||
| @ -27,6 +27,8 @@ pub mod refunds; | ||||
| pub mod routing; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user_role; | ||||
| #[cfg(all(feature = "olap", feature = "kms"))] | ||||
| pub mod verification; | ||||
| #[cfg(feature = "olap")] | ||||
|  | ||||
| @ -23,7 +23,7 @@ use super::verification::{apple_pay_merchant_registration, retrieve_apple_pay_ve | ||||
| #[cfg(feature = "olap")] | ||||
| use super::{ | ||||
|     admin::*, api_keys::*, disputes::*, files::*, gsm::*, locker_migration, payment_link::*, | ||||
|     user::*, | ||||
|     user::*, user_role::*, | ||||
| }; | ||||
| use super::{cache::*, health::*}; | ||||
| #[cfg(any(feature = "olap", feature = "oltp"))] | ||||
| @ -812,6 +812,17 @@ impl User { | ||||
|                     .route(web::post().to(set_merchant_scoped_dashboard_metadata)), | ||||
|             ) | ||||
|             .service(web::resource("/data").route(web::get().to(get_multiple_dashboard_metadata))) | ||||
|             .service(web::resource("/internal_signup").route(web::post().to(internal_user_signup))) | ||||
|             .service(web::resource("/switch_merchant").route(web::post().to(switch_merchant_id))) | ||||
|             .service( | ||||
|                 web::resource("/create_merchant") | ||||
|                     .route(web::post().to(user_merchant_account_create)), | ||||
|             ) | ||||
|             // User Role APIs | ||||
|             .service(web::resource("/permission_info").route(web::get().to(get_authorization_info))) | ||||
|             .service(web::resource("/user/update_role").route(web::post().to(update_user_role))) | ||||
|             .service(web::resource("/role/list").route(web::get().to(list_roles))) | ||||
|             .service(web::resource("/role/{role_id}").route(web::get().to(get_role))) | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -27,6 +27,7 @@ pub enum ApiIdentifier { | ||||
|     RustLockerMigration, | ||||
|     Gsm, | ||||
|     User, | ||||
|     UserRole, | ||||
| } | ||||
|  | ||||
| impl From<Flow> for ApiIdentifier { | ||||
| @ -151,7 +152,14 @@ impl From<Flow> for ApiIdentifier { | ||||
|             | Flow::ChangePassword | ||||
|             | Flow::SetDashboardMetadata | ||||
|             | Flow::GetMutltipleDashboardMetadata | ||||
|             | Flow::VerifyPaymentConnector => Self::User, | ||||
|             | Flow::VerifyPaymentConnector | ||||
|             | Flow::InternalUserSignup | ||||
|             | Flow::SwitchMerchant | ||||
|             | Flow::UserMerchantAccountCreate => Self::User, | ||||
|  | ||||
|             Flow::ListRoles | Flow::GetRole | Flow::UpdateUserRole | Flow::GetAuthorizationInfo => { | ||||
|                 Self::UserRole | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -5,7 +5,7 @@ use router_env::Flow; | ||||
|  | ||||
| use super::AppState; | ||||
| use crate::{ | ||||
|     core::{api_locking, user}, | ||||
|     core::{api_locking, user as user_core}, | ||||
|     services::{ | ||||
|         api, | ||||
|         authentication::{self as auth}, | ||||
| @ -26,7 +26,7 @@ pub async fn user_connect_account( | ||||
|         state, | ||||
|         &http_req, | ||||
|         req_payload.clone(), | ||||
|         |state, _, req_body| user::connect_account(state, req_body), | ||||
|         |state, _, req_body| user_core::connect_account(state, req_body), | ||||
|         &auth::NoAuth, | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
| @ -44,7 +44,7 @@ pub async fn change_password( | ||||
|         state.clone(), | ||||
|         &http_req, | ||||
|         json_payload.into_inner(), | ||||
|         |state, user, req| user::change_password(state, req, user), | ||||
|         |state, user, req| user_core::change_password(state, req, user), | ||||
|         &auth::DashboardNoPermissionAuth, | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
| @ -70,7 +70,7 @@ pub async fn set_merchant_scoped_dashboard_metadata( | ||||
|         state, | ||||
|         &req, | ||||
|         payload, | ||||
|         user::dashboard_metadata::set_metadata, | ||||
|         user_core::dashboard_metadata::set_metadata, | ||||
|         &auth::JWTAuth(Permission::MerchantAccountWrite), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
| @ -96,9 +96,65 @@ pub async fn get_multiple_dashboard_metadata( | ||||
|         state, | ||||
|         &req, | ||||
|         payload, | ||||
|         user::dashboard_metadata::get_multiple_metadata, | ||||
|         user_core::dashboard_metadata::get_multiple_metadata, | ||||
|         &auth::DashboardNoPermissionAuth, | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn internal_user_signup( | ||||
|     state: web::Data<AppState>, | ||||
|     http_req: HttpRequest, | ||||
|     json_payload: web::Json<user_api::CreateInternalUserRequest>, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::InternalUserSignup; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &http_req, | ||||
|         json_payload.into_inner(), | ||||
|         |state, _, req| user_core::create_internal_user(state, req), | ||||
|         &auth::AdminApiAuth, | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn switch_merchant_id( | ||||
|     state: web::Data<AppState>, | ||||
|     http_req: HttpRequest, | ||||
|     json_payload: web::Json<user_api::SwitchMerchantIdRequest>, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::SwitchMerchant; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &http_req, | ||||
|         json_payload.into_inner(), | ||||
|         |state, user, req| user_core::switch_merchant_id(state, req, user), | ||||
|         &auth::DashboardNoPermissionAuth, | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn user_merchant_account_create( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     json_payload: web::Json<user_api::UserMerchantCreate>, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::UserMerchantAccountCreate; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state, | ||||
|         &req, | ||||
|         json_payload.into_inner(), | ||||
|         |state, auth: auth::UserFromToken, json_payload| { | ||||
|             user_core::create_merchant_account(state, auth, json_payload) | ||||
|         }, | ||||
|         &auth::JWTAuth(Permission::MerchantAccountCreate), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
							
								
								
									
										84
									
								
								crates/router/src/routes/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								crates/router/src/routes/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | ||||
| use actix_web::{web, HttpRequest, HttpResponse}; | ||||
| use api_models::user_role as user_role_api; | ||||
| use router_env::Flow; | ||||
|  | ||||
| use super::AppState; | ||||
| use crate::{ | ||||
|     core::{api_locking, user_role as user_role_core}, | ||||
|     services::{ | ||||
|         api, | ||||
|         authentication::{self as auth}, | ||||
|         authorization::permissions::Permission, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| pub async fn get_authorization_info( | ||||
|     state: web::Data<AppState>, | ||||
|     http_req: HttpRequest, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::GetAuthorizationInfo; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &http_req, | ||||
|         (), | ||||
|         |state, _: (), _| user_role_core::get_authorization_info(state), | ||||
|         &auth::JWTAuth(Permission::UsersRead), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn list_roles(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse { | ||||
|     let flow = Flow::ListRoles; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &req, | ||||
|         (), | ||||
|         |state, _: (), _| user_role_core::list_roles(state), | ||||
|         &auth::JWTAuth(Permission::UsersRead), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn get_role( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     path: web::Path<String>, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::GetRole; | ||||
|     let request_payload = user_role_api::GetRoleRequest { | ||||
|         role_id: path.into_inner(), | ||||
|     }; | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &req, | ||||
|         request_payload, | ||||
|         |state, _: (), req| user_role_core::get_role(state, req), | ||||
|         &auth::JWTAuth(Permission::UsersRead), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
|  | ||||
| pub async fn update_user_role( | ||||
|     state: web::Data<AppState>, | ||||
|     req: HttpRequest, | ||||
|     json_payload: web::Json<user_role_api::UpdateUserRoleRequest>, | ||||
| ) -> HttpResponse { | ||||
|     let flow = Flow::UpdateUserRole; | ||||
|     let payload = json_payload.into_inner(); | ||||
|     Box::pin(api::server_wrap( | ||||
|         flow, | ||||
|         state.clone(), | ||||
|         &req, | ||||
|         payload, | ||||
|         user_role_core::update_user_role, | ||||
|         &auth::JWTAuth(Permission::UsersWrite), | ||||
|         api_locking::LockAction::NotApplicable, | ||||
|     )) | ||||
|     .await | ||||
| } | ||||
| @ -444,6 +444,9 @@ where | ||||
|     ) -> RouterResult<(UserFromToken, AuthenticationType)> { | ||||
|         let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?; | ||||
|  | ||||
|         let permissions = authorization::get_permissions(&payload.role_id)?; | ||||
|         authorization::check_authorization(&self.0, permissions)?; | ||||
|  | ||||
|         Ok(( | ||||
|             UserFromToken { | ||||
|                 user_id: payload.user_id.clone(), | ||||
|  | ||||
| @ -28,7 +28,67 @@ impl RoleInfo { | ||||
| pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy::new(|| { | ||||
|     let mut roles = HashMap::new(); | ||||
|     roles.insert( | ||||
|         consts::ROLE_ID_ORGANIZATION_ADMIN, | ||||
|         consts::user_role::ROLE_ID_INTERNAL_ADMIN, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::PaymentWrite, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::RefundWrite, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::ApiKeyWrite, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::MerchantAccountWrite, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::MerchantConnectorAccountWrite, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::RoutingWrite, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::ThreeDsDecisionManagerWrite, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerWrite, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::DisputeWrite, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::MandateWrite, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::FileWrite, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|                 Permission::UsersWrite, | ||||
|                 Permission::MerchantAccountCreate, | ||||
|             ], | ||||
|             name: None, | ||||
|             is_invitable: false, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_INTERNAL_VIEW_ONLY_USER, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::UsersRead, | ||||
|             ], | ||||
|             name: None, | ||||
|             is_invitable: false, | ||||
|         }, | ||||
|     ); | ||||
|  | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_ORGANIZATION_ADMIN, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
| @ -63,6 +123,164 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy: | ||||
|             is_invitable: false, | ||||
|         }, | ||||
|     ); | ||||
|  | ||||
|     // MERCHANT ROLES | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_ADMIN, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::PaymentWrite, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::RefundWrite, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::ApiKeyWrite, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::MerchantAccountWrite, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::MerchantConnectorAccountWrite, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::RoutingWrite, | ||||
|                 Permission::ThreeDsDecisionManagerWrite, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerWrite, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::DisputeWrite, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::MandateWrite, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::FileWrite, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|                 Permission::UsersWrite, | ||||
|             ], | ||||
|             name: Some("Admin"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_VIEW_ONLY, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|             ], | ||||
|             name: Some("View Only"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_IAM_ADMIN, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|                 Permission::UsersWrite, | ||||
|             ], | ||||
|             name: Some("IAM"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_DEVELOPER, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::ApiKeyWrite, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|             ], | ||||
|             name: Some("Developer"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_OPERATOR, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::PaymentWrite, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::RefundWrite, | ||||
|                 Permission::ApiKeyRead, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::MerchantConnectorAccountWrite, | ||||
|                 Permission::RoutingRead, | ||||
|                 Permission::RoutingWrite, | ||||
|                 Permission::ThreeDsDecisionManagerRead, | ||||
|                 Permission::ThreeDsDecisionManagerWrite, | ||||
|                 Permission::SurchargeDecisionManagerRead, | ||||
|                 Permission::SurchargeDecisionManagerWrite, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::Analytics, | ||||
|                 Permission::UsersRead, | ||||
|             ], | ||||
|             name: Some("Operator"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles.insert( | ||||
|         consts::user_role::ROLE_ID_MERCHANT_CUSTOMER_SUPPORT, | ||||
|         RoleInfo { | ||||
|             permissions: vec![ | ||||
|                 Permission::PaymentRead, | ||||
|                 Permission::RefundRead, | ||||
|                 Permission::RefundWrite, | ||||
|                 Permission::ForexRead, | ||||
|                 Permission::DisputeRead, | ||||
|                 Permission::DisputeWrite, | ||||
|                 Permission::MerchantAccountRead, | ||||
|                 Permission::MerchantConnectorAccountRead, | ||||
|                 Permission::MandateRead, | ||||
|                 Permission::FileRead, | ||||
|                 Permission::FileWrite, | ||||
|                 Permission::Analytics, | ||||
|             ], | ||||
|             name: Some("Customer Support"), | ||||
|             is_invitable: true, | ||||
|         }, | ||||
|     ); | ||||
|     roles | ||||
| }); | ||||
|  | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| use std::{collections::HashSet, ops, str::FromStr}; | ||||
|  | ||||
| use api_models::{admin as admin_api, organization as api_org, user as user_api}; | ||||
| use api_models::{ | ||||
|     admin as admin_api, organization as api_org, user as user_api, user_role as user_role_api, | ||||
| }; | ||||
| use common_utils::pii; | ||||
| use diesel_models::{ | ||||
|     enums::UserStatus, | ||||
| @ -12,17 +14,21 @@ use diesel_models::{ | ||||
| use error_stack::{IntoReport, ResultExt}; | ||||
| use masking::{ExposeInterface, PeekInterface, Secret}; | ||||
| use once_cell::sync::Lazy; | ||||
| use router_env::env; | ||||
| use unicode_segmentation::UnicodeSegmentation; | ||||
|  | ||||
| use crate::{ | ||||
|     consts::user as consts, | ||||
|     consts, | ||||
|     core::{ | ||||
|         admin, | ||||
|         errors::{UserErrors, UserResult}, | ||||
|     }, | ||||
|     db::StorageInterface, | ||||
|     routes::AppState, | ||||
|     services::authentication::AuthToken, | ||||
|     services::{ | ||||
|         authentication::{AuthToken, UserFromToken}, | ||||
|         authorization::info, | ||||
|     }, | ||||
|     types::transformers::ForeignFrom, | ||||
|     utils::user::password, | ||||
| }; | ||||
| @ -36,7 +42,7 @@ impl UserName { | ||||
|     pub fn new(name: Secret<String>) -> UserResult<Self> { | ||||
|         let name = name.expose(); | ||||
|         let is_empty_or_whitespace = name.trim().is_empty(); | ||||
|         let is_too_long = name.graphemes(true).count() > consts::MAX_NAME_LENGTH; | ||||
|         let is_too_long = name.graphemes(true).count() > consts::user::MAX_NAME_LENGTH; | ||||
|  | ||||
|         let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; | ||||
|         let contains_forbidden_characters = name.chars().any(|g| forbidden_characters.contains(&g)); | ||||
| @ -167,7 +173,8 @@ impl UserCompanyName { | ||||
|     pub fn new(company_name: String) -> UserResult<Self> { | ||||
|         let company_name = company_name.trim(); | ||||
|         let is_empty_or_whitespace = company_name.is_empty(); | ||||
|         let is_too_long = company_name.graphemes(true).count() > consts::MAX_COMPANY_NAME_LENGTH; | ||||
|         let is_too_long = | ||||
|             company_name.graphemes(true).count() > consts::user::MAX_COMPANY_NAME_LENGTH; | ||||
|  | ||||
|         let is_all_valid_characters = company_name | ||||
|             .chars() | ||||
| @ -216,9 +223,47 @@ impl From<user_api::ConnectAccountRequest> for NewUserOrganization { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<user_api::CreateInternalUserRequest> for NewUserOrganization { | ||||
|     fn from(_value: user_api::CreateInternalUserRequest) -> Self { | ||||
|         let new_organization = api_org::OrganizationNew::new(None); | ||||
|         let db_organization = ForeignFrom::foreign_from(new_organization); | ||||
|         Self(db_organization) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<UserMerchantCreateRequestWithToken> for NewUserOrganization { | ||||
|     fn from(value: UserMerchantCreateRequestWithToken) -> Self { | ||||
|         Self(diesel_org::OrganizationNew { | ||||
|             org_id: value.2.org_id, | ||||
|             org_name: Some(value.1.company_name), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct MerchantId(String); | ||||
|  | ||||
| impl MerchantId { | ||||
|     pub fn new(merchant_id: String) -> UserResult<Self> { | ||||
|         let merchant_id = merchant_id.trim().to_lowercase().replace(' ', "_"); | ||||
|         let is_empty_or_whitespace = merchant_id.is_empty(); | ||||
|  | ||||
|         let is_all_valid_characters = merchant_id.chars().all(|x| x.is_alphanumeric() || x == '_'); | ||||
|         if is_empty_or_whitespace || !is_all_valid_characters { | ||||
|             Err(UserErrors::MerchantIdParsingError.into()) | ||||
|         } else { | ||||
|             Ok(Self(merchant_id.to_string())) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn get_secret(&self) -> String { | ||||
|         self.0.clone() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct NewUserMerchant { | ||||
|     merchant_id: String, | ||||
|     merchant_id: MerchantId, | ||||
|     company_name: Option<UserCompanyName>, | ||||
|     new_organization: NewUserOrganization, | ||||
| } | ||||
| @ -229,7 +274,7 @@ impl NewUserMerchant { | ||||
|     } | ||||
|  | ||||
|     pub fn get_merchant_id(&self) -> String { | ||||
|         self.merchant_id.clone() | ||||
|         self.merchant_id.get_secret() | ||||
|     } | ||||
|  | ||||
|     pub fn get_new_organization(&self) -> NewUserOrganization { | ||||
| @ -293,7 +338,10 @@ impl TryFrom<user_api::ConnectAccountRequest> for NewUserMerchant { | ||||
|     type Error = error_stack::Report<UserErrors>; | ||||
|  | ||||
|     fn try_from(value: user_api::ConnectAccountRequest) -> UserResult<Self> { | ||||
|         let merchant_id = format!("merchant_{}", common_utils::date_time::now_unix_timestamp()); | ||||
|         let merchant_id = MerchantId::new(format!( | ||||
|             "merchant_{}", | ||||
|             common_utils::date_time::now_unix_timestamp() | ||||
|         ))?; | ||||
|         let new_organization = NewUserOrganization::from(value); | ||||
|  | ||||
|         Ok(Self { | ||||
| @ -304,6 +352,45 @@ impl TryFrom<user_api::ConnectAccountRequest> for NewUserMerchant { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl TryFrom<user_api::CreateInternalUserRequest> for NewUserMerchant { | ||||
|     type Error = error_stack::Report<UserErrors>; | ||||
|  | ||||
|     fn try_from(value: user_api::CreateInternalUserRequest) -> UserResult<Self> { | ||||
|         let merchant_id = | ||||
|             MerchantId::new(consts::user_role::INTERNAL_USER_MERCHANT_ID.to_string())?; | ||||
|         let new_organization = NewUserOrganization::from(value); | ||||
|  | ||||
|         Ok(Self { | ||||
|             company_name: None, | ||||
|             merchant_id, | ||||
|             new_organization, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| type UserMerchantCreateRequestWithToken = | ||||
|     (UserFromStorage, user_api::UserMerchantCreate, UserFromToken); | ||||
|  | ||||
| impl TryFrom<UserMerchantCreateRequestWithToken> for NewUserMerchant { | ||||
|     type Error = error_stack::Report<UserErrors>; | ||||
|  | ||||
|     fn try_from(value: UserMerchantCreateRequestWithToken) -> UserResult<Self> { | ||||
|         let merchant_id = if matches!(env::which(), env::Env::Production) { | ||||
|             MerchantId::new(value.1.company_name.clone())? | ||||
|         } else { | ||||
|             MerchantId::new(format!( | ||||
|                 "merchant_{}", | ||||
|                 common_utils::date_time::now_unix_timestamp() | ||||
|             ))? | ||||
|         }; | ||||
|         Ok(Self { | ||||
|             merchant_id, | ||||
|             company_name: Some(UserCompanyName::new(value.1.company_name.clone())?), | ||||
|             new_organization: NewUserOrganization::from(value), | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct NewUser { | ||||
|     user_id: String, | ||||
| @ -428,6 +515,44 @@ impl TryFrom<user_api::ConnectAccountRequest> for NewUser { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl TryFrom<user_api::CreateInternalUserRequest> for NewUser { | ||||
|     type Error = error_stack::Report<UserErrors>; | ||||
|  | ||||
|     fn try_from(value: user_api::CreateInternalUserRequest) -> UserResult<Self> { | ||||
|         let user_id = uuid::Uuid::new_v4().to_string(); | ||||
|         let email = value.email.clone().try_into()?; | ||||
|         let name = UserName::new(value.name.clone())?; | ||||
|         let password = UserPassword::new(value.password.clone())?; | ||||
|         let new_merchant = NewUserMerchant::try_from(value)?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             user_id, | ||||
|             name, | ||||
|             email, | ||||
|             password, | ||||
|             new_merchant, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl TryFrom<UserMerchantCreateRequestWithToken> for NewUser { | ||||
|     type Error = error_stack::Report<UserErrors>; | ||||
|  | ||||
|     fn try_from(value: UserMerchantCreateRequestWithToken) -> Result<Self, Self::Error> { | ||||
|         let user = value.0.clone(); | ||||
|         let new_merchant = NewUserMerchant::try_from(value)?; | ||||
|  | ||||
|         Ok(Self { | ||||
|             user_id: user.0.user_id, | ||||
|             name: UserName::new(user.0.name)?, | ||||
|             email: user.0.email.clone().try_into()?, | ||||
|             password: UserPassword::new(user.0.password)?, | ||||
|             new_merchant, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone)] | ||||
| pub struct UserFromStorage(pub storage_user::User); | ||||
|  | ||||
| impl From<storage_user::User> for UserFromStorage { | ||||
| @ -475,6 +600,23 @@ impl UserFromStorage { | ||||
|         .await | ||||
|     } | ||||
|  | ||||
|     pub async fn get_jwt_auth_token_with_custom_merchant_id( | ||||
|         &self, | ||||
|         state: AppState, | ||||
|         merchant_id: String, | ||||
|         org_id: String, | ||||
|     ) -> UserResult<String> { | ||||
|         let role_id = self.get_role_from_db(state.clone()).await?.role_id; | ||||
|         AuthToken::new_token( | ||||
|             self.0.user_id.clone(), | ||||
|             merchant_id, | ||||
|             role_id, | ||||
|             &state.conf, | ||||
|             org_id, | ||||
|         ) | ||||
|         .await | ||||
|     } | ||||
|  | ||||
|     pub async fn get_role_from_db(&self, state: AppState) -> UserResult<UserRole> { | ||||
|         state | ||||
|             .store | ||||
| @ -483,3 +625,49 @@ impl UserFromStorage { | ||||
|             .change_context(UserErrors::InternalServerError) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl TryFrom<info::ModuleInfo> for user_role_api::ModuleInfo { | ||||
|     type Error = (); | ||||
|     fn try_from(value: info::ModuleInfo) -> Result<Self, Self::Error> { | ||||
|         let mut permissions = Vec::with_capacity(value.permissions.len()); | ||||
|         for permission in value.permissions { | ||||
|             let permission = permission.try_into()?; | ||||
|             permissions.push(permission); | ||||
|         } | ||||
|         Ok(Self { | ||||
|             module: value.module.into(), | ||||
|             description: value.description, | ||||
|             permissions, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<info::PermissionModule> for user_role_api::PermissionModule { | ||||
|     fn from(value: info::PermissionModule) -> Self { | ||||
|         match value { | ||||
|             info::PermissionModule::Payments => Self::Payments, | ||||
|             info::PermissionModule::Refunds => Self::Refunds, | ||||
|             info::PermissionModule::MerchantAccount => Self::MerchantAccount, | ||||
|             info::PermissionModule::Forex => Self::Forex, | ||||
|             info::PermissionModule::Connectors => Self::Connectors, | ||||
|             info::PermissionModule::Routing => Self::Routing, | ||||
|             info::PermissionModule::Analytics => Self::Analytics, | ||||
|             info::PermissionModule::Mandates => Self::Mandates, | ||||
|             info::PermissionModule::Disputes => Self::Disputes, | ||||
|             info::PermissionModule::Files => Self::Files, | ||||
|             info::PermissionModule::ThreeDsDecisionManager => Self::ThreeDsDecisionManager, | ||||
|             info::PermissionModule::SurchargeDecisionManager => Self::SurchargeDecisionManager, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl TryFrom<info::PermissionInfo> for user_role_api::PermissionInfo { | ||||
|     type Error = (); | ||||
|     fn try_from(value: info::PermissionInfo) -> Result<Self, Self::Error> { | ||||
|         let enum_name = (&value.enum_name).try_into()?; | ||||
|         Ok(Self { | ||||
|             enum_name, | ||||
|             description: value.description, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -7,6 +7,8 @@ pub mod storage_partitioning; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod user_role; | ||||
| #[cfg(feature = "olap")] | ||||
| pub mod verify_connector; | ||||
|  | ||||
| use std::fmt::Debug; | ||||
|  | ||||
| @ -1,2 +1,51 @@ | ||||
| use error_stack::ResultExt; | ||||
|  | ||||
| use crate::{ | ||||
|     core::errors::{UserErrors, UserResult}, | ||||
|     routes::AppState, | ||||
|     services::authentication::UserFromToken, | ||||
|     types::domain::MerchantAccount, | ||||
| }; | ||||
|  | ||||
| pub mod dashboard_metadata; | ||||
| pub mod password; | ||||
|  | ||||
| impl UserFromToken { | ||||
|     pub async fn get_merchant_account(&self, state: AppState) -> UserResult<MerchantAccount> { | ||||
|         let key_store = state | ||||
|             .store | ||||
|             .get_merchant_key_store_by_merchant_id( | ||||
|                 &self.merchant_id, | ||||
|                 &state.store.get_master_key().to_vec().into(), | ||||
|             ) | ||||
|             .await | ||||
|             .map_err(|e| { | ||||
|                 if e.current_context().is_db_not_found() { | ||||
|                     e.change_context(UserErrors::MerchantIdNotFound) | ||||
|                 } else { | ||||
|                     e.change_context(UserErrors::InternalServerError) | ||||
|                 } | ||||
|             })?; | ||||
|         let merchant_account = state | ||||
|             .store | ||||
|             .find_merchant_account_by_merchant_id(&self.merchant_id, &key_store) | ||||
|             .await | ||||
|             .map_err(|e| { | ||||
|                 if e.current_context().is_db_not_found() { | ||||
|                     e.change_context(UserErrors::MerchantIdNotFound) | ||||
|                 } else { | ||||
|                     e.change_context(UserErrors::InternalServerError) | ||||
|                 } | ||||
|             })?; | ||||
|         Ok(merchant_account) | ||||
|     } | ||||
|  | ||||
|     pub async fn get_user(&self, state: AppState) -> UserResult<diesel_models::user::User> { | ||||
|         let user = state | ||||
|             .store | ||||
|             .find_user_by_id(&self.user_id) | ||||
|             .await | ||||
|             .change_context(UserErrors::InternalServerError)?; | ||||
|         Ok(user) | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										93
									
								
								crates/router/src/utils/user_role.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								crates/router/src/utils/user_role.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,93 @@ | ||||
| use api_models::user_role as user_role_api; | ||||
| use diesel_models::enums::UserStatus; | ||||
| use error_stack::ResultExt; | ||||
| use router_env::logger; | ||||
|  | ||||
| use crate::{ | ||||
|     consts, | ||||
|     core::errors::{UserErrors, UserResult}, | ||||
|     routes::AppState, | ||||
|     services::authorization::{ | ||||
|         permissions::Permission, | ||||
|         predefined_permissions::{self, RoleInfo}, | ||||
|     }, | ||||
| }; | ||||
|  | ||||
| pub fn is_internal_role(role_id: &str) -> bool { | ||||
|     role_id == consts::user_role::ROLE_ID_INTERNAL_ADMIN | ||||
|         || role_id == consts::user_role::ROLE_ID_INTERNAL_VIEW_ONLY_USER | ||||
| } | ||||
|  | ||||
| pub async fn get_merchant_ids_for_user(state: AppState, user_id: &str) -> UserResult<Vec<String>> { | ||||
|     Ok(state | ||||
|         .store | ||||
|         .list_user_roles_by_user_id(user_id) | ||||
|         .await | ||||
|         .change_context(UserErrors::InternalServerError)? | ||||
|         .into_iter() | ||||
|         .filter_map(|ele| { | ||||
|             if ele.status == UserStatus::Active { | ||||
|                 return Some(ele.merchant_id); | ||||
|             } | ||||
|             None | ||||
|         }) | ||||
|         .collect()) | ||||
| } | ||||
|  | ||||
| pub fn validate_role_id(role_id: &str) -> UserResult<()> { | ||||
|     if predefined_permissions::is_role_invitable(role_id) { | ||||
|         return Ok(()); | ||||
|     } | ||||
|     Err(UserErrors::InvalidRoleId.into()) | ||||
| } | ||||
|  | ||||
| pub fn get_role_name_and_permission_response( | ||||
|     role_info: &RoleInfo, | ||||
| ) -> Option<(Vec<user_role_api::Permission>, &'static str)> { | ||||
|     role_info | ||||
|         .get_permissions() | ||||
|         .iter() | ||||
|         .map(TryInto::try_into) | ||||
|         .collect::<Result<Vec<user_role_api::Permission>, _>>() | ||||
|         .ok() | ||||
|         .zip(role_info.get_name()) | ||||
| } | ||||
|  | ||||
| impl TryFrom<&Permission> for user_role_api::Permission { | ||||
|     type Error = (); | ||||
|     fn try_from(value: &Permission) -> Result<Self, Self::Error> { | ||||
|         match value { | ||||
|             Permission::PaymentRead => Ok(Self::PaymentRead), | ||||
|             Permission::PaymentWrite => Ok(Self::PaymentWrite), | ||||
|             Permission::RefundRead => Ok(Self::RefundRead), | ||||
|             Permission::RefundWrite => Ok(Self::RefundWrite), | ||||
|             Permission::ApiKeyRead => Ok(Self::ApiKeyRead), | ||||
|             Permission::ApiKeyWrite => Ok(Self::ApiKeyWrite), | ||||
|             Permission::MerchantAccountRead => Ok(Self::MerchantAccountRead), | ||||
|             Permission::MerchantAccountWrite => Ok(Self::MerchantAccountWrite), | ||||
|             Permission::MerchantConnectorAccountRead => Ok(Self::MerchantConnectorAccountRead), | ||||
|             Permission::MerchantConnectorAccountWrite => Ok(Self::MerchantConnectorAccountWrite), | ||||
|             Permission::ForexRead => Ok(Self::ForexRead), | ||||
|             Permission::RoutingRead => Ok(Self::RoutingRead), | ||||
|             Permission::RoutingWrite => Ok(Self::RoutingWrite), | ||||
|             Permission::DisputeRead => Ok(Self::DisputeRead), | ||||
|             Permission::DisputeWrite => Ok(Self::DisputeWrite), | ||||
|             Permission::MandateRead => Ok(Self::MandateRead), | ||||
|             Permission::MandateWrite => Ok(Self::MandateWrite), | ||||
|             Permission::FileRead => Ok(Self::FileRead), | ||||
|             Permission::FileWrite => Ok(Self::FileWrite), | ||||
|             Permission::Analytics => Ok(Self::Analytics), | ||||
|             Permission::ThreeDsDecisionManagerWrite => Ok(Self::ThreeDsDecisionManagerWrite), | ||||
|             Permission::ThreeDsDecisionManagerRead => Ok(Self::ThreeDsDecisionManagerRead), | ||||
|             Permission::SurchargeDecisionManagerWrite => Ok(Self::SurchargeDecisionManagerWrite), | ||||
|             Permission::SurchargeDecisionManagerRead => Ok(Self::SurchargeDecisionManagerRead), | ||||
|             Permission::UsersRead => Ok(Self::UsersRead), | ||||
|             Permission::UsersWrite => Ok(Self::UsersWrite), | ||||
|  | ||||
|             Permission::MerchantAccountCreate => { | ||||
|                 logger::error!("Invalid use of internal permission"); | ||||
|                 Err(()) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -265,6 +265,20 @@ pub enum Flow { | ||||
|     GetMutltipleDashboardMetadata, | ||||
|     /// Payment Connector Verify | ||||
|     VerifyPaymentConnector, | ||||
|     /// Internal user signup | ||||
|     InternalUserSignup, | ||||
|     /// Switch merchant | ||||
|     SwitchMerchant, | ||||
|     /// Get permission info | ||||
|     GetAuthorizationInfo, | ||||
|     /// List roles | ||||
|     ListRoles, | ||||
|     /// Get role | ||||
|     GetRole, | ||||
|     /// Update user role | ||||
|     UpdateUserRole, | ||||
|     /// Create merchant account for user in a org | ||||
|     UserMerchantAccountCreate, | ||||
| } | ||||
|  | ||||
| /// | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Mani Chandra
					Mani Chandra