Skip to main content

rustio_admin/auth/
emergency.rs

1//! Emergency-recovery primitives — the framework-side surface called
2//! by the `rustio user <op>` CLI commands.
3//!
4//! Each function performs the atomic DB-mutation chain for one
5//! emergency operation and returns a typed outcome the CLI uses to
6//! render its post-mutation summary.
7//!
8//! ## D12 — CLI-only audit emission
9//!
10//! These functions deliberately do NOT write the
11//! `AuditEvent::EmergencyRecovery` row. That emission lives in the
12//! CLI crate (`crates/rustio-admin-cli/`), so the audit row's
13//! call-stack-of-record is always rooted in the CLI binary. The
14//! framework provides the DB transaction; the CLI provides the
15//! audit trail. The
16//! [`admin::audit::tests::emergency_recovery_is_cli_only`] unit
17//! test enforces this — adding an `AuditEvent::EmergencyRecovery`
18//! emission anywhere in `crates/rustio-admin/src/` fails the
19//! default test gate.
20//!
21//! ## D11 — atomic per command
22//!
23//! Each function that mutates more than one row runs its mutations
24//! inside a single sqlx transaction. Partial failures roll back so
25//! half-applied state never lands — e.g. a `reset_password` that
26//! hashed + stored the new password but then failed to flip
27//! `must_change_password` would leave the user in a worse state
28//! than before.
29//!
30//! ## Building blocks reused
31//!
32//! - [`crate::auth::users::hash_password`] for Argon2id-hashed
33//!   password storage
34//! - [`crate::auth::invalidate_sessions`] for revocation under
35//!   doctrine 22 (single writer of `revoked_at`)
36//!
37//! See `DESIGN_R4_EMERGENCY.md` §6 for the building-block table per
38//! operation and §3 for the locked scope.
39
40use crate::auth::sessions::{
41    invalidate_sessions, InvalidationOutcome, SessionInvalidationReason, SessionTarget,
42};
43use crate::auth::users::hash_password;
44use crate::auth::Role;
45use crate::error::{Error, Result};
46use crate::orm::Db;
47
48// ---- Outcomes ------------------------------------------------------------
49
50// public:
51/// Outcome of [`reset_password`]. The CLI uses this to compose the
52/// post-mutation summary line ("password set, N sessions revoked"
53/// or "user not found").
54#[derive(Debug, Clone)]
55pub enum ResetOutcome {
56    Ok { revoked_session_count: usize },
57    UnknownTarget,
58}
59
60// public:
61/// Outcome of [`unlock`].
62#[derive(Debug, Clone)]
63pub enum UnlockOutcome {
64    Ok { previously_locked: bool },
65    UnknownTarget,
66}
67
68// public:
69/// Outcome of [`disable_mfa`].
70#[derive(Debug, Clone)]
71pub enum DisableMfaOutcome {
72    Ok {
73        was_enabled: bool,
74        deleted_backup_codes: usize,
75        revoked_session_count: usize,
76    },
77    UnknownTarget,
78}
79
80// public:
81/// Outcome of [`promote`]. `SoleAdministratorDemoteRefused` is the
82/// only "refuse the operation outright" branch — the rest of the
83/// emergency surface allows action on any extant target (the
84/// operator already has DB access; the framework's role is
85/// audit-visibility, not gatekeeping).
86#[derive(Debug, Clone)]
87pub enum PromoteOutcome {
88    Ok {
89        previous_role: Role,
90        new_role: Role,
91        revoked_session_count: usize,
92    },
93    /// Target already carries `new_role`. No DB write, no session
94    /// revocation. The CLI surfaces this as a benign no-op.
95    NoChange {
96        current_role: Role,
97    },
98    UnknownTarget,
99    /// Demoting from `Administrator` would leave zero active
100    /// administrators. Refused per `DESIGN_R4_EMERGENCY.md` §3.4.
101    SoleAdministratorDemoteRefused,
102}
103
104// public:
105/// Outcome of [`emergency_access`].
106#[derive(Debug, Clone)]
107pub enum EmergencyAccessOutcome {
108    Ok {
109        token_id: i64,
110        url_path: String,
111        expires_at: chrono::DateTime<chrono::Utc>,
112    },
113    UnknownTarget,
114    InactiveTarget,
115}
116
117// ---- Helpers -------------------------------------------------------------
118
119/// Confirm the target row exists. Returns the `is_active` flag when
120/// present, `None` when no row matches. Used by every emergency
121/// function as the first gate (UnknownTarget short-circuit).
122async fn target_exists(db: &Db, user_id: i64) -> Result<Option<bool>> {
123    let row: Option<(bool,)> = sqlx::query_as("SELECT is_active FROM rustio_users WHERE id = $1")
124        .bind(user_id)
125        .fetch_optional(db.pool())
126        .await
127        .map_err(Error::from)?;
128    Ok(row.map(|(active,)| active))
129}
130
131// ---- reset_password ------------------------------------------------------
132
133/// Set a new password for `target_user_id`, raise
134/// `must_change_password = TRUE`, revoke every session for the user.
135///
136/// The CLI supplies `new_password` — either operator-provided via
137/// `--temp-password` or a CLI-generated random string. This function
138/// does not generate or echo the plaintext; the caller owns it and
139/// is responsible for displaying it exactly once.
140///
141/// Atomic: the password update + must-change flag flip + audit
142/// columns (`password_changed_at = NOW()`) land in one transaction.
143/// Session revocation runs after commit because
144/// [`invalidate_sessions`] is the single writer of `revoked_at`
145/// (doctrine 22) and runs its own atomic statement; a transaction
146/// boundary here keeps the password mutation isolated from the
147/// session sweep.
148// public:
149pub async fn reset_password(
150    db: &Db,
151    target_user_id: i64,
152    new_password: &str,
153) -> Result<ResetOutcome> {
154    if target_exists(db, target_user_id).await?.is_none() {
155        return Ok(ResetOutcome::UnknownTarget);
156    }
157
158    let hash = hash_password(new_password)?;
159
160    let mut tx = db.pool().begin().await.map_err(Error::from)?;
161    sqlx::query(
162        "UPDATE rustio_users \
163         SET password_hash = $1, \
164             password_changed_at = NOW(), \
165             must_change_password = TRUE \
166         WHERE id = $2",
167    )
168    .bind(&hash)
169    .bind(target_user_id)
170    .execute(&mut *tx)
171    .await
172    .map_err(Error::from)?;
173    tx.commit().await.map_err(Error::from)?;
174
175    let outcome = invalidate_sessions(
176        db,
177        SessionTarget::User {
178            user_id: target_user_id,
179        },
180        SessionInvalidationReason::PasswordResetByOther,
181    )
182    .await?;
183
184    Ok(ResetOutcome::Ok {
185        revoked_session_count: outcome.revoked_session_ids.len(),
186    })
187}
188
189// ---- unlock --------------------------------------------------------------
190
191/// Clear `locked_until` and reset `failed_login_count = 0` on the
192/// target. Does NOT touch sessions — an unlock is not a session
193/// event per `DESIGN_R4_EMERGENCY.md` §7.
194///
195/// Returns `previously_locked` so the CLI can render "the account
196/// was indeed locked, now cleared" vs. "the account was already
197/// open, this was a no-op" without an extra round-trip.
198// public:
199pub async fn unlock(db: &Db, target_user_id: i64) -> Result<UnlockOutcome> {
200    if target_exists(db, target_user_id).await?.is_none() {
201        return Ok(UnlockOutcome::UnknownTarget);
202    }
203
204    // Capture the pre-state so the outcome can distinguish "no-op"
205    // from "actually unlocked." Both rows still write — clearing
206    // an already-clear column is benign.
207    let was_locked: bool = sqlx::query_scalar(
208        "SELECT (locked_until IS NOT NULL AND locked_until > NOW()) \
209                OR failed_login_count > 0 \
210         FROM rustio_users WHERE id = $1",
211    )
212    .bind(target_user_id)
213    .fetch_one(db.pool())
214    .await
215    .map_err(Error::from)?;
216
217    sqlx::query(
218        "UPDATE rustio_users \
219         SET locked_until = NULL, failed_login_count = 0 \
220         WHERE id = $1",
221    )
222    .bind(target_user_id)
223    .execute(db.pool())
224    .await
225    .map_err(Error::from)?;
226
227    Ok(UnlockOutcome::Ok {
228        previously_locked: was_locked,
229    })
230}
231
232// ---- disable_mfa ---------------------------------------------------------
233
234/// Clear every MFA column on the target user, delete every backup-
235/// code row, revoke every session for the user.
236///
237/// **Session-revocation scope.** `DESIGN_R4_EMERGENCY.md` §7 calls
238/// for revoking only sessions with `trust_level = 'mfa_verified'`
239/// (other sessions stay valid). The current [`SessionTarget`] enum
240/// has no trust-level filter; rather than introduce a new variant
241/// in commit #3, this function revokes ALL of the target's sessions
242/// via `SessionTarget::User`. The over-broad revoke is conservative
243/// — every revoked session forces a fresh login that picks up the
244/// post-disable MFA state cleanly. A future
245/// `SessionTarget::UserWithTrustLevel` variant could narrow this
246/// without changing the function's caller contract.
247///
248/// Atomic: the column clear + backup-code DELETE land in one
249/// transaction. Session revocation runs after commit.
250// public:
251pub async fn disable_mfa(db: &Db, target_user_id: i64) -> Result<DisableMfaOutcome> {
252    if target_exists(db, target_user_id).await?.is_none() {
253        return Ok(DisableMfaOutcome::UnknownTarget);
254    }
255
256    let was_enabled: bool =
257        sqlx::query_scalar("SELECT COALESCE(mfa_enabled, FALSE) FROM rustio_users WHERE id = $1")
258            .bind(target_user_id)
259            .fetch_one(db.pool())
260            .await
261            .map_err(Error::from)?;
262
263    let mut tx = db.pool().begin().await.map_err(Error::from)?;
264
265    sqlx::query(
266        "UPDATE rustio_users SET \
267            mfa_enabled = FALSE, \
268            mfa_secret_ciphertext = NULL, \
269            mfa_secret_key_id = NULL, \
270            mfa_last_used_step = NULL \
271         WHERE id = $1",
272    )
273    .bind(target_user_id)
274    .execute(&mut *tx)
275    .await
276    .map_err(Error::from)?;
277
278    let deleted: u64 = sqlx::query("DELETE FROM rustio_mfa_backup_codes WHERE user_id = $1")
279        .bind(target_user_id)
280        .execute(&mut *tx)
281        .await
282        .map_err(Error::from)?
283        .rows_affected();
284
285    tx.commit().await.map_err(Error::from)?;
286
287    let outcome = invalidate_sessions(
288        db,
289        SessionTarget::User {
290            user_id: target_user_id,
291        },
292        SessionInvalidationReason::MfaDisabledByOther,
293    )
294    .await?;
295
296    Ok(DisableMfaOutcome::Ok {
297        was_enabled,
298        deleted_backup_codes: deleted as usize,
299        revoked_session_count: outcome.revoked_session_ids.len(),
300    })
301}
302
303// ---- promote -------------------------------------------------------------
304
305/// Change the target user's role to `new_role`.
306///
307/// Refuses to demote the sole active administrator: if the target
308/// currently holds `Role::Administrator` AND `new_role !=
309/// Administrator` AND no OTHER active administrators exist, returns
310/// [`PromoteOutcome::SoleAdministratorDemoteRefused`]. This guard
311/// is per `DESIGN_R4_EMERGENCY.md` §3.4 — the framework refuses to
312/// leave the deployment with zero administrators, even via CLI.
313///
314/// Atomic: the role-write + session-revoke are in one transaction
315/// to preserve doctrine 22 single-writer semantics while keeping
316/// the promote operation isolated from concurrent session reads.
317/// Session revocation runs after commit per the
318/// `invalidate_sessions` contract.
319// public:
320pub async fn promote(db: &Db, target_user_id: i64, new_role: Role) -> Result<PromoteOutcome> {
321    let row: Option<(String, bool)> =
322        sqlx::query_as("SELECT role, is_active FROM rustio_users WHERE id = $1")
323            .bind(target_user_id)
324            .fetch_optional(db.pool())
325            .await
326            .map_err(Error::from)?;
327    let (current_role_str, _is_active) = match row {
328        Some(r) => r,
329        None => return Ok(PromoteOutcome::UnknownTarget),
330    };
331
332    let current_role = parse_role(&current_role_str).unwrap_or(Role::User);
333    if current_role == new_role {
334        return Ok(PromoteOutcome::NoChange { current_role });
335    }
336
337    // Sole-administrator demote check. `is_active = TRUE` is the
338    // pre-condition for an administrator to be usable; demoting to
339    // a tier below Administrator must leave at least one active
340    // administrator standing.
341    if current_role == Role::Administrator && new_role != Role::Administrator {
342        let other_admins: i64 = sqlx::query_scalar(
343            "SELECT COUNT(*) FROM rustio_users \
344             WHERE role = 'administrator' AND is_active = TRUE AND id <> $1",
345        )
346        .bind(target_user_id)
347        .fetch_one(db.pool())
348        .await
349        .map_err(Error::from)?;
350        if other_admins == 0 {
351            return Ok(PromoteOutcome::SoleAdministratorDemoteRefused);
352        }
353    }
354
355    sqlx::query("UPDATE rustio_users SET role = $1 WHERE id = $2")
356        .bind(role_as_str(new_role))
357        .bind(target_user_id)
358        .execute(db.pool())
359        .await
360        .map_err(Error::from)?;
361
362    let outcome = invalidate_sessions(
363        db,
364        SessionTarget::User {
365            user_id: target_user_id,
366        },
367        SessionInvalidationReason::RoleChangedByOther,
368    )
369    .await?;
370
371    Ok(PromoteOutcome::Ok {
372        previous_role: current_role,
373        new_role,
374        revoked_session_count: outcome.revoked_session_ids.len(),
375    })
376}
377
378// ---- emergency_access ----------------------------------------------------
379
380/// Issue a single-use password-reset URL bypassing the email
381/// mailer. The URL plaintext is returned in
382/// [`EmergencyAccessOutcome::Ok::url_path`] — the operator hands it
383/// to the target via whatever out-of-band channel makes sense.
384///
385/// Reuses R1's `rustio_password_reset_tokens` table: inserts a row
386/// keyed by SHA-256(token), returns the plaintext token in the URL
387/// path. The plaintext never lands in the DB; the token is single-
388/// use per the R1 partial unique index on
389/// `(token_hash) WHERE consumed_at IS NULL`.
390///
391/// `ttl_minutes` is clamped to `[1, 60]` — beyond 60 the operator
392/// should use `reset_password` instead (longer TTLs widen the URL
393/// interception window for diminishing operational benefit).
394///
395/// `InactiveTarget` is the only emergency operation that refuses
396/// inactive users: issuing a URL to a deactivated account has no
397/// recovery semantic.
398// public:
399pub async fn emergency_access(
400    db: &Db,
401    target_user_id: i64,
402    ttl_minutes: i64,
403) -> Result<EmergencyAccessOutcome> {
404    let is_active = match target_exists(db, target_user_id).await? {
405        Some(active) => active,
406        None => return Ok(EmergencyAccessOutcome::UnknownTarget),
407    };
408    if !is_active {
409        return Ok(EmergencyAccessOutcome::InactiveTarget);
410    }
411
412    let ttl = ttl_minutes.clamp(1, 60);
413    let expires_at = chrono::Utc::now() + chrono::Duration::minutes(ttl);
414
415    // Reuse R1's exact token + hash format. Hand-rolling either
416    // here would risk a format drift that bricks the URL on the
417    // consume path — see commit #8 smoke where a hex-encoded hash
418    // missed the base64-encoded R1 lookup and the framework
419    // rendered "This link is no longer valid". Calling these
420    // crate-internal helpers ensures the emergency-access URL
421    // round-trips through the same machinery as a self-service
422    // R1 reset URL.
423    let token = crate::auth::sessions::random_token();
424    let token_hash = crate::auth::sessions::hash_token_for_storage(&token);
425
426    let token_id: i64 = sqlx::query_scalar(
427        "INSERT INTO rustio_password_reset_tokens \
428            (user_id, token_hash, expires_at) \
429         VALUES ($1, $2, $3) \
430         RETURNING id",
431    )
432    .bind(target_user_id)
433    .bind(&token_hash)
434    .bind(expires_at)
435    .fetch_one(db.pool())
436    .await
437    .map_err(Error::from)?;
438
439    Ok(EmergencyAccessOutcome::Ok {
440        token_id,
441        url_path: format!("/admin/reset-password/{token}"),
442        expires_at,
443    })
444}
445
446// ---- Role <-> string helpers --------------------------------------------
447//
448// `Role` already implements `FromStr` / `Display` somewhere in the
449// framework, but the persisted shape (`'administrator'`, lowercase
450// snake-case-ish) is what we need here and not all of those impls
451// match. Keep the mapping explicit and local until R5 unifies the
452// role-string surface.
453
454fn parse_role(s: &str) -> Option<Role> {
455    match s {
456        "user" => Some(Role::User),
457        "staff" => Some(Role::Staff),
458        "supervisor" => Some(Role::Supervisor),
459        "administrator" => Some(Role::Administrator),
460        "developer" => Some(Role::Developer),
461        _ => None,
462    }
463}
464
465fn role_as_str(role: Role) -> &'static str {
466    match role {
467        Role::User => "user",
468        Role::Staff => "staff",
469        Role::Supervisor => "supervisor",
470        Role::Administrator => "administrator",
471        Role::Developer => "developer",
472    }
473}
474
475// `InvalidationOutcome` is re-exported by `auth::mod` already; this
476// `use` keeps the type name reachable in callers that match on it.
477#[allow(unused_imports)]
478use InvalidationOutcome as _;
479
480// ---- CLI-side helpers (called from rustio-admin-cli) --------------------
481
482/// Generate an alphanumeric temp password of `len` characters. The
483/// alphabet excludes visually ambiguous glyphs (`0`/`O`, `1`/`l`/`I`)
484/// so an operator reading the password aloud or writing it down by
485/// hand has fewer transcription errors. Used by
486/// `rustio user reset-password` when `--temp-password` is not
487/// supplied.
488///
489/// The generated value is NOT stored anywhere by this function —
490/// the caller passes it through [`reset_password`] which Argon2-
491/// hashes it for `rustio_users.password_hash` and then prints the
492/// plaintext exactly once.
493// public:
494pub fn generate_temp_password(len: usize) -> String {
495    use rand::Rng;
496    // 54 chars: A-Z minus I, O, L; a-z minus i, l, o; 2-9 (no 0 or 1).
497    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
498    let mut rng = rand::thread_rng();
499    (0..len)
500        .map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char)
501        .collect()
502}
503
504/// Produce a fresh hyphenated UUID v7 for use as the CLI-emitted
505/// audit row's `correlation_id`. Matches the format the framework's
506/// `correlation_id` middleware writes per request, so a future
507/// cross-table audit pivot can join framework rows and CLI rows on
508/// this column without per-source post-processing.
509// public:
510pub fn fresh_correlation_id() -> String {
511    uuid::Uuid::now_v7().hyphenated().to_string()
512}