Skip to main content

rustio_admin/auth/
users.rs

1//! User records, password hashing, and the login flow.
2
3use 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// public:
17/// The identity attached to a request by the auth middleware. Kept
18/// cheap to clone because we pass it into handler bodies.
19#[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    /// Whether this user was seeded by a demo-fixture flow. Drives the
26    /// red banner in the admin UI; remains FALSE for users created via
27    /// the normal `create_user` path.
28    pub is_demo: bool,
29    pub demo_label: Option<String>,
30    /// Mirrors the `rustio_users.must_change_password` column added in
31    /// R1's recovery migration. When `TRUE`, R2's `login_guard`
32    /// (commit #13) redirects every authenticated request to
33    /// `/admin/must-change-password` until the user completes the
34    /// forced rotation, except for a small whitelist
35    /// (`/admin/must-change-password`, `/admin/logout`,
36    /// `/admin/account/sessions`). R1 emissions don't read this
37    /// field; this commit only loads it from the SQL paths so commits
38    /// #9 / #13 can act on it.
39    pub must_change_password: bool,
40    /// Mirrors the `rustio_users.mfa_enabled` column added in R3's
41    /// MFA migration (commit #1). When `TRUE`, the login flow
42    /// (commit #16) redirects to `/admin/mfa/verify` after
43    /// successful password verification, and R3's `login_guard`
44    /// extension (commit #18) restricts non-MFA-verified
45    /// sessions to a small whitelist
46    /// (`/admin/mfa/verify`, `/admin/logout`,
47    /// `/admin/account/sessions`). Pre-R3 sessions and users
48    /// who have not enrolled get `FALSE` and bypass the
49    /// challenge entirely — pre-R3 framework behaviour.
50    pub mfa_enabled: bool,
51    /// The active session's trust level
52    /// (`authenticated` / `elevated` / `mfa_verified`). R3's
53    /// `login_guard` (commit #18) reads this together with
54    /// `mfa_enabled` to gate the pending-MFA state: an
55    /// MFA-enrolled user whose current session has
56    /// `trust_level != mfa_verified` (i.e. just signed in,
57    /// hasn't yet completed `/admin/mfa/verify`) is restricted
58    /// to a tiny whitelist until they finish the second-factor
59    /// challenge. Pre-R3 sessions default to
60    /// `SessionTrust::Authenticated` from the schema's column
61    /// default — no migration data change.
62    pub trust_level: crate::auth::SessionTrust,
63}
64
65impl Identity {
66    // public:
67    /// Administrator-or-higher (Administrator, Developer).
68    pub fn is_admin(&self) -> bool {
69        self.is_active && self.role.includes(Role::Administrator)
70    }
71
72    // public:
73    /// Anyone allowed into the admin panel (Staff and above).
74    pub fn can_access_admin(&self) -> bool {
75        self.is_active && self.role.can_access_panel()
76    }
77}
78
79// public:
80pub 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    /// Mirrors `rustio_users.must_change_password`. The R2 login flow
89    /// (commit #9) reads this through `find_user_by_email` to decide
90    /// whether to set `must_change_password` on the freshly-issued
91    /// `Identity`; when the flag is set the user is redirected
92    /// immediately to `/admin/must-change-password` after sign-in.
93    pub must_change_password: bool,
94    /// Mirrors `rustio_users.mfa_enabled`. R3's `do_login`
95    /// (commit #16) reads this to decide whether to redirect
96    /// the freshly-authenticated user to `/admin/mfa/verify`
97    /// before allowing access to `/admin`.
98    pub mfa_enabled: bool,
99    /// Profile identity columns. All nullable in the DB; consumers
100    /// pick the first non-empty entry following the
101    /// `display_name → first_name → email-local-part → "there"`
102    /// fallback chain when rendering greetings. Surface examples:
103    /// recovery-email greeting, signature block, future
104    /// security-alert emails, operator-facing chrome.
105    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    // public:
113    /// Resolve the greeting label per the documented fallback:
114    /// `display_name → first_name → email-local-part → "there"`.
115    /// Always returns a non-empty string suitable for direct
116    /// interpolation into "Hello {x},".
117    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    // public:
140    /// "Best display name + role" pair used by the recovery
141    /// email's signature block. Falls back gracefully when the
142    /// profile fields are unset.
143    pub fn signature_lines(&self) -> (String, Option<String>) {
144        // Line 1: full name preferred — `first last`, otherwise
145        // display_name, otherwise email-local-part.
146        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// public:
179/// Read-only view of a user, used by the built-in admin profile page.
180/// Excludes `password_hash` deliberately. Construct via
181/// [`load_user_profile`].
182#[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
196// public:
197pub 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
219/// Idempotent schema upgrade for the 5-tier role hierarchy + demo + profile
220/// columns. Safe to call repeatedly; safe on a fresh DB and on a legacy
221/// `'admin'`-roled DB.
222///
223/// Order is load-bearing:
224/// 1. Rename existing `'admin'` rows to `'administrator'` BEFORE the CHECK
225///    constraint is added, otherwise the constraint would reject the row.
226/// 2. Add the demo columns idempotently.
227/// 3. Add the CHECK constraint conditionally (PG has no `IF NOT EXISTS`
228///    for CHECK constraints, so we guard via `pg_constraint`).
229/// 4. Add the indexes.
230/// 5. Add the profile-display columns.
231// public:
232pub 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    // Profile identity columns surfaced by recovery emails + chrome.
283    // All nullable — legacy installs continue to work without them.
284    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
300// public:
301pub 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
309// public:
310pub 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
319// public:
320pub 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        // Keep Postgres internals out of the client response. The full
334        // error stays in the operator's log; the user sees a clean,
335        // generic message — except the unique-email collision, which
336        // is worth surfacing because it's actionable.
337        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
351// public:
352pub 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
386// public:
387/// Load a user by id for display purposes. Returns `Ok(None)` for a
388/// missing id (callers map to 404). Returns `Err` only on a real DB
389/// failure or a corrupted role string. Never reads `password_hash`.
390pub 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
420/// Re-hash and write a new password for `user_id`. Stamps both
421/// `password_changed_at` and `updated_at` to the same `NOW()` —
422/// `password_changed_at` is the doctrine-7 surface ("Password last
423/// changed: 2 days ago") that the active-sessions UI reads; the
424/// existing `updated_at` continues to track row-level edits.
425///
426/// R1 (DESIGN_RECOVERY.md §14.1) introduced the
427/// `password_changed_at` write here so every code path that mutates a
428/// password — self-change, self-reset, R2 admin-driven reset, R4 CLI
429/// emergency reset — stamps the column without each caller having to
430/// remember. Pre-0.5.0 rows have `NULL` for the column; it populates
431/// on the next change.
432///
433/// Callers that need to invalidate sessions (per doctrine 22) do so
434/// separately by calling `auth::invalidate_sessions(...)` after this
435/// returns. This function deliberately does NOT call into the session
436/// engine: a CLI flow may want to keep sessions live, and the auth-
437/// driven self-change wants to keep the current device alive
438/// (`SessionTarget::UserExceptCurrent`). Wiring it in here would
439/// remove that flexibility.
440// public:
441pub 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
456// public:
457pub 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
467/// Pure verdict for the orphan check, factored out so it can be
468/// unit-tested without a `Db`. The async wrapper [`would_orphan_role`]
469/// supplies `active_count` and `target_is_protected` from SQL.
470///
471/// Returns `true` only when removing this user from the protected
472/// pool would empty it (count == 1 and the target user IS in that
473/// pool, AND the proposed new state would no longer satisfy
474/// membership).
475// public:
476pub 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    // The target IS the only active member. Block unless the proposed
489    // state keeps them in the same protected role and active.
490    !(new_active && new_role_is_protected)
491}
492
493/// Would the proposed change leave the system with zero active members
494/// of `protected_role`?
495///
496/// `new_role` / `new_active` describe the target row's proposed state:
497/// - delete: pass `new_active = false` (the row goes away).
498/// - role change: pass the new role.
499/// - deactivate: pass `new_active = false`.
500///
501/// Returns `true` only when:
502/// - exactly one active member of `protected_role` exists, AND
503/// - the target user IS that member, AND
504/// - the proposed state would remove them from the protected pool.
505// public:
506pub 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
535/// Walk every entry in [`super::role::protected_roles`] and return
536/// the first protected role whose membership would be orphaned by
537/// the proposed change. `None` means the change is safe.
538// public:
539pub 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/// Legacy alias preserved so external callers keep compiling. Prefer
554/// [`would_orphan_protected`] which generalises across every role in
555/// [`super::role::protected_roles`].
556// public:
557#[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
573// public:
574/// Verify credentials and create a session. Returns the session token
575/// to set in the cookie. A deliberately vague error on failure — we
576/// don't want to leak whether the email was valid.
577pub 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    // ---- verdict_for_orphan_role ----
608
609    #[test]
610    fn verdict_safe_when_target_not_in_protected_pool() {
611        // Target is Staff; we're checking Administrator orphan-ness.
612        // Even if active_count == 0 the change is irrelevant to that pool.
613        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        // Target IS the protected role, but there's a second active
621        // member — losing this one keeps the floor satisfied.
622        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        // active_count = 1, target IS that member, new state drops
629        // them out of the pool → block.
630        assert!(verdict_for_orphan_role(1, true, false, true));
631    }
632
633    #[test]
634    fn verdict_blocks_when_last_member_deactivating() {
635        // Same shape but new_active = false; new_role doesn't matter.
636        assert!(verdict_for_orphan_role(1, true, true, false));
637    }
638
639    #[test]
640    fn verdict_blocks_when_last_member_deleting() {
641        // Delete is modelled as new_active = false in the wrapper.
642        assert!(verdict_for_orphan_role(1, true, false, false));
643    }
644
645    #[test]
646    fn verdict_safe_when_last_member_keeps_role() {
647        // No-op save: still in pool, still active → safe.
648        assert!(!verdict_for_orphan_role(1, true, true, true));
649    }
650}