rustio_admin/auth/emergency.rs
1//! Emergency-recovery primitives — the framework-side surface called
2//! by the `rustio-admin 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(¤t_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-admin 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}