From c56e046f3e3b9c321a37ebe2b15c038f941d4e20 Mon Sep 17 00:00:00 2001 From: Anant Vindal Date: Thu, 23 Jul 2026 14:21:25 +0530 Subject: [PATCH 1/3] update: upsert email API ``` PUT /api/v1/user/{userid}/email body- {email: "email@example.com"} ``` Users can upsert their email --- src/handlers/http/modal/query_server.rs | 8 ++++++ src/handlers/http/modal/server.rs | 7 +++++ src/handlers/http/rbac.rs | 25 ++++++++++++++++- src/rbac/mod.rs | 19 +++++++++++++ src/rbac/role.rs | 5 ++++ src/rbac/user.rs | 37 ++++++++++++++++++++++++- 6 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/modal/query_server.rs b/src/handlers/http/modal/query_server.rs index 8b0e7ff00..e12f6fd87 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::put_email; use crate::handlers::http::{base_path, prism_base_path}; use crate::handlers::http::{rbac, role}; use crate::hottier::GLOBAL_HOTTIER; @@ -229,6 +230,13 @@ impl QueryServer { .authorize_for_user(Action::GetUserRoles), ), ) + .service( + web::resource("/{userid}/email").route( + web::put() + .to(put_email) + .authorize_for_user(Action::PutEmail), + ), + ) .service( web::resource("/{userid}/role/add") // PATCH /user/{userid}/role/add => Add roles to a user diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 23de0677c..3b2caedc8 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -712,6 +712,13 @@ impl Server { ) .wrap(DisAllowRootUser), ) + .service( + web::resource("/{userid}/email").route( + web::put() + .to(http::rbac::put_email) + .authorize_for_user(Action::PutEmail), + ), + ) .service( web::resource("/{userid}/role").route( web::get() diff --git a/src/handlers/http/rbac.rs b/src/handlers/http/rbac.rs index 552b52c53..2d49ef520 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, UserEmail, UserType}, utils::to_prism_user, }, storage::ObjectStorageError, @@ -178,6 +178,29 @@ pub async fn post_user( Ok(password) } +// Handler for PUT /api/v1/user/{userid}/email +// Upsert email +pub async fn put_email( + req: HttpRequest, + userid: web::Path, + web::Json(email): 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); + }; + if !email.is_valid() { + return Err(RBACError::Anyhow(anyhow::Error::msg("Invalid Email"))); + } + Users.put_email(email.email, &tenant_id, &userid)?; + + 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..e8a3decb5 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); diff --git a/src/rbac/role.rs b/src/rbac/role.rs index 4a4e028af..dee4b9978 100644 --- a/src/rbac/role.rs +++ b/src/rbac/role.rs @@ -34,6 +34,7 @@ pub enum Action { GetStats, DeleteStream, GetRetention, + PutEmail, PutRetention, PutHotTierEnabled, GetHotTierEnabled, @@ -117,6 +118,7 @@ impl RoleBuilder { | Action::Metrics | Action::PutUser | Action::ListUser + | Action::PutEmail | Action::PutUserRoles | Action::GetUserRoles | Action::DeleteUser @@ -378,6 +380,7 @@ pub mod model { Action::CreateDashboard, Action::DeleteDashboard, Action::GetUserRoles, + Action::PutEmail, ], resource_type: Some(ParseableResourceType::All), } @@ -418,6 +421,7 @@ pub mod model { Action::CreateFilter, Action::DeleteFilter, Action::GetUserRoles, + Action::PutEmail, ], resource_type: None, } @@ -451,6 +455,7 @@ pub mod model { Action::GetHotTierEnabled, Action::GetStreamInfo, Action::GetUserRoles, + Action::PutEmail, Action::GetAlert, ], resource_type: None, diff --git a/src/rbac/user.rs b/src/rbac/user.rs index e9cecc4c2..65dd02f87 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,6 +29,8 @@ use rand::{ RngCore, distributions::{Alphanumeric, DistString}, }; +use regex::Regex; +use serde::Deserialize; use ulid::Ulid; use crate::{ @@ -41,6 +43,23 @@ use crate::{ }, }; +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 UserEmail { + pub email: String, +} + +impl UserEmail { + pub fn is_valid(&self) -> bool { + EMAIL_VALIDATOR_RE.is_match(&self.email) + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(untagged)] pub enum UserType { @@ -73,6 +92,7 @@ impl User { ty: UserType::Native(Basic { username, password_hash: hash, + email: None, }), roles: HashSet::new(), user_groups: HashSet::new(), @@ -130,6 +150,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 +219,7 @@ impl User { pub struct Basic { pub username: String, pub password_hash: String, + pub email: Option, } impl Basic { @@ -240,6 +274,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(), From 37c0f4730ee922418dc3552fbff15972943e2334 Mon Sep 17 00:00:00 2001 From: Anant Vindal Date: Thu, 23 Jul 2026 14:56:31 +0530 Subject: [PATCH 2/3] fix: user listing and regex --- src/rbac/mod.rs | 2 +- src/rbac/user.rs | 3 +-- src/rbac/utils.rs | 8 +++++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/rbac/mod.rs b/src/rbac/mod.rs index e8a3decb5..654272fe4 100644 --- a/src/rbac/mod.rs +++ b/src/rbac/mod.rs @@ -382,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/user.rs b/src/rbac/user.rs index 65dd02f87..693a6eaf4 100644 --- a/src/rbac/user.rs +++ b/src/rbac/user.rs @@ -44,8 +44,7 @@ use crate::{ }; 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])?$ -") + 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") }); 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); From add521db6249d5fdaf001d945ef61e0c10de374d Mon Sep 17 00:00:00 2001 From: Anant Vindal Date: Thu, 23 Jul 2026 15:33:24 +0530 Subject: [PATCH 3/3] update: make API PATCH instead of PUT ``` PATCH /api/v1/user/{userid} body- {email: "email@example.com"} ``` --- src/handlers/http/modal/query_server.rs | 14 ++++++------- src/handlers/http/modal/server.rs | 12 +++++------- src/handlers/http/rbac.rs | 16 +++++++-------- src/rbac/role.rs | 10 +++++----- src/rbac/user.rs | 26 ++++++++++++++++++++----- 5 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/handlers/http/modal/query_server.rs b/src/handlers/http/modal/query_server.rs index e12f6fd87..a0cea4a3b 100644 --- a/src/handlers/http/modal/query_server.rs +++ b/src/handlers/http/modal/query_server.rs @@ -25,7 +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::put_email; +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; @@ -221,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( @@ -230,13 +235,6 @@ impl QueryServer { .authorize_for_user(Action::GetUserRoles), ), ) - .service( - web::resource("/{userid}/email").route( - web::put() - .to(put_email) - .authorize_for_user(Action::PutEmail), - ), - ) .service( web::resource("/{userid}/role/add") // PATCH /user/{userid}/role/add => Add roles to a user diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 3b2caedc8..5a0172bdd 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -710,15 +710,13 @@ 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( - web::resource("/{userid}/email").route( - web::put() - .to(http::rbac::put_email) - .authorize_for_user(Action::PutEmail), - ), - ) .service( web::resource("/{userid}/role").route( web::get() diff --git a/src/handlers/http/rbac.rs b/src/handlers/http/rbac.rs index 2d49ef520..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, UserEmail, UserType}, + user::{self, UpdateUser, UserType}, utils::to_prism_user, }, storage::ObjectStorageError, @@ -178,12 +178,12 @@ pub async fn post_user( Ok(password) } -// Handler for PUT /api/v1/user/{userid}/email -// Upsert email -pub async fn put_email( +// Handler for PATCH /api/v1/user/{userid} +// Upsert user (e.g. email) +pub async fn patch_user( req: HttpRequest, userid: web::Path, - web::Json(email): web::Json, + web::Json(update): web::Json, ) -> Result { let userid = userid.into_inner(); let tenant_id = get_tenant_id_from_request(&req); @@ -193,10 +193,8 @@ pub async fn put_email( { return Err(RBACError::ProtectedUser); }; - if !email.is_valid() { - return Err(RBACError::Anyhow(anyhow::Error::msg("Invalid Email"))); - } - Users.put_email(email.email, &tenant_id, &userid)?; + + update.update(&userid, &tenant_id)?; Ok(HttpResponse::Ok()) } diff --git a/src/rbac/role.rs b/src/rbac/role.rs index dee4b9978..e1feb00d1 100644 --- a/src/rbac/role.rs +++ b/src/rbac/role.rs @@ -34,7 +34,7 @@ pub enum Action { GetStats, DeleteStream, GetRetention, - PutEmail, + PatchUser, PutRetention, PutHotTierEnabled, GetHotTierEnabled, @@ -118,7 +118,7 @@ impl RoleBuilder { | Action::Metrics | Action::PutUser | Action::ListUser - | Action::PutEmail + | Action::PatchUser | Action::PutUserRoles | Action::GetUserRoles | Action::DeleteUser @@ -380,7 +380,7 @@ pub mod model { Action::CreateDashboard, Action::DeleteDashboard, Action::GetUserRoles, - Action::PutEmail, + Action::PatchUser, ], resource_type: Some(ParseableResourceType::All), } @@ -421,7 +421,7 @@ pub mod model { Action::CreateFilter, Action::DeleteFilter, Action::GetUserRoles, - Action::PutEmail, + Action::PatchUser, ], resource_type: None, } @@ -455,7 +455,7 @@ pub mod model { Action::GetHotTierEnabled, Action::GetStreamInfo, Action::GetUserRoles, - Action::PutEmail, + Action::PatchUser, Action::GetAlert, ], resource_type: None, diff --git a/src/rbac/user.rs b/src/rbac/user.rs index 693a6eaf4..00ab52237 100644 --- a/src/rbac/user.rs +++ b/src/rbac/user.rs @@ -37,6 +37,7 @@ 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, @@ -49,13 +50,28 @@ static EMAIL_VALIDATOR_RE: LazyLock = LazyLock::new(|| { }); #[derive(Debug, Deserialize)] -pub struct UserEmail { - pub email: String, +pub struct UpdateUser { + pub email: Option, } -impl UserEmail { - pub fn is_valid(&self) -> bool { - EMAIL_VALIDATOR_RE.is_match(&self.email) +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 + } } }