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