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