feat(users): Add email domain based restriction for dashboard entry APIs (#6940)

This commit is contained in:
Mani Chandra
2024-12-30 12:39:16 +05:30
committed by GitHub
parent 3eb2eb1cf5
commit 227c274ece
14 changed files with 322 additions and 52 deletions

View File

@ -305,18 +305,26 @@ pub struct CreateUserAuthenticationMethodRequest {
pub owner_type: common_enums::Owner,
pub auth_method: AuthConfig,
pub allow_signup: bool,
pub email_domain: Option<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct UpdateUserAuthenticationMethodRequest {
pub id: String,
// TODO: When adding more fields make config and new fields option
pub auth_method: AuthConfig,
#[serde(rename_all = "snake_case")]
pub enum UpdateUserAuthenticationMethodRequest {
AuthMethod {
id: String,
auth_config: AuthConfig,
},
EmailDomain {
owner_id: String,
email_domain: String,
},
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct GetUserAuthenticationMethodsRequest {
pub auth_id: String,
pub auth_id: Option<String>,
pub email_domain: Option<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]

View File

@ -64,4 +64,18 @@ impl UserAuthenticationMethod {
)
.await
}
pub async fn list_user_authentication_methods_for_email_domain(
conn: &PgPooledConn,
email_domain: &str,
) -> StorageResult<Vec<Self>> {
generics::generic_filter::<<Self as HasTable>::Table, _, _, _>(
conn,
dsl::email_domain.eq(email_domain.to_owned()),
None,
None,
Some(dsl::last_modified_at.asc()),
)
.await
}
}

View File

@ -1405,6 +1405,8 @@ diesel::table! {
allow_signup -> Bool,
created_at -> Timestamp,
last_modified_at -> Timestamp,
#[max_length = 64]
email_domain -> Varchar,
}
}

View File

@ -1352,6 +1352,8 @@ diesel::table! {
allow_signup -> Bool,
created_at -> Timestamp,
last_modified_at -> Timestamp,
#[max_length = 64]
email_domain -> Varchar,
}
}

View File

@ -17,6 +17,7 @@ pub struct UserAuthenticationMethod {
pub allow_signup: bool,
pub created_at: PrimitiveDateTime,
pub last_modified_at: PrimitiveDateTime,
pub email_domain: String,
}
#[derive(router_derive::Setter, Clone, Debug, Insertable, router_derive::DebugAsDisplay)]
@ -32,6 +33,7 @@ pub struct UserAuthenticationMethodNew {
pub allow_signup: bool,
pub created_at: PrimitiveDateTime,
pub last_modified_at: PrimitiveDateTime,
pub email_domain: String,
}
#[derive(Clone, Debug, AsChangeset, router_derive::DebugAsDisplay)]
@ -40,6 +42,7 @@ pub struct OrgAuthenticationMethodUpdateInternal {
pub private_config: Option<Encryption>,
pub public_config: Option<serde_json::Value>,
pub last_modified_at: PrimitiveDateTime,
pub email_domain: Option<String>,
}
pub enum UserAuthenticationMethodUpdate {
@ -47,6 +50,9 @@ pub enum UserAuthenticationMethodUpdate {
private_config: Option<Encryption>,
public_config: Option<serde_json::Value>,
},
EmailDomain {
email_domain: String,
},
}
impl From<UserAuthenticationMethodUpdate> for OrgAuthenticationMethodUpdateInternal {
@ -60,6 +66,13 @@ impl From<UserAuthenticationMethodUpdate> for OrgAuthenticationMethodUpdateInter
private_config,
public_config,
last_modified_at,
email_domain: None,
},
UserAuthenticationMethodUpdate::EmailDomain { email_domain } => Self {
private_config: None,
public_config: None,
last_modified_at,
email_domain: Some(email_domain),
},
}
}

View File

@ -108,6 +108,8 @@ pub enum UserErrors {
InvalidThemeLineage(String),
#[error("Missing required field: email_config")]
MissingEmailConfig,
#[error("Invalid Auth Method Operation: {0}")]
InvalidAuthMethodOperationWithMessage(String),
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@ -280,6 +282,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::MissingEmailConfig => {
AER::BadRequest(ApiError::new(sub_code, 56, self.get_error_message(), None))
}
Self::InvalidAuthMethodOperationWithMessage(_) => {
AER::BadRequest(ApiError::new(sub_code, 57, self.get_error_message(), None))
}
}
}
}
@ -347,6 +352,9 @@ impl UserErrors {
format!("Invalid field: {} in lineage", field_name)
}
Self::MissingEmailConfig => "Missing required field: email_config".to_string(),
Self::InvalidAuthMethodOperationWithMessage(operation) => {
format!("Invalid Auth Method Operation: {}", operation)
}
}
}
}

View File

@ -7,7 +7,7 @@ use api_models::{
payments::RedirectionResponse,
user::{self as user_api, InviteMultipleUserResponse, NameIdUnit},
};
use common_enums::EntityType;
use common_enums::{EntityType, UserAuthType};
use common_utils::{type_name, types::keymanager::Identifier};
#[cfg(feature = "email")]
use diesel_models::user_role::UserRoleUpdate;
@ -22,6 +22,7 @@ use masking::{ExposeInterface, PeekInterface, Secret};
#[cfg(feature = "email")]
use router_env::env;
use router_env::logger;
use storage_impl::errors::StorageError;
#[cfg(not(feature = "email"))]
use user_api::dashboard_metadata::SetMetaDataRequest;
@ -152,6 +153,14 @@ pub async fn signup_token_only_flow(
state: SessionState,
request: user_api::SignUpRequest,
) -> UserResponse<user_api::TokenResponse> {
let user_email = domain::UserEmail::from_pii_email(request.email.clone())?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&user_email,
UserAuthType::Password,
)
.await?;
let new_user = domain::NewUser::try_from(request)?;
new_user
.get_new_merchant()
@ -187,9 +196,18 @@ pub async fn signin_token_only_flow(
state: SessionState,
request: user_api::SignInRequest,
) -> UserResponse<user_api::TokenResponse> {
let user_email = domain::UserEmail::from_pii_email(request.email)?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&user_email,
UserAuthType::Password,
)
.await?;
let user_from_db: domain::UserFromStorage = state
.global_store
.find_user_by_email(&domain::UserEmail::from_pii_email(request.email)?)
.find_user_by_email(&user_email)
.await
.to_not_found_response(UserErrors::InvalidCredentials)?
.into();
@ -215,10 +233,16 @@ pub async fn connect_account(
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<user_api::ConnectAccountResponse> {
let find_user = state
.global_store
.find_user_by_email(&domain::UserEmail::from_pii_email(request.email.clone())?)
.await;
let user_email = domain::UserEmail::from_pii_email(request.email.clone())?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&user_email,
UserAuthType::MagicLink,
)
.await?;
let find_user = state.global_store.find_user_by_email(&user_email).await;
if let Ok(found_user) = find_user {
let user_from_db: domain::UserFromStorage = found_user.into();
@ -412,6 +436,13 @@ pub async fn forgot_password(
) -> UserResponse<()> {
let user_email = domain::UserEmail::from_pii_email(request.email)?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&user_email,
UserAuthType::Password,
)
.await?;
let user_from_db = state
.global_store
.find_user_by_email(&user_email)
@ -1757,7 +1788,15 @@ pub async fn send_verification_mail(
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<()> {
let user_email = domain::UserEmail::try_from(req.email)?;
let user_email = domain::UserEmail::from_pii_email(req.email)?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&user_email,
UserAuthType::MagicLink,
)
.await?;
let user = state
.global_store
.find_user_by_email(&user_email)
@ -2317,10 +2356,30 @@ pub async fn create_user_authentication_method(
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to get list of auth methods for the owner id")?;
let auth_id = auth_methods
.first()
.map(|auth_method| auth_method.auth_id.clone())
.unwrap_or(uuid::Uuid::new_v4().to_string());
let (auth_id, email_domain) = if let Some(auth_method) = auth_methods.first() {
let email_domain = match req.email_domain {
Some(email_domain) => {
if email_domain != auth_method.email_domain {
return Err(report!(UserErrors::InvalidAuthMethodOperationWithMessage(
"Email domain mismatch".to_string()
)));
}
email_domain
}
None => auth_method.email_domain.clone(),
};
(auth_method.auth_id.clone(), email_domain)
} else {
let email_domain =
req.email_domain
.ok_or(UserErrors::InvalidAuthMethodOperationWithMessage(
"Email domain not found".to_string(),
))?;
(uuid::Uuid::new_v4().to_string(), email_domain)
};
for db_auth_method in auth_methods {
let is_type_same = db_auth_method.auth_type == (&req.auth_method).foreign_into();
@ -2360,6 +2419,7 @@ pub async fn create_user_authentication_method(
allow_signup: req.allow_signup,
created_at: now,
last_modified_at: now,
email_domain,
})
.await
.to_duplicate_response(UserErrors::UserAuthMethodAlreadyExists)?;
@ -2383,25 +2443,71 @@ pub async fn update_user_authentication_method(
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to decode DEK")?;
let (private_config, public_config) = utils::user::construct_public_and_private_db_configs(
match req {
user_api::UpdateUserAuthenticationMethodRequest::AuthMethod {
id,
auth_config: auth_method,
} => {
let (private_config, public_config) =
utils::user::construct_public_and_private_db_configs(
&state,
&req.auth_method,
&auth_method,
&user_auth_encryption_key,
req.id.clone(),
id.clone(),
)
.await?;
state
.store
.update_user_authentication_method(
&req.id,
&id,
UserAuthenticationMethodUpdate::UpdateConfig {
private_config,
public_config,
},
)
.await
.change_context(UserErrors::InvalidUserAuthMethodOperation)?;
.map_err(|error| {
let user_error = match error.current_context() {
StorageError::ValueNotFound(_) => {
UserErrors::InvalidAuthMethodOperationWithMessage(
"Auth method not found".to_string(),
)
}
StorageError::DuplicateValue { .. } => {
UserErrors::UserAuthMethodAlreadyExists
}
_ => UserErrors::InternalServerError,
};
error.change_context(user_error)
})?;
}
user_api::UpdateUserAuthenticationMethodRequest::EmailDomain {
owner_id,
email_domain,
} => {
let auth_methods = state
.store
.list_user_authentication_methods_for_owner_id(&owner_id)
.await
.change_context(UserErrors::InternalServerError)?;
futures::future::try_join_all(auth_methods.iter().map(|auth_method| async {
state
.store
.update_user_authentication_method(
&auth_method.id,
UserAuthenticationMethodUpdate::EmailDomain {
email_domain: email_domain.clone(),
},
)
.await
.to_duplicate_response(UserErrors::UserAuthMethodAlreadyExists)
}))
.await?;
}
}
Ok(ApplicationResponse::StatusOk)
}
@ -2409,18 +2515,28 @@ pub async fn list_user_authentication_methods(
state: SessionState,
req: user_api::GetUserAuthenticationMethodsRequest,
) -> UserResponse<Vec<user_api::UserAuthenticationMethodResponse>> {
let user_authentication_methods = state
let user_authentication_methods = match (req.auth_id, req.email_domain) {
(Some(auth_id), None) => state
.store
.list_user_authentication_methods_for_auth_id(&req.auth_id)
.list_user_authentication_methods_for_auth_id(&auth_id)
.await
.change_context(UserErrors::InternalServerError)?;
.change_context(UserErrors::InternalServerError)?,
(None, Some(email_domain)) => state
.store
.list_user_authentication_methods_for_email_domain(&email_domain)
.await
.change_context(UserErrors::InternalServerError)?,
(Some(_), Some(_)) | (None, None) => {
return Err(UserErrors::InvalidUserAuthMethodOperation.into());
}
};
Ok(ApplicationResponse::Json(
user_authentication_methods
.into_iter()
.map(|auth_method| {
let auth_name = match (auth_method.auth_type, auth_method.public_config) {
(common_enums::UserAuthType::OpenIdConnect, config) => {
(UserAuthType::OpenIdConnect, config) => {
let open_id_public_config: Option<user_api::OpenIdConnectPublicConfig> =
config
.map(|config| {
@ -2546,6 +2662,13 @@ pub async fn sso_sign(
)
.await?;
utils::user::validate_email_domain_auth_type_using_db(
&state,
&email,
UserAuthType::OpenIdConnect,
)
.await?;
// TODO: Use config to handle not found error
let user_from_db: domain::UserFromStorage = state
.global_store
@ -2594,14 +2717,20 @@ pub async fn terminate_auth_select(
.change_context(UserErrors::InternalServerError)?
.into();
let user_authentication_method = if let Some(id) = &req.id {
state
let user_email = domain::UserEmail::from_pii_email(user_from_db.get_email())?;
let auth_methods = state
.store
.get_user_authentication_method_by_id(id)
.list_user_authentication_methods_for_email_domain(user_email.extract_domain()?)
.await
.to_not_found_response(UserErrors::InvalidUserAuthMethodOperation)?
} else {
DEFAULT_USER_AUTH_METHOD.clone()
.change_context(UserErrors::InternalServerError)?;
let user_authentication_method = match (req.id, auth_methods.is_empty()) {
(Some(id), _) => auth_methods
.into_iter()
.find(|auth_method| auth_method.id == id)
.ok_or(UserErrors::InvalidUserAuthMethodOperation)?,
(None, true) => DEFAULT_USER_AUTH_METHOD.clone(),
(None, false) => return Err(UserErrors::InvalidUserAuthMethodOperation.into()),
};
let current_flow = domain::CurrentFlow::new(user_token, domain::SPTFlow::AuthSelect.into())?;

View File

@ -3816,6 +3816,18 @@ impl UserAuthenticationMethodInterface for KafkaStore {
.update_user_authentication_method(id, user_authentication_method_update)
.await
}
async fn list_user_authentication_methods_for_email_domain(
&self,
email_domain: &str,
) -> CustomResult<
Vec<diesel_models::user_authentication_method::UserAuthenticationMethod>,
errors::StorageError,
> {
self.diesel_store
.list_user_authentication_methods_for_email_domain(email_domain)
.await
}
}
#[async_trait::async_trait]

View File

@ -36,6 +36,11 @@ pub trait UserAuthenticationMethodInterface {
id: &str,
user_authentication_method_update: storage::UserAuthenticationMethodUpdate,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError>;
async fn list_user_authentication_methods_for_email_domain(
&self,
email_domain: &str,
) -> CustomResult<Vec<storage::UserAuthenticationMethod>, errors::StorageError>;
}
#[async_trait::async_trait]
@ -57,7 +62,7 @@ impl UserAuthenticationMethodInterface for Store {
&self,
id: &str,
) -> CustomResult<storage::UserAuthenticationMethod, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
let conn = connection::pg_connection_read(self).await?;
storage::UserAuthenticationMethod::get_user_authentication_method_by_id(&conn, id)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
@ -68,7 +73,7 @@ impl UserAuthenticationMethodInterface for Store {
&self,
auth_id: &str,
) -> CustomResult<Vec<storage::UserAuthenticationMethod>, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
let conn = connection::pg_connection_read(self).await?;
storage::UserAuthenticationMethod::list_user_authentication_methods_for_auth_id(
&conn, auth_id,
)
@ -81,7 +86,7 @@ impl UserAuthenticationMethodInterface for Store {
&self,
owner_id: &str,
) -> CustomResult<Vec<storage::UserAuthenticationMethod>, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
let conn = connection::pg_connection_read(self).await?;
storage::UserAuthenticationMethod::list_user_authentication_methods_for_owner_id(
&conn, owner_id,
)
@ -104,6 +109,20 @@ impl UserAuthenticationMethodInterface for Store {
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[instrument(skip_all)]
async fn list_user_authentication_methods_for_email_domain(
&self,
email_domain: &str,
) -> CustomResult<Vec<storage::UserAuthenticationMethod>, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
storage::UserAuthenticationMethod::list_user_authentication_methods_for_email_domain(
&conn,
email_domain,
)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
}
#[async_trait::async_trait]
@ -130,6 +149,7 @@ impl UserAuthenticationMethodInterface for MockDb {
allow_signup: user_authentication_method.allow_signup,
created_at: user_authentication_method.created_at,
last_modified_at: user_authentication_method.last_modified_at,
email_domain: user_authentication_method.email_domain,
};
user_authentication_methods.push(user_authentication_method.clone());
@ -222,6 +242,13 @@ impl UserAuthenticationMethodInterface for MockDb {
last_modified_at: common_utils::date_time::now(),
..auth_method_inner.to_owned()
},
storage::UserAuthenticationMethodUpdate::EmailDomain { email_domain } => {
storage::UserAuthenticationMethod {
email_domain: email_domain.to_owned(),
last_modified_at: common_utils::date_time::now(),
..auth_method_inner.to_owned()
}
}
};
auth_method_inner.to_owned()
})
@ -232,4 +259,20 @@ impl UserAuthenticationMethodInterface for MockDb {
.into(),
)
}
#[instrument(skip_all)]
async fn list_user_authentication_methods_for_email_domain(
&self,
email_domain: &str,
) -> CustomResult<Vec<storage::UserAuthenticationMethod>, errors::StorageError> {
let user_authentication_methods = self.user_authentication_methods.lock().await;
let user_authentication_methods_list: Vec<_> = user_authentication_methods
.iter()
.filter(|auth_method_inner| auth_method_inner.email_domain == email_domain)
.cloned()
.collect();
Ok(user_authentication_methods_list)
}
}

View File

@ -138,6 +138,15 @@ impl UserEmail {
pub fn get_secret(self) -> Secret<String, pii::EmailStrategy> {
(*self.0).clone()
}
pub fn extract_domain(&self) -> UserResult<&str> {
let (_username, domain) = self
.peek()
.split_once('@')
.ok_or(UserErrors::InternalServerError)?;
Ok(domain)
}
}
impl TryFrom<pii::Email> for UserEmail {

View File

@ -14,4 +14,5 @@ pub static DEFAULT_USER_AUTH_METHOD: Lazy<UserAuthenticationMethod> =
allow_signup: true,
created_at: common_utils::date_time::now(),
last_modified_at: common_utils::date_time::now(),
email_domain: String::from("hyperswitch"),
});

View File

@ -5,7 +5,7 @@ use common_enums::UserAuthType;
use common_utils::{
encryption::Encryption, errors::CustomResult, id_type, type_name, types::keymanager::Identifier,
};
use diesel_models::{organization, organization::OrganizationBridge};
use diesel_models::organization::{self, OrganizationBridge};
use error_stack::ResultExt;
use masking::{ExposeInterface, Secret};
use redis_interface::RedisConnectionPool;
@ -312,3 +312,23 @@ pub fn create_merchant_account_request_for_org(
pm_collect_link_config: None,
})
}
pub async fn validate_email_domain_auth_type_using_db(
state: &SessionState,
email: &domain::UserEmail,
required_auth_type: UserAuthType,
) -> UserResult<()> {
let domain = email.extract_domain()?;
let user_auth_methods = state
.store
.list_user_authentication_methods_for_email_domain(domain)
.await
.change_context(UserErrors::InternalServerError)?;
(user_auth_methods.is_empty()
|| user_auth_methods
.iter()
.any(|auth_method| auth_method.auth_type == required_auth_type))
.then_some(())
.ok_or(UserErrors::InvalidUserAuthMethodOperation.into())
}

View File

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP INDEX email_domain_index;
ALTER TABLE user_authentication_methods DROP COLUMN email_domain;

View File

@ -0,0 +1,6 @@
-- Your SQL goes here
ALTER TABLE user_authentication_methods ADD COLUMN email_domain VARCHAR(64);
UPDATE user_authentication_methods SET email_domain = auth_id WHERE email_domain IS NULL;
ALTER TABLE user_authentication_methods ALTER COLUMN email_domain SET NOT NULL;
CREATE INDEX email_domain_index ON user_authentication_methods (email_domain);