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