diff --git a/src/handlers/http/modal/query_server.rs b/src/handlers/http/modal/query_server.rs index 8b0e7ff00..a0cea4a3b 100644 --- a/src/handlers/http/modal/query_server.rs +++ b/src/handlers/http/modal/query_server.rs @@ -25,6 +25,7 @@ use crate::handlers::http::logstream; use crate::handlers::http::max_event_payload_size; use crate::handlers::http::middleware::{DisAllowRootUser, RouteExt}; use crate::handlers::http::modal::initialize_hot_tier_metadata_on_startup; +use crate::handlers::http::rbac::patch_user; use crate::handlers::http::{base_path, prism_base_path}; use crate::handlers::http::{rbac, role}; use crate::hottier::GLOBAL_HOTTIER; @@ -220,6 +221,11 @@ impl QueryServer { .to(querier_rbac::delete_user) .authorize(Action::DeleteUser), ) + .route( + web::patch() + .to(patch_user) + .authorize_for_user(Action::PatchUser), + ) .wrap(DisAllowRootUser), ) .service( diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 23de0677c..5a0172bdd 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -710,6 +710,11 @@ impl Server { .to(http::rbac::delete_user) .authorize(Action::DeleteUser), ) + .route( + web::patch() + .to(http::rbac::patch_user) + .authorize_for_user(Action::PatchUser), + ) .wrap(DisAllowRootUser), ) .service( diff --git a/src/handlers/http/rbac.rs b/src/handlers/http/rbac.rs index 552b52c53..b5e7e8cef 100644 --- a/src/handlers/http/rbac.rs +++ b/src/handlers/http/rbac.rs @@ -24,7 +24,7 @@ use crate::{ self, Users, map::{read_user_groups, roles, users}, role::model::Role, - user::{self, UserType}, + user::{self, UpdateUser, UserType}, utils::to_prism_user, }, storage::ObjectStorageError, @@ -178,6 +178,27 @@ pub async fn post_user( Ok(password) } +// Handler for PATCH /api/v1/user/{userid} +// Upsert user (e.g. email) +pub async fn patch_user( + req: HttpRequest, + userid: web::Path, + web::Json(update): web::Json, +) -> Result { + let userid = userid.into_inner(); + let tenant_id = get_tenant_id_from_request(&req); + + if let Some(p) = Users.is_protected(&userid, &tenant_id) + && p + { + return Err(RBACError::ProtectedUser); + }; + + update.update(&userid, &tenant_id)?; + + Ok(HttpResponse::Ok()) +} + // Handler for POST /api/v1/user/{userid}/generate-new-password // Resets password for the user to a newly generated one and returns it pub async fn post_gen_password( diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index 7197c7583..654272fe4 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -32,6 +32,7 @@ use serde::Serialize; use url::Url; use crate::handlers::TENANT_ID; +use crate::handlers::http::rbac::RBACError; use crate::parseable::DEFAULT_TENANT; use crate::rbac::map::{mut_sessions, mut_users, read_user_groups, roles, sessions, users}; use crate::rbac::role::Action; @@ -58,6 +59,24 @@ pub enum Response { pub struct Users; impl Users { + pub fn put_email( + &self, + email: String, + tenant_id: &Option, + userid: &str, + ) -> Result<(), RBACError> { + // won't change permissions so don't refresh session + let tenant_id = tenant_id.as_deref().unwrap_or(DEFAULT_TENANT); + if let Some(tenant_users) = mut_users().get_mut(tenant_id) + && let Some(user) = tenant_users.get_mut(userid) + { + user.insert_email(email)?; + Ok(()) + } else { + Err(RBACError::UserDoesNotExist) + } + } + pub fn put_user(&self, user: User) { let tenant_id = user.tenant.as_deref().unwrap_or(DEFAULT_TENANT); mut_sessions().remove_user(user.userid(), tenant_id); @@ -363,7 +382,7 @@ pub struct UsersPrism { pub username: String, // oaith or native pub method: String, - // email only if method is oauth + // email if set pub email: Option, // picture only if oauth pub picture: Option, diff --git a/src/rbac/role.rs b/src/rbac/role.rs index 4a4e028af..e1feb00d1 100644 --- a/src/rbac/role.rs +++ b/src/rbac/role.rs @@ -34,6 +34,7 @@ pub enum Action { GetStats, DeleteStream, GetRetention, + PatchUser, PutRetention, PutHotTierEnabled, GetHotTierEnabled, @@ -117,6 +118,7 @@ impl RoleBuilder { | Action::Metrics | Action::PutUser | Action::ListUser + | Action::PatchUser | Action::PutUserRoles | Action::GetUserRoles | Action::DeleteUser @@ -378,6 +380,7 @@ pub mod model { Action::CreateDashboard, Action::DeleteDashboard, Action::GetUserRoles, + Action::PatchUser, ], resource_type: Some(ParseableResourceType::All), } @@ -418,6 +421,7 @@ pub mod model { Action::CreateFilter, Action::DeleteFilter, Action::GetUserRoles, + Action::PatchUser, ], resource_type: None, } @@ -451,6 +455,7 @@ pub mod model { Action::GetHotTierEnabled, Action::GetStreamInfo, Action::GetUserRoles, + Action::PatchUser, Action::GetAlert, ], resource_type: None, diff --git a/src/rbac/user.rs b/src/rbac/user.rs index e9cecc4c2..00ab52237 100644 --- a/src/rbac/user.rs +++ b/src/rbac/user.rs @@ -16,7 +16,7 @@ * */ -use std::collections::HashSet; +use std::{collections::HashSet, sync::LazyLock}; use argon2::{ Argon2, PasswordHash, PasswordVerifier, @@ -29,18 +29,52 @@ use rand::{ RngCore, distributions::{Alphanumeric, DistString}, }; +use regex::Regex; +use serde::Deserialize; use ulid::Ulid; use crate::{ handlers::http::rbac::{InvalidUserGroupError, RBACError}, parseable::{DEFAULT_TENANT, PARSEABLE}, rbac::{ + Users, map::{mut_sessions, read_user_groups, roles, users}, role::model::RoleType, roles_to_permission, }, }; +static EMAIL_VALIDATOR_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?i)[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + .expect("email validation regex is invalid") +}); + +#[derive(Debug, Deserialize)] +pub struct UpdateUser { + pub email: Option, +} + +impl UpdateUser { + pub fn update(&self, userid: &str, tenant_id: &Option) -> Result<(), RBACError> { + if let Some(email) = &self.email { + if self.is_valid_email() { + Users.put_email(email.clone(), tenant_id, userid)?; + } else { + return Err(RBACError::Anyhow(anyhow::Error::msg("Invalid Email"))); + } + } + + Ok(()) + } + fn is_valid_email(&self) -> bool { + if let Some(email) = &self.email { + EMAIL_VALIDATOR_RE.is_match(email) + } else { + false + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(untagged)] pub enum UserType { @@ -73,6 +107,7 @@ impl User { ty: UserType::Native(Basic { username, password_hash: hash, + email: None, }), roles: HashSet::new(), user_groups: HashSet::new(), @@ -130,6 +165,19 @@ impl User { } } + pub fn insert_email(&mut self, email: String) -> Result<(), RBACError> { + match self.ty { + UserType::Native(ref mut user) => user.email = Some(email), + UserType::OAuth(ref mut oauth) => oauth.user_info.email = Some(email), + _ => { + return Err(RBACError::Anyhow(anyhow::Error::msg( + "Can't add email to API key", + ))); + } + } + Ok(()) + } + pub fn userid(&self) -> &str { match self.ty { UserType::Native(Basic { ref username, .. }) => username, @@ -186,6 +234,7 @@ impl User { pub struct Basic { pub username: String, pub password_hash: String, + pub email: Option, } impl Basic { @@ -240,6 +289,7 @@ pub fn get_super_admin_user() -> User { ty: UserType::Native(Basic { username, password_hash: hashcode, + email: None, }), roles: ["super-admin".to_string()].into(), user_groups: HashSet::new(), diff --git a/src/rbac/utils.rs b/src/rbac/utils.rs index 9e6000f5c..435fc4b1e 100644 --- a/src/rbac/utils.rs +++ b/src/rbac/utils.rs @@ -33,7 +33,13 @@ use super::{ pub fn to_prism_user(user: &User) -> UsersPrism { let tenant_id = user.tenant.as_deref().unwrap_or(DEFAULT_TENANT); let (id, username, method, email, picture) = match &user.ty { - UserType::Native(_) => (user.userid(), user.userid(), "native", None, None), + UserType::Native(basic) => ( + user.userid(), + user.userid(), + "native", + basic.email.clone(), + None, + ), UserType::OAuth(oauth) => { let userid = user.userid(); let display_name = oauth.user_info.name.as_deref().unwrap_or(userid);