1use argon2::password_hash::{
4 rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
5};
6use argon2::Argon2;
7use chrono::{DateTime, Utc};
8use sqlx::Row as SqlxRow;
9
10use crate::error::{Error, Result};
11use crate::orm::{Db, Row};
12
13use super::role::Role;
14use super::sessions::create_session;
15
16#[derive(Debug, Clone)]
20pub struct Identity {
21 pub user_id: i64,
22 pub email: String,
23 pub role: Role,
24 pub is_active: bool,
25 pub is_demo: bool,
29 pub demo_label: Option<String>,
30 pub must_change_password: bool,
40 pub mfa_enabled: bool,
51 pub trust_level: crate::auth::SessionTrust,
63}
64
65impl Identity {
66 pub fn is_admin(&self) -> bool {
69 self.is_active && self.role.includes(Role::Administrator)
70 }
71
72 pub fn can_access_admin(&self) -> bool {
75 self.is_active && self.role.can_access_panel()
76 }
77}
78
79pub struct StoredUser {
81 pub id: i64,
82 pub email: String,
83 pub password_hash: String,
84 pub role: Role,
85 pub is_active: bool,
86 pub is_demo: bool,
87 pub demo_label: Option<String>,
88 pub must_change_password: bool,
94 pub mfa_enabled: bool,
99 pub first_name: Option<String>,
106 pub last_name: Option<String>,
107 pub display_name: Option<String>,
108 pub job_title: Option<String>,
109}
110
111impl StoredUser {
112 pub fn greeting_name(&self) -> String {
118 if let Some(d) = self.display_name.as_deref() {
119 let t = d.trim();
120 if !t.is_empty() {
121 return t.to_string();
122 }
123 }
124 if let Some(f) = self.first_name.as_deref() {
125 let t = f.trim();
126 if !t.is_empty() {
127 return t.to_string();
128 }
129 }
130 if let Some((local, _)) = self.email.split_once('@') {
131 let t = local.trim();
132 if !t.is_empty() {
133 return t.to_string();
134 }
135 }
136 "there".to_string()
137 }
138
139 pub fn signature_lines(&self) -> (String, Option<String>) {
144 let primary = match (
147 self.first_name.as_deref().map(str::trim).filter(|s| !s.is_empty()),
148 self.last_name.as_deref().map(str::trim).filter(|s| !s.is_empty()),
149 ) {
150 (Some(f), Some(l)) => format!("{f} {l}"),
151 (Some(f), None) => f.to_string(),
152 (None, Some(l)) => l.to_string(),
153 (None, None) => {
154 if let Some(d) = self.display_name.as_deref() {
155 let t = d.trim();
156 if !t.is_empty() {
157 return (t.to_string(), self.job_title.clone());
158 }
159 }
160 self.email
161 .split('@')
162 .next()
163 .unwrap_or(self.email.as_str())
164 .to_string()
165 }
166 };
167 let secondary = self.job_title.clone().filter(|s| !s.trim().is_empty());
168 (primary, secondary)
169 }
170}
171
172#[derive(Debug, Clone)]
177pub struct UserProfile {
178 pub id: i64,
179 pub email: String,
180 pub role: Role,
181 pub is_active: bool,
182 pub created_at: DateTime<Utc>,
183 pub full_name: Option<String>,
184 pub locale: Option<String>,
185 pub timezone: Option<String>,
186 pub is_demo: bool,
187 pub demo_label: Option<String>,
188}
189
190pub async fn init_user_tables(db: &Db) -> Result<()> {
192 sqlx::query(
193 "CREATE TABLE IF NOT EXISTS rustio_users (
194 id BIGSERIAL PRIMARY KEY,
195 email TEXT NOT NULL UNIQUE,
196 password_hash TEXT NOT NULL,
197 role TEXT NOT NULL DEFAULT 'user',
198 is_active BOOLEAN NOT NULL DEFAULT TRUE,
199 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
200 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
201 )",
202 )
203 .execute(db.pool())
204 .await?;
205
206 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
207 .execute(db.pool())
208 .await?;
209
210 Ok(())
211}
212
213pub async fn migrate_user_schema(db: &Db) -> Result<()> {
227 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
228 .execute(db.pool())
229 .await?;
230
231 sqlx::query(
232 "ALTER TABLE rustio_users \
233 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
234 )
235 .execute(db.pool())
236 .await?;
237 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
238 .execute(db.pool())
239 .await?;
240
241 sqlx::query(
242 "DO $$
243 BEGIN
244 IF NOT EXISTS (
245 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
246 ) THEN
247 ALTER TABLE rustio_users
248 ADD CONSTRAINT rustio_users_role_check
249 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
250 END IF;
251 END $$",
252 )
253 .execute(db.pool())
254 .await?;
255
256 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
257 .execute(db.pool())
258 .await?;
259 sqlx::query(
260 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
261 ON rustio_users(is_demo) WHERE is_demo = TRUE",
262 )
263 .execute(db.pool())
264 .await?;
265
266 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
267 .execute(db.pool())
268 .await?;
269 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
270 .execute(db.pool())
271 .await?;
272 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
273 .execute(db.pool())
274 .await?;
275
276 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS first_name TEXT")
279 .execute(db.pool())
280 .await?;
281 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS last_name TEXT")
282 .execute(db.pool())
283 .await?;
284 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS display_name TEXT")
285 .execute(db.pool())
286 .await?;
287 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS job_title TEXT")
288 .execute(db.pool())
289 .await?;
290
291 Ok(())
292}
293
294pub fn hash_password(plain: &str) -> Result<String> {
296 let salt = SaltString::generate(&mut OsRng);
297 Argon2::default()
298 .hash_password(plain.as_bytes(), &salt)
299 .map(|h| h.to_string())
300 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
301}
302
303pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
305 match PasswordHash::new(stored_hash) {
306 Ok(parsed) => Argon2::default()
307 .verify_password(plain.as_bytes(), &parsed)
308 .is_ok(),
309 Err(_) => false,
310 }
311}
312
313pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
315 let hash = hash_password(password)?;
316 let row = sqlx::query(
317 "INSERT INTO rustio_users (email, password_hash, role)
318 VALUES ($1, $2, $3)
319 RETURNING id",
320 )
321 .bind(email)
322 .bind(&hash)
323 .bind(role.as_str())
324 .fetch_one(db.pool())
325 .await
326 .map_err(|e| {
327 log::warn!("create_user failed for {email}: {e}");
332 let detail = e.to_string();
333 if detail.contains("rustio_users_email_key") {
334 Error::BadRequest("An account with this email already exists.".into())
335 } else {
336 Error::BadRequest("Could not create user. Please check your input.".into())
337 }
338 })?;
339 let id: i64 = row
340 .try_get("id")
341 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
342 Ok(id)
343}
344
345pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
347 let row = sqlx::query(
348 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
349 must_change_password, mfa_enabled, \
350 first_name, last_name, display_name, job_title \
351 FROM rustio_users \
352 WHERE email = $1",
353 )
354 .bind(email)
355 .fetch_optional(db.pool())
356 .await?;
357 match row {
358 Some(r) => {
359 let r = Row::from_pg(&r);
360 Ok(Some(StoredUser {
361 id: r.get_i64("id")?,
362 email: r.get_string("email")?,
363 password_hash: r.get_string("password_hash")?,
364 role: Role::parse(&r.get_string("role")?)?,
365 is_active: r.get_bool("is_active")?,
366 is_demo: r.get_bool("is_demo")?,
367 demo_label: r.get_optional_string("demo_label")?,
368 must_change_password: r.get_bool("must_change_password")?,
369 mfa_enabled: r.get_bool("mfa_enabled")?,
370 first_name: r.get_optional_string("first_name")?,
371 last_name: r.get_optional_string("last_name")?,
372 display_name: r.get_optional_string("display_name")?,
373 job_title: r.get_optional_string("job_title")?,
374 }))
375 }
376 None => Ok(None),
377 }
378}
379
380pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
385 let row = sqlx::query(
386 "SELECT id, email, role, is_active, created_at,
387 full_name, locale, timezone, is_demo, demo_label
388 FROM rustio_users
389 WHERE id = $1",
390 )
391 .bind(user_id)
392 .fetch_optional(db.pool())
393 .await?;
394 match row {
395 Some(r) => {
396 let r = Row::from_pg(&r);
397 Ok(Some(UserProfile {
398 id: r.get_i64("id")?,
399 email: r.get_string("email")?,
400 role: Role::parse(&r.get_string("role")?)?,
401 is_active: r.get_bool("is_active")?,
402 created_at: r.get_datetime("created_at")?,
403 full_name: r.get_optional_string("full_name")?,
404 locale: r.get_optional_string("locale")?,
405 timezone: r.get_optional_string("timezone")?,
406 is_demo: r.get_bool("is_demo")?,
407 demo_label: r.get_optional_string("demo_label")?,
408 }))
409 }
410 None => Ok(None),
411 }
412}
413
414pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
436 let hash = hash_password(new_password)?;
437 sqlx::query(
438 "UPDATE rustio_users \
439 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
440 WHERE id = $3",
441 )
442 .bind(&hash)
443 .bind(Utc::now())
444 .bind(user_id)
445 .execute(db.pool())
446 .await?;
447 Ok(())
448}
449
450pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
452 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
453 .bind(role.as_str())
454 .bind(Utc::now())
455 .bind(user_id)
456 .execute(db.pool())
457 .await?;
458 Ok(())
459}
460
461pub fn verdict_for_orphan_role(
471 active_count_in_protected: i64,
472 target_is_in_protected: bool,
473 new_role_is_protected: bool,
474 new_active: bool,
475) -> bool {
476 if !target_is_in_protected {
477 return false;
478 }
479 if active_count_in_protected != 1 {
480 return false;
481 }
482 !(new_active && new_role_is_protected)
485}
486
487pub async fn would_orphan_role(
501 db: &Db,
502 user_id: i64,
503 protected_role: Role,
504 new_role: Role,
505 new_active: bool,
506) -> Result<bool> {
507 let active_count: i64 = sqlx::query_scalar(
508 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
509 )
510 .bind(protected_role.as_str())
511 .fetch_one(db.pool())
512 .await?;
513
514 let target_role_str: Option<String> =
515 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
516 .bind(user_id)
517 .fetch_optional(db.pool())
518 .await?;
519 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
520
521 Ok(verdict_for_orphan_role(
522 active_count,
523 target_is_in_protected,
524 new_role == protected_role,
525 new_active,
526 ))
527}
528
529pub async fn would_orphan_protected(
534 db: &Db,
535 user_id: i64,
536 new_role: Role,
537 new_active: bool,
538) -> Result<Option<Role>> {
539 for &role in super::role::protected_roles() {
540 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
541 return Ok(Some(role));
542 }
543 }
544 Ok(None)
545}
546
547#[deprecated(
552 since = "0.3.0",
553 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
554)]
555pub async fn would_orphan_developers(
556 db: &Db,
557 user_id: i64,
558 new_role: Option<Role>,
559) -> Result<bool> {
560 let (role, active) = match new_role {
561 Some(r) => (r, true),
562 None => (Role::User, false),
563 };
564 would_orphan_role(db, user_id, Role::Developer, role, active).await
565}
566
567pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
572 let user = find_user_by_email(db, email)
573 .await?
574 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
575 if !user.is_active {
576 return Err(Error::Forbidden("account disabled".into()));
577 }
578 if !verify_password(password, &user.password_hash) {
579 return Err(Error::Unauthorized("invalid email or password".into()));
580 }
581 create_session(db, user.id).await
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn user_profile_derives_debug_and_clone() {
590 fn assert_traits<T: std::fmt::Debug + Clone>() {}
591 assert_traits::<UserProfile>();
592 }
593
594 #[test]
595 fn password_round_trip() {
596 let h = hash_password("secret").unwrap();
597 assert!(verify_password("secret", &h));
598 assert!(!verify_password("wrong", &h));
599 }
600
601 #[test]
604 fn verdict_safe_when_target_not_in_protected_pool() {
605 assert!(!verdict_for_orphan_role(0, false, false, true));
608 assert!(!verdict_for_orphan_role(1, false, false, false));
609 assert!(!verdict_for_orphan_role(5, false, true, true));
610 }
611
612 #[test]
613 fn verdict_safe_when_more_than_one_member() {
614 assert!(!verdict_for_orphan_role(2, true, false, true));
617 assert!(!verdict_for_orphan_role(5, true, false, false));
618 }
619
620 #[test]
621 fn verdict_blocks_when_last_member_demoting() {
622 assert!(verdict_for_orphan_role(1, true, false, true));
625 }
626
627 #[test]
628 fn verdict_blocks_when_last_member_deactivating() {
629 assert!(verdict_for_orphan_role(1, true, true, false));
631 }
632
633 #[test]
634 fn verdict_blocks_when_last_member_deleting() {
635 assert!(verdict_for_orphan_role(1, true, false, false));
637 }
638
639 #[test]
640 fn verdict_safe_when_last_member_keeps_role() {
641 assert!(!verdict_for_orphan_role(1, true, true, true));
643 }
644}