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}
100
101// public:
102/// Read-only view of a user, used by the built-in admin profile page.
103/// Excludes `password_hash` deliberately. Construct via
104/// [`load_user_profile`].
105#[derive(Debug, Clone)]
106pub struct UserProfile {
107    pub id: i64,
108    pub email: String,
109    pub role: Role,
110    pub is_active: bool,
111    pub created_at: DateTime<Utc>,
112    pub full_name: Option<String>,
113    pub locale: Option<String>,
114    pub timezone: Option<String>,
115    pub is_demo: bool,
116    pub demo_label: Option<String>,
117}
118
119// public:
120pub async fn init_user_tables(db: &Db) -> Result<()> {
121    sqlx::query(
122        "CREATE TABLE IF NOT EXISTS rustio_users (
123            id            BIGSERIAL PRIMARY KEY,
124            email         TEXT NOT NULL UNIQUE,
125            password_hash TEXT NOT NULL,
126            role          TEXT NOT NULL DEFAULT 'user',
127            is_active     BOOLEAN NOT NULL DEFAULT TRUE,
128            created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
129            updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
130        )",
131    )
132    .execute(db.pool())
133    .await?;
134
135    sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
136        .execute(db.pool())
137        .await?;
138
139    Ok(())
140}
141
142/// Idempotent schema upgrade for the 5-tier role hierarchy + demo + profile
143/// columns. Safe to call repeatedly; safe on a fresh DB and on a legacy
144/// `'admin'`-roled DB.
145///
146/// Order is load-bearing:
147/// 1. Rename existing `'admin'` rows to `'administrator'` BEFORE the CHECK
148///    constraint is added, otherwise the constraint would reject the row.
149/// 2. Add the demo columns idempotently.
150/// 3. Add the CHECK constraint conditionally (PG has no `IF NOT EXISTS`
151///    for CHECK constraints, so we guard via `pg_constraint`).
152/// 4. Add the indexes.
153/// 5. Add the profile-display columns.
154// public:
155pub async fn migrate_user_schema(db: &Db) -> Result<()> {
156    sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
157        .execute(db.pool())
158        .await?;
159
160    sqlx::query(
161        "ALTER TABLE rustio_users \
162         ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
163    )
164    .execute(db.pool())
165    .await?;
166    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
167        .execute(db.pool())
168        .await?;
169
170    sqlx::query(
171        "DO $$
172         BEGIN
173            IF NOT EXISTS (
174                SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
175            ) THEN
176                ALTER TABLE rustio_users
177                ADD CONSTRAINT rustio_users_role_check
178                CHECK (role IN ('user','staff','supervisor','administrator','developer'));
179            END IF;
180         END $$",
181    )
182    .execute(db.pool())
183    .await?;
184
185    sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
186        .execute(db.pool())
187        .await?;
188    sqlx::query(
189        "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
190         ON rustio_users(is_demo) WHERE is_demo = TRUE",
191    )
192    .execute(db.pool())
193    .await?;
194
195    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
196        .execute(db.pool())
197        .await?;
198    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
199        .execute(db.pool())
200        .await?;
201    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
202        .execute(db.pool())
203        .await?;
204
205    Ok(())
206}
207
208// public:
209pub fn hash_password(plain: &str) -> Result<String> {
210    let salt = SaltString::generate(&mut OsRng);
211    Argon2::default()
212        .hash_password(plain.as_bytes(), &salt)
213        .map(|h| h.to_string())
214        .map_err(|e| Error::Internal(format!("password hashing: {e}")))
215}
216
217// public:
218pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
219    match PasswordHash::new(stored_hash) {
220        Ok(parsed) => Argon2::default()
221            .verify_password(plain.as_bytes(), &parsed)
222            .is_ok(),
223        Err(_) => false,
224    }
225}
226
227// public:
228pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
229    let hash = hash_password(password)?;
230    let row = sqlx::query(
231        "INSERT INTO rustio_users (email, password_hash, role)
232         VALUES ($1, $2, $3)
233         RETURNING id",
234    )
235    .bind(email)
236    .bind(&hash)
237    .bind(role.as_str())
238    .fetch_one(db.pool())
239    .await
240    .map_err(|e| {
241        // Keep Postgres internals out of the client response. The full
242        // error stays in the operator's log; the user sees a clean,
243        // generic message — except the unique-email collision, which
244        // is worth surfacing because it's actionable.
245        log::warn!("create_user failed for {email}: {e}");
246        let detail = e.to_string();
247        if detail.contains("rustio_users_email_key") {
248            Error::BadRequest("An account with this email already exists.".into())
249        } else {
250            Error::BadRequest("Could not create user. Please check your input.".into())
251        }
252    })?;
253    let id: i64 = row
254        .try_get("id")
255        .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
256    Ok(id)
257}
258
259// public:
260pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
261    let row = sqlx::query(
262        "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
263                must_change_password, mfa_enabled \
264           FROM rustio_users \
265          WHERE email = $1",
266    )
267    .bind(email)
268    .fetch_optional(db.pool())
269    .await?;
270    match row {
271        Some(r) => {
272            let r = Row::from_pg(&r);
273            Ok(Some(StoredUser {
274                id: r.get_i64("id")?,
275                email: r.get_string("email")?,
276                password_hash: r.get_string("password_hash")?,
277                role: Role::parse(&r.get_string("role")?)?,
278                is_active: r.get_bool("is_active")?,
279                is_demo: r.get_bool("is_demo")?,
280                demo_label: r.get_optional_string("demo_label")?,
281                must_change_password: r.get_bool("must_change_password")?,
282                mfa_enabled: r.get_bool("mfa_enabled")?,
283            }))
284        }
285        None => Ok(None),
286    }
287}
288
289// public:
290/// Load a user by id for display purposes. Returns `Ok(None)` for a
291/// missing id (callers map to 404). Returns `Err` only on a real DB
292/// failure or a corrupted role string. Never reads `password_hash`.
293pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
294    let row = sqlx::query(
295        "SELECT id, email, role, is_active, created_at,
296                full_name, locale, timezone, is_demo, demo_label
297           FROM rustio_users
298          WHERE id = $1",
299    )
300    .bind(user_id)
301    .fetch_optional(db.pool())
302    .await?;
303    match row {
304        Some(r) => {
305            let r = Row::from_pg(&r);
306            Ok(Some(UserProfile {
307                id: r.get_i64("id")?,
308                email: r.get_string("email")?,
309                role: Role::parse(&r.get_string("role")?)?,
310                is_active: r.get_bool("is_active")?,
311                created_at: r.get_datetime("created_at")?,
312                full_name: r.get_optional_string("full_name")?,
313                locale: r.get_optional_string("locale")?,
314                timezone: r.get_optional_string("timezone")?,
315                is_demo: r.get_bool("is_demo")?,
316                demo_label: r.get_optional_string("demo_label")?,
317            }))
318        }
319        None => Ok(None),
320    }
321}
322
323/// Re-hash and write a new password for `user_id`. Stamps both
324/// `password_changed_at` and `updated_at` to the same `NOW()` —
325/// `password_changed_at` is the doctrine-7 surface ("Password last
326/// changed: 2 days ago") that the active-sessions UI reads; the
327/// existing `updated_at` continues to track row-level edits.
328///
329/// R1 (DESIGN_RECOVERY.md §14.1) introduced the
330/// `password_changed_at` write here so every code path that mutates a
331/// password — self-change, self-reset, R2 admin-driven reset, R4 CLI
332/// emergency reset — stamps the column without each caller having to
333/// remember. Pre-0.5.0 rows have `NULL` for the column; it populates
334/// on the next change.
335///
336/// Callers that need to invalidate sessions (per doctrine 22) do so
337/// separately by calling `auth::invalidate_sessions(...)` after this
338/// returns. This function deliberately does NOT call into the session
339/// engine: a CLI flow may want to keep sessions live, and the auth-
340/// driven self-change wants to keep the current device alive
341/// (`SessionTarget::UserExceptCurrent`). Wiring it in here would
342/// remove that flexibility.
343// public:
344pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
345    let hash = hash_password(new_password)?;
346    sqlx::query(
347        "UPDATE rustio_users \
348            SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
349          WHERE id = $3",
350    )
351    .bind(&hash)
352    .bind(Utc::now())
353    .bind(user_id)
354    .execute(db.pool())
355    .await?;
356    Ok(())
357}
358
359// public:
360pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
361    sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
362        .bind(role.as_str())
363        .bind(Utc::now())
364        .bind(user_id)
365        .execute(db.pool())
366        .await?;
367    Ok(())
368}
369
370/// Pure verdict for the orphan check, factored out so it can be
371/// unit-tested without a `Db`. The async wrapper [`would_orphan_role`]
372/// supplies `active_count` and `target_is_protected` from SQL.
373///
374/// Returns `true` only when removing this user from the protected
375/// pool would empty it (count == 1 and the target user IS in that
376/// pool, AND the proposed new state would no longer satisfy
377/// membership).
378// public:
379pub fn verdict_for_orphan_role(
380    active_count_in_protected: i64,
381    target_is_in_protected: bool,
382    new_role_is_protected: bool,
383    new_active: bool,
384) -> bool {
385    if !target_is_in_protected {
386        return false;
387    }
388    if active_count_in_protected != 1 {
389        return false;
390    }
391    // The target IS the only active member. Block unless the proposed
392    // state keeps them in the same protected role and active.
393    !(new_active && new_role_is_protected)
394}
395
396/// Would the proposed change leave the system with zero active members
397/// of `protected_role`?
398///
399/// `new_role` / `new_active` describe the target row's proposed state:
400/// - delete: pass `new_active = false` (the row goes away).
401/// - role change: pass the new role.
402/// - deactivate: pass `new_active = false`.
403///
404/// Returns `true` only when:
405/// - exactly one active member of `protected_role` exists, AND
406/// - the target user IS that member, AND
407/// - the proposed state would remove them from the protected pool.
408// public:
409pub async fn would_orphan_role(
410    db: &Db,
411    user_id: i64,
412    protected_role: Role,
413    new_role: Role,
414    new_active: bool,
415) -> Result<bool> {
416    let active_count: i64 = sqlx::query_scalar(
417        "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
418    )
419    .bind(protected_role.as_str())
420    .fetch_one(db.pool())
421    .await?;
422
423    let target_role_str: Option<String> =
424        sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
425            .bind(user_id)
426            .fetch_optional(db.pool())
427            .await?;
428    let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
429
430    Ok(verdict_for_orphan_role(
431        active_count,
432        target_is_in_protected,
433        new_role == protected_role,
434        new_active,
435    ))
436}
437
438/// Walk every entry in [`super::role::protected_roles`] and return
439/// the first protected role whose membership would be orphaned by
440/// the proposed change. `None` means the change is safe.
441// public:
442pub async fn would_orphan_protected(
443    db: &Db,
444    user_id: i64,
445    new_role: Role,
446    new_active: bool,
447) -> Result<Option<Role>> {
448    for &role in super::role::protected_roles() {
449        if would_orphan_role(db, user_id, role, new_role, new_active).await? {
450            return Ok(Some(role));
451        }
452    }
453    Ok(None)
454}
455
456/// Legacy alias preserved so external callers keep compiling. Prefer
457/// [`would_orphan_protected`] which generalises across every role in
458/// [`super::role::protected_roles`].
459// public:
460#[deprecated(
461    since = "0.3.0",
462    note = "use `would_orphan_protected` to cover every protected role, not just Developer"
463)]
464pub async fn would_orphan_developers(
465    db: &Db,
466    user_id: i64,
467    new_role: Option<Role>,
468) -> Result<bool> {
469    let (role, active) = match new_role {
470        Some(r) => (r, true),
471        None => (Role::User, false),
472    };
473    would_orphan_role(db, user_id, Role::Developer, role, active).await
474}
475
476// public:
477/// Verify credentials and create a session. Returns the session token
478/// to set in the cookie. A deliberately vague error on failure — we
479/// don't want to leak whether the email was valid.
480pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
481    let user = find_user_by_email(db, email)
482        .await?
483        .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
484    if !user.is_active {
485        return Err(Error::Forbidden("account disabled".into()));
486    }
487    if !verify_password(password, &user.password_hash) {
488        return Err(Error::Unauthorized("invalid email or password".into()));
489    }
490    create_session(db, user.id).await
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn user_profile_derives_debug_and_clone() {
499        fn assert_traits<T: std::fmt::Debug + Clone>() {}
500        assert_traits::<UserProfile>();
501    }
502
503    #[test]
504    fn password_round_trip() {
505        let h = hash_password("secret").unwrap();
506        assert!(verify_password("secret", &h));
507        assert!(!verify_password("wrong", &h));
508    }
509
510    // ---- verdict_for_orphan_role ----
511
512    #[test]
513    fn verdict_safe_when_target_not_in_protected_pool() {
514        // Target is Staff; we're checking Administrator orphan-ness.
515        // Even if active_count == 0 the change is irrelevant to that pool.
516        assert!(!verdict_for_orphan_role(0, false, false, true));
517        assert!(!verdict_for_orphan_role(1, false, false, false));
518        assert!(!verdict_for_orphan_role(5, false, true, true));
519    }
520
521    #[test]
522    fn verdict_safe_when_more_than_one_member() {
523        // Target IS the protected role, but there's a second active
524        // member — losing this one keeps the floor satisfied.
525        assert!(!verdict_for_orphan_role(2, true, false, true));
526        assert!(!verdict_for_orphan_role(5, true, false, false));
527    }
528
529    #[test]
530    fn verdict_blocks_when_last_member_demoting() {
531        // active_count = 1, target IS that member, new state drops
532        // them out of the pool → block.
533        assert!(verdict_for_orphan_role(1, true, false, true));
534    }
535
536    #[test]
537    fn verdict_blocks_when_last_member_deactivating() {
538        // Same shape but new_active = false; new_role doesn't matter.
539        assert!(verdict_for_orphan_role(1, true, true, false));
540    }
541
542    #[test]
543    fn verdict_blocks_when_last_member_deleting() {
544        // Delete is modelled as new_active = false in the wrapper.
545        assert!(verdict_for_orphan_role(1, true, false, false));
546    }
547
548    #[test]
549    fn verdict_safe_when_last_member_keeps_role() {
550        // No-op save: still in pool, still active → safe.
551        assert!(!verdict_for_orphan_role(1, true, true, true));
552    }
553}