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(¤t_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}