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