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
148 .as_deref()
149 .map(str::trim)
150 .filter(|s| !s.is_empty()),
151 self.last_name
152 .as_deref()
153 .map(str::trim)
154 .filter(|s| !s.is_empty()),
155 ) {
156 (Some(f), Some(l)) => format!("{f} {l}"),
157 (Some(f), None) => f.to_string(),
158 (None, Some(l)) => l.to_string(),
159 (None, None) => {
160 if let Some(d) = self.display_name.as_deref() {
161 let t = d.trim();
162 if !t.is_empty() {
163 return (t.to_string(), self.job_title.clone());
164 }
165 }
166 self.email
167 .split('@')
168 .next()
169 .unwrap_or(self.email.as_str())
170 .to_string()
171 }
172 };
173 let secondary = self.job_title.clone().filter(|s| !s.trim().is_empty());
174 (primary, secondary)
175 }
176}
177
178#[derive(Debug, Clone)]
183pub struct UserProfile {
184 pub id: i64,
185 pub email: String,
186 pub role: Role,
187 pub is_active: bool,
188 pub created_at: DateTime<Utc>,
189 pub full_name: Option<String>,
190 pub locale: Option<String>,
191 pub timezone: Option<String>,
192 pub is_demo: bool,
193 pub demo_label: Option<String>,
194}
195
196pub async fn init_user_tables(db: &Db) -> Result<()> {
198 sqlx::query(
199 "CREATE TABLE IF NOT EXISTS rustio_users (
200 id BIGSERIAL PRIMARY KEY,
201 email TEXT NOT NULL UNIQUE,
202 password_hash TEXT NOT NULL,
203 role TEXT NOT NULL DEFAULT 'user',
204 is_active BOOLEAN NOT NULL DEFAULT TRUE,
205 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
206 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
207 )",
208 )
209 .execute(db.pool())
210 .await?;
211
212 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
213 .execute(db.pool())
214 .await?;
215
216 Ok(())
217}
218
219pub async fn migrate_user_schema(db: &Db) -> Result<()> {
233 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
234 .execute(db.pool())
235 .await?;
236
237 sqlx::query(
238 "ALTER TABLE rustio_users \
239 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
240 )
241 .execute(db.pool())
242 .await?;
243 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
244 .execute(db.pool())
245 .await?;
246
247 sqlx::query(
248 "DO $$
249 BEGIN
250 IF NOT EXISTS (
251 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
252 ) THEN
253 ALTER TABLE rustio_users
254 ADD CONSTRAINT rustio_users_role_check
255 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
256 END IF;
257 END $$",
258 )
259 .execute(db.pool())
260 .await?;
261
262 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
263 .execute(db.pool())
264 .await?;
265 sqlx::query(
266 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
267 ON rustio_users(is_demo) WHERE is_demo = TRUE",
268 )
269 .execute(db.pool())
270 .await?;
271
272 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
273 .execute(db.pool())
274 .await?;
275 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
276 .execute(db.pool())
277 .await?;
278 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
279 .execute(db.pool())
280 .await?;
281
282 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS first_name TEXT")
285 .execute(db.pool())
286 .await?;
287 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS last_name TEXT")
288 .execute(db.pool())
289 .await?;
290 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS display_name TEXT")
291 .execute(db.pool())
292 .await?;
293 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS job_title TEXT")
294 .execute(db.pool())
295 .await?;
296
297 Ok(())
298}
299
300pub fn hash_password(plain: &str) -> Result<String> {
302 let salt = SaltString::generate(&mut OsRng);
303 Argon2::default()
304 .hash_password(plain.as_bytes(), &salt)
305 .map(|h| h.to_string())
306 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
307}
308
309pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
311 match PasswordHash::new(stored_hash) {
312 Ok(parsed) => Argon2::default()
313 .verify_password(plain.as_bytes(), &parsed)
314 .is_ok(),
315 Err(_) => false,
316 }
317}
318
319pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
321 let hash = hash_password(password)?;
322 let row = sqlx::query(
323 "INSERT INTO rustio_users (email, password_hash, role)
324 VALUES ($1, $2, $3)
325 RETURNING id",
326 )
327 .bind(email)
328 .bind(&hash)
329 .bind(role.as_str())
330 .fetch_one(db.pool())
331 .await
332 .map_err(|e| {
333 log::warn!("create_user failed for {email}: {e}");
338 let detail = e.to_string();
339 if detail.contains("rustio_users_email_key") {
340 Error::BadRequest("An account with this email already exists.".into())
341 } else {
342 Error::BadRequest("Could not create user. Please check your input.".into())
343 }
344 })?;
345 let id: i64 = row
346 .try_get("id")
347 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
348 Ok(id)
349}
350
351pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
353 let row = sqlx::query(
354 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
355 must_change_password, mfa_enabled, \
356 first_name, last_name, display_name, job_title \
357 FROM rustio_users \
358 WHERE email = $1",
359 )
360 .bind(email)
361 .fetch_optional(db.pool())
362 .await?;
363 match row {
364 Some(r) => {
365 let r = Row::from_pg(&r);
366 Ok(Some(StoredUser {
367 id: r.get_i64("id")?,
368 email: r.get_string("email")?,
369 password_hash: r.get_string("password_hash")?,
370 role: Role::parse(&r.get_string("role")?)?,
371 is_active: r.get_bool("is_active")?,
372 is_demo: r.get_bool("is_demo")?,
373 demo_label: r.get_optional_string("demo_label")?,
374 must_change_password: r.get_bool("must_change_password")?,
375 mfa_enabled: r.get_bool("mfa_enabled")?,
376 first_name: r.get_optional_string("first_name")?,
377 last_name: r.get_optional_string("last_name")?,
378 display_name: r.get_optional_string("display_name")?,
379 job_title: r.get_optional_string("job_title")?,
380 }))
381 }
382 None => Ok(None),
383 }
384}
385
386pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
391 let row = sqlx::query(
392 "SELECT id, email, role, is_active, created_at,
393 full_name, locale, timezone, is_demo, demo_label
394 FROM rustio_users
395 WHERE id = $1",
396 )
397 .bind(user_id)
398 .fetch_optional(db.pool())
399 .await?;
400 match row {
401 Some(r) => {
402 let r = Row::from_pg(&r);
403 Ok(Some(UserProfile {
404 id: r.get_i64("id")?,
405 email: r.get_string("email")?,
406 role: Role::parse(&r.get_string("role")?)?,
407 is_active: r.get_bool("is_active")?,
408 created_at: r.get_datetime("created_at")?,
409 full_name: r.get_optional_string("full_name")?,
410 locale: r.get_optional_string("locale")?,
411 timezone: r.get_optional_string("timezone")?,
412 is_demo: r.get_bool("is_demo")?,
413 demo_label: r.get_optional_string("demo_label")?,
414 }))
415 }
416 None => Ok(None),
417 }
418}
419
420pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
442 let hash = hash_password(new_password)?;
443 sqlx::query(
444 "UPDATE rustio_users \
445 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
446 WHERE id = $3",
447 )
448 .bind(&hash)
449 .bind(Utc::now())
450 .bind(user_id)
451 .execute(db.pool())
452 .await?;
453 Ok(())
454}
455
456pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
458 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
459 .bind(role.as_str())
460 .bind(Utc::now())
461 .bind(user_id)
462 .execute(db.pool())
463 .await?;
464 Ok(())
465}
466
467pub fn verdict_for_orphan_role(
477 active_count_in_protected: i64,
478 target_is_in_protected: bool,
479 new_role_is_protected: bool,
480 new_active: bool,
481) -> bool {
482 if !target_is_in_protected {
483 return false;
484 }
485 if active_count_in_protected != 1 {
486 return false;
487 }
488 !(new_active && new_role_is_protected)
491}
492
493pub async fn would_orphan_role(
507 db: &Db,
508 user_id: i64,
509 protected_role: Role,
510 new_role: Role,
511 new_active: bool,
512) -> Result<bool> {
513 let active_count: i64 = sqlx::query_scalar(
514 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
515 )
516 .bind(protected_role.as_str())
517 .fetch_one(db.pool())
518 .await?;
519
520 let target_role_str: Option<String> =
521 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
522 .bind(user_id)
523 .fetch_optional(db.pool())
524 .await?;
525 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
526
527 Ok(verdict_for_orphan_role(
528 active_count,
529 target_is_in_protected,
530 new_role == protected_role,
531 new_active,
532 ))
533}
534
535pub async fn would_orphan_protected(
540 db: &Db,
541 user_id: i64,
542 new_role: Role,
543 new_active: bool,
544) -> Result<Option<Role>> {
545 for &role in super::role::protected_roles() {
546 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
547 return Ok(Some(role));
548 }
549 }
550 Ok(None)
551}
552
553#[deprecated(
558 since = "0.3.0",
559 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
560)]
561pub async fn would_orphan_developers(
562 db: &Db,
563 user_id: i64,
564 new_role: Option<Role>,
565) -> Result<bool> {
566 let (role, active) = match new_role {
567 Some(r) => (r, true),
568 None => (Role::User, false),
569 };
570 would_orphan_role(db, user_id, Role::Developer, role, active).await
571}
572
573pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
578 let user = find_user_by_email(db, email)
579 .await?
580 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
581 if !user.is_active {
582 return Err(Error::Forbidden("account disabled".into()));
583 }
584 if !verify_password(password, &user.password_hash) {
585 return Err(Error::Unauthorized("invalid email or password".into()));
586 }
587 create_session(db, user.id).await
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn user_profile_derives_debug_and_clone() {
596 fn assert_traits<T: std::fmt::Debug + Clone>() {}
597 assert_traits::<UserProfile>();
598 }
599
600 #[test]
601 fn password_round_trip() {
602 let h = hash_password("secret").unwrap();
603 assert!(verify_password("secret", &h));
604 assert!(!verify_password("wrong", &h));
605 }
606
607 #[test]
610 fn verdict_safe_when_target_not_in_protected_pool() {
611 assert!(!verdict_for_orphan_role(0, false, false, true));
614 assert!(!verdict_for_orphan_role(1, false, false, false));
615 assert!(!verdict_for_orphan_role(5, false, true, true));
616 }
617
618 #[test]
619 fn verdict_safe_when_more_than_one_member() {
620 assert!(!verdict_for_orphan_role(2, true, false, true));
623 assert!(!verdict_for_orphan_role(5, true, false, false));
624 }
625
626 #[test]
627 fn verdict_blocks_when_last_member_demoting() {
628 assert!(verdict_for_orphan_role(1, true, false, true));
631 }
632
633 #[test]
634 fn verdict_blocks_when_last_member_deactivating() {
635 assert!(verdict_for_orphan_role(1, true, true, false));
637 }
638
639 #[test]
640 fn verdict_blocks_when_last_member_deleting() {
641 assert!(verdict_for_orphan_role(1, true, false, false));
643 }
644
645 #[test]
646 fn verdict_safe_when_last_member_keeps_role() {
647 assert!(!verdict_for_orphan_role(1, true, true, true));
649 }
650}