rustio_admin/admin/audit.rs
1//! Admin action log — every create / update / delete driven through
2//! the admin writes a row to `rustio_admin_actions`. The audit trail
3//! powers two user-visible surfaces:
4//!
5//! - `GET /admin/history` — project-wide timeline.
6//! - `GET /admin/<model>/<id>/history` — per-object history.
7//!
8//! ## Integrity
9//!
10//! [`record`] rejects entries that are missing any of `user_id`,
11//! `model_name`, or `object_id`. The caller gets an
12//! [`Error::Internal`] so the admin handler can fail loudly rather
13//! than silently losing the audit trail.
14
15use chrono::{DateTime, Utc};
16use sqlx::Row as _;
17
18use crate::error::{Error, Result};
19use crate::orm::Db;
20
21pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
22 id BIGSERIAL PRIMARY KEY,
23 user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
24 action_type TEXT NOT NULL,
25 model_name TEXT NOT NULL,
26 object_id BIGINT NOT NULL,
27 timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28 ip_address TEXT,
29 summary TEXT NOT NULL DEFAULT ''
30)";
31
32pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
33 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
34 ON rustio_admin_actions(model_name, object_id)";
35
36pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
37 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
38 ON rustio_admin_actions(timestamp DESC)";
39
40/// Ensure the `rustio_admin_actions` table and its indexes exist.
41/// Idempotent. Depends on `rustio_users` existing first.
42///
43/// 0.4.0 lifecycle additions: `metadata` JSONB, `correlation_id`, and
44/// `session_id`. The framework will populate these as recovery flows
45/// land in R1+; existing audit rows from 0.3.x stay valid with NULLs.
46pub async fn ensure_table(db: &Db) -> Result<()> {
47 sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
48 sqlx::query(CREATE_MODEL_INDEX_SQL)
49 .execute(db.pool())
50 .await?;
51 sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
52 .execute(db.pool())
53 .await?;
54
55 // R0 (0.4.0) lifecycle additions — additive, idempotent.
56 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS metadata JSONB")
57 .execute(db.pool())
58 .await?;
59 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS correlation_id TEXT")
60 .execute(db.pool())
61 .await?;
62 sqlx::query("ALTER TABLE rustio_admin_actions ADD COLUMN IF NOT EXISTS session_id BIGINT")
63 .execute(db.pool())
64 .await?;
65 sqlx::query(
66 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_correlation_idx \
67 ON rustio_admin_actions (correlation_id) WHERE correlation_id IS NOT NULL",
68 )
69 .execute(db.pool())
70 .await?;
71 sqlx::query(
72 "CREATE INDEX IF NOT EXISTS rustio_admin_actions_session_idx \
73 ON rustio_admin_actions (session_id) WHERE session_id IS NOT NULL",
74 )
75 .execute(db.pool())
76 .await?;
77
78 Ok(())
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ActionType {
83 Create,
84 Update,
85 Delete,
86}
87
88impl ActionType {
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Self::Create => "create",
92 Self::Update => "update",
93 Self::Delete => "delete",
94 }
95 }
96
97 pub fn parse(s: &str) -> Option<Self> {
98 match s {
99 "create" => Some(Self::Create),
100 "update" => Some(Self::Update),
101 "delete" => Some(Self::Delete),
102 _ => None,
103 }
104 }
105
106 pub fn label(self) -> &'static str {
107 match self {
108 Self::Create => "Created",
109 Self::Update => "Updated",
110 Self::Delete => "Deleted",
111 }
112 }
113
114 pub fn pill_class(self) -> &'static str {
115 match self {
116 Self::Create => "badge-success",
117 Self::Update => "badge-neutral",
118 Self::Delete => "badge-danger",
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
124pub struct AdminAction {
125 pub id: i64,
126 pub user_id: i64,
127 pub user_email: Option<String>,
128 pub action_type: String,
129 pub model_name: String,
130 pub object_id: i64,
131 pub timestamp: DateTime<Utc>,
132 pub ip_address: Option<String>,
133 pub summary: String,
134}
135
136pub struct LogEntry<'a> {
137 pub user_id: i64,
138 pub action_type: ActionType,
139 pub model_name: &'a str,
140 pub object_id: i64,
141 pub ip_address: Option<&'a str>,
142 pub summary: String,
143 /// Per-request UUID (R0). All audit rows written under one HTTP
144 /// request share this id so a future `/admin/history/<id>` page
145 /// can reconstruct the chain of events ("admin reset password →
146 /// all sessions revoked → security email dispatched").
147 pub correlation_id: Option<&'a str>,
148 /// The session that performed the action, when applicable. CLI
149 /// emergency actions write `None`.
150 pub session_id: Option<i64>,
151 /// Structured before/after / extra metadata. JSONB column.
152 /// R2 emissions populate this with an *object* (not a scalar
153 /// or array) — the merge layer in [`record`] inserts
154 /// `actor_user_id` and similar typed sidecars into the object
155 /// before persistence, and a non-object value disables that
156 /// merge (the row still writes; a warning is logged).
157 pub metadata: Option<serde_json::Value>,
158 /// The acting principal when this row records one user
159 /// performing an action on another (admin password reset,
160 /// admin lock/unlock, admin revoke-sessions, etc.). Persisted
161 /// under `metadata.actor_user_id` — the column itself doesn't
162 /// change.
163 ///
164 /// R2 emissions set this via [`LogEntry::with_actor`]; R0/R1
165 /// emissions leave it `None`. The legacy [`Self::user_id`]
166 /// field continues to carry the actor for backwards-compat
167 /// with `/admin/history`'s "who did what" view; `actor_user_id`
168 /// is the typed mirror that lets metadata consumers (SIEM,
169 /// future per-user audit pivots) read the actor without
170 /// relying on heuristics about which row-shape sets
171 /// `user_id` to actor vs. target.
172 ///
173 /// When `Some(id)` is set and `metadata` is also `Some(obj)`,
174 /// [`record`] inserts the key into the object. If the existing
175 /// metadata already contains `actor_user_id`, the typed-field
176 /// value wins — the struct field is the source of truth.
177 pub actor_user_id: Option<i64>,
178 /// When `Some`, supersedes `action_type.as_str()` as the
179 /// persisted `rustio_admin_actions.action_type` string. Set via
180 /// [`LogEntry::with_event`]; the `action_type` field becomes a
181 /// placeholder in that case (the convention is to pass
182 /// `ActionType::Update`). Used by R1+ recovery / authority /
183 /// identity emissions that need the richer typed vocabulary —
184 /// see `DESIGN_AUDIT.md` §3 + `DESIGN_RECOVERY.md` §6.
185 pub event: Option<AuditEvent>,
186}
187
188impl<'a> LogEntry<'a> {
189 /// Builder helper for the common case (every field that R0
190 /// added defaults to `None`). Existing call sites can migrate
191 /// incrementally.
192 pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
193 Self {
194 user_id,
195 action_type,
196 model_name,
197 object_id,
198 ip_address: None,
199 summary: String::new(),
200 correlation_id: None,
201 session_id: None,
202 metadata: None,
203 actor_user_id: None,
204 event: None,
205 }
206 }
207
208 /// Mark this entry as an admin acting on another user. The id
209 /// is persisted under `metadata.actor_user_id` by [`record`],
210 /// not as a separate column. Pair with `.with_event(...)` for
211 /// the canonical R2 admin-action shape, e.g.
212 ///
213 /// ```ignore
214 /// LogEntry::new(target_id, ActionType::Update, "user", target_id)
215 /// .with_event(AuditEvent::PasswordResetByOther)
216 /// .with_actor(admin_identity.user_id)
217 /// ```
218 ///
219 /// Auto-throttle (no actor) leaves this `None`; the row's
220 /// `user_id` column carries the affected user as the
221 /// closest-reasonable subject. See `DESIGN_R2_ORGANISATIONAL.md`
222 /// §5.2.
223 pub fn with_actor(mut self, actor_user_id: i64) -> Self {
224 self.actor_user_id = Some(actor_user_id);
225 self
226 }
227
228 /// Promote this entry's persisted `action_type` string from the
229 /// legacy [`ActionType`] (create/update/delete) trio to the
230 /// richer typed [`AuditEvent`]. The `action_type` field becomes
231 /// a placeholder; the convention is to pass `ActionType::Update`
232 /// to [`Self::new`] and chain `.with_event(...)`.
233 ///
234 /// ```ignore
235 /// let entry = LogEntry::new(user_id, ActionType::Update, "user", user_id)
236 /// .with_event(AuditEvent::PasswordChangedSelf);
237 /// ```
238 ///
239 /// Use this for framework-internal authority + identity +
240 /// recovery audit rows per `DESIGN_AUDIT.md` §3 +
241 /// `DESIGN_RECOVERY.md` §6. Project code that records generic
242 /// CRUD on its own models continues to use [`Self::new`] alone
243 /// with the legacy `ActionType` trio.
244 pub fn with_event(mut self, event: AuditEvent) -> Self {
245 self.event = Some(event);
246 self
247 }
248
249 /// Resolve the persisted `action_type` string. The `event`
250 /// override wins when set; otherwise the legacy `action_type`
251 /// trio's lowercase string is used. Pulled out as a small helper
252 /// so the `record()` insert and any future read-side rendering
253 /// share one resolution rule.
254 pub(crate) fn resolved_action_type(&self) -> &'static str {
255 match self.event {
256 Some(e) => e.as_str(),
257 None => self.action_type.as_str(),
258 }
259 }
260}
261
262/// Merge `actor_user_id` into the persisted `metadata` JSONB column.
263///
264/// - When `actor_user_id` is `None`: returns `metadata` unchanged.
265/// - When `actor_user_id` is `Some(id)` and `metadata` is `None`:
266/// synthesizes `{"actor_user_id": id}`.
267/// - When `actor_user_id` is `Some(id)` and `metadata` is
268/// `Some(object)`: inserts the key, replacing any existing
269/// `actor_user_id` (the typed field wins — it is the source of
270/// truth for the actor association).
271/// - When `actor_user_id` is `Some(id)` and `metadata` is
272/// `Some(scalar | array)`: returns the original metadata
273/// unchanged and emits a `log::warn!`. The merge requires an
274/// object; non-object metadata is a programming error and the
275/// audit row is preferable to silent loss.
276///
277/// Pulled out as a free function so the merge contract is
278/// unit-testable without a database.
279fn build_persisted_metadata(
280 metadata: Option<serde_json::Value>,
281 actor_user_id: Option<i64>,
282) -> Option<serde_json::Value> {
283 let actor = match actor_user_id {
284 None => return metadata,
285 Some(id) => id,
286 };
287
288 match metadata {
289 None => Some(serde_json::json!({ "actor_user_id": actor })),
290 Some(mut value) => {
291 if let Some(obj) = value.as_object_mut() {
292 obj.insert("actor_user_id".to_string(), serde_json::json!(actor));
293 Some(value)
294 } else {
295 log::warn!(
296 "audit::record: actor_user_id={} set but metadata is not a JSON object \
297 ({:?}); writing row without merging actor — fix the call site",
298 actor,
299 value
300 );
301 Some(value)
302 }
303 }
304 }
305}
306
307/// Write one row to the action log. Validates required fields before
308/// touching the DB so a broken audit pipeline becomes visible.
309pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
310 if entry.user_id <= 0 {
311 return Err(Error::Internal("admin audit: missing user_id".to_string()));
312 }
313 if entry.model_name.trim().is_empty() {
314 return Err(Error::Internal(
315 "admin audit: missing model_name".to_string(),
316 ));
317 }
318 if entry.object_id <= 0 {
319 return Err(Error::Internal(
320 "admin audit: missing object_id".to_string(),
321 ));
322 }
323
324 let now = Utc::now();
325 let action_type_str = entry.resolved_action_type();
326 let metadata = build_persisted_metadata(entry.metadata, entry.actor_user_id);
327 sqlx::query(
328 "INSERT INTO rustio_admin_actions
329 (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
330 correlation_id, session_id, metadata)
331 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
332 )
333 .bind(entry.user_id)
334 .bind(action_type_str)
335 .bind(entry.model_name)
336 .bind(entry.object_id)
337 .bind(now)
338 .bind(entry.ip_address)
339 .bind(&entry.summary)
340 .bind(entry.correlation_id)
341 .bind(entry.session_id)
342 .bind(metadata.as_ref())
343 .execute(db.pool())
344 .await?;
345 Ok(())
346}
347
348/// Typed representation of every audit `action_type` the framework
349/// emits for authority + identity + recovery actions.
350///
351/// **Public-API stability (0.5.0):** the enum is `pub` from R1
352/// onwards (doctrine 18). External consumers — SIEM tooling, custom
353/// dashboards, integration tests — can match on these variants
354/// instead of (or in addition to) the persisted strings. The
355/// `as_str()` mapping is the single canonical boundary between the
356/// typed surface and the `rustio_admin_actions.action_type` TEXT
357/// column. Every existing variant's string is locked-in by the
358/// `audit_event_existing_variants_have_stable_strings` test below;
359/// renaming a string is a breaking change requiring a major version
360/// bump.
361///
362/// **Coexistence with `ActionType`:** the legacy
363/// `ActionType::{Create, Update, Delete}` trio writes the strings
364/// `"create" / "update" / "delete"`, used for generic CRUD on
365/// project-registered models. `AuditEvent` strings are richer
366/// (`"user_created"`, `"password_reset_self_consume"`, …) and used
367/// for the framework's own authority + identity + recovery surfaces.
368/// The two vocabularies are disjoint by design;
369/// `action_type_and_audit_event_vocabularies_dont_collide` asserts
370/// the disjointness.
371///
372/// **Future-extensibility:** `#[non_exhaustive]` lets future
373/// R-phases (R2 / R3 / R4) add variants without breaking external
374/// matchers. Variants whose call-sites haven't shipped yet are
375/// listed here in anticipation — `as_str()` returns the canonical
376/// string regardless of whether anything emits it. The roadmap
377/// in `DESIGN_RECOVERY.md` §16 + `ROADMAP.md` covers when each
378/// variant lights up.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
380#[non_exhaustive]
381pub enum AuditEvent {
382 // ---- User / Group authority CRUD (R0+) ----
383 UserCreated,
384 UserUpdated,
385 UserDeleted,
386 GroupCreated,
387 GroupUpdated,
388 GroupDeleted,
389 // ---- Password lifecycle (R1+) ----
390 /// Authenticated user changed their own password via
391 /// `/admin/password_change`. R1 commit #11 wires emission.
392 PasswordChangedSelf,
393 /// Anonymous user requested a password-reset email via
394 /// `/admin/forgot-password`. R1 commit #7 wires emission.
395 PasswordResetSelfRequest,
396 /// Anonymous user consumed a reset token + set a new password
397 /// via `/admin/reset-password/<token>`. R1 commit #7 wires
398 /// emission.
399 PasswordResetSelfConsume,
400 /// An administrator reset another user's password. R2 wires
401 /// emission via the dedicated `/admin/users/<id>/reset-password`
402 /// route.
403 PasswordResetByOther,
404 /// A user with `must_change_password = TRUE` completed the
405 /// forced rotation via `POST /admin/must-change-password`,
406 /// clearing the flag and (per `DESIGN_R2_ORGANISATIONAL.md`
407 /// §3.4) revoking every other session for the same user.
408 /// `metadata.triggered_by_audit_id` links back to the
409 /// originating `PasswordResetByOther` row;
410 /// `metadata.invalidated_session_count` records how many
411 /// sessions were revoked. R2 commit #12 wires emission.
412 ForcedPasswordChangeCompleted,
413 // ---- Account state (R2+) ----
414 AccountLocked,
415 AccountUnlocked,
416 // ---- MFA (R3+) ----
417 MfaEnabled,
418 MfaDisabled,
419 MfaResetByOther,
420 /// A user consumed a backup code as the second factor on
421 /// the login or re-auth flow. Emitted by
422 /// `auth::mfa::consume_backup_code` (R3 commit #8).
423 /// `metadata.code_id` identifies which code was used;
424 /// `metadata.remaining_codes` tracks the unused-code count
425 /// post-consume so the user can be nudged toward
426 /// regeneration before exhaustion;
427 /// `metadata.via = "login" | "reauth"` distinguishes the
428 /// caller context.
429 MfaCodeConsumed,
430 /// A user regenerated their backup-code batch. Emitted by
431 /// `auth::mfa::regenerate_backup_codes` (R3 commit #10).
432 /// The DELETE + INSERT runs in one transaction per D3 of
433 /// the design (atomic regeneration; the old batch is
434 /// unrecoverable from the moment the transaction commits).
435 /// `metadata.previous_codes_invalidated` records the
436 /// count of rows the DELETE removed (used + unused
437 /// combined); `metadata.new_codes_count` is locked at 8
438 /// per Appendix B.
439 BackupCodesRegenerated,
440 // ---- Session lifecycle (R0/R1+) ----
441 SessionsRevokedSelf,
442 SessionsRevokedByOther,
443 SessionLogout,
444 // ---- Layer-3 CLI (R4+) ----
445 /// Emergency-recovery operation initiated from the `rustio`
446 /// CLI binary. Subsumes every `rustio user <op>` emergency
447 /// subcommand — the specific operation lives in
448 /// `metadata.cli_operation` (`"reset_password" | "unlock" |
449 /// "disable_mfa" | "promote" | "emergency_access"`). The
450 /// OS-level actor identity lives in `metadata.os_actor`
451 /// (`<whoami>@<hostname>`).
452 ///
453 /// **CLI-only invariant (D12).** This variant must NOT be
454 /// emitted from any framework HTTP handler. The web surface
455 /// has its own audit variants (`PasswordResetByOther`,
456 /// `MfaResetByOther`, etc.); collapsing those into
457 /// `EmergencyRecovery` would lose the distinction between
458 /// "admin acted via the web" and "operator went around every
459 /// other tier via shell." A grep-based unit test (see
460 /// `audit::tests::emergency_recovery_is_cli_only`) enforces
461 /// this by walking `crates/rustio-admin/src/` and asserting
462 /// zero callsites; the variant is exercised only from
463 /// `crates/rustio-admin-cli/src/`.
464 ///
465 /// See `DESIGN_R4_EMERGENCY.md` §5 for the full metadata
466 /// schema and §10 doctrine D12.
467 EmergencyRecovery,
468}
469
470impl AuditEvent {
471 /// Stable lowercase identifier persisted as
472 /// `rustio_admin_actions.action_type`.
473 ///
474 /// **Stability contract:** every string returned here is
475 /// part of the public API from 0.5.0 onwards. Existing values
476 /// are locked-in by
477 /// `audit_event_existing_variants_have_stable_strings` and
478 /// changing one is a breaking change requiring a major bump.
479 /// New `AuditEvent` variants may be added in minor versions
480 /// (the enum is `#[non_exhaustive]`); each new variant ships
481 /// with its locked string from day one.
482 pub const fn as_str(self) -> &'static str {
483 match self {
484 Self::UserCreated => "user_created",
485 Self::UserUpdated => "user_updated",
486 Self::UserDeleted => "user_deleted",
487 Self::GroupCreated => "group_created",
488 Self::GroupUpdated => "group_updated",
489 Self::GroupDeleted => "group_deleted",
490 Self::PasswordChangedSelf => "password_changed_self",
491 Self::PasswordResetSelfRequest => "password_reset_self_request",
492 Self::PasswordResetSelfConsume => "password_reset_self_consume",
493 Self::PasswordResetByOther => "password_reset_by_other",
494 Self::ForcedPasswordChangeCompleted => "forced_password_change_completed",
495 Self::AccountLocked => "account_locked",
496 Self::AccountUnlocked => "account_unlocked",
497 Self::MfaEnabled => "mfa_enabled",
498 Self::MfaDisabled => "mfa_disabled",
499 Self::MfaResetByOther => "mfa_reset_by_other",
500 Self::MfaCodeConsumed => "mfa_code_consumed",
501 Self::BackupCodesRegenerated => "backup_codes_regenerated",
502 Self::SessionsRevokedSelf => "sessions_revoked_self",
503 Self::SessionsRevokedByOther => "sessions_revoked_by_other",
504 Self::SessionLogout => "session_logout",
505 Self::EmergencyRecovery => "emergency_recovery",
506 }
507 }
508}
509
510/// Fetch the most recent `limit` admin actions, newest first.
511pub async fn recent(
512 db: &Db,
513 limit: i64,
514 model_filter: Option<&str>,
515 action_filter: Option<&str>,
516) -> Result<Vec<AdminAction>> {
517 let mut sql = String::from(
518 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
519 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
520 FROM rustio_admin_actions a
521 LEFT JOIN rustio_users u ON u.id = a.user_id",
522 );
523 let mut clauses: Vec<String> = Vec::new();
524 let mut param_idx: usize = 1;
525 if model_filter.is_some() {
526 clauses.push(format!("a.model_name = ${param_idx}"));
527 param_idx += 1;
528 }
529 if action_filter.is_some() {
530 clauses.push(format!("a.action_type = ${param_idx}"));
531 param_idx += 1;
532 }
533 if !clauses.is_empty() {
534 sql.push_str(" WHERE ");
535 sql.push_str(&clauses.join(" AND "));
536 }
537 sql.push_str(&format!(
538 " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
539 ));
540
541 let mut q = sqlx::query(&sql);
542 if let Some(m) = model_filter {
543 q = q.bind(m);
544 }
545 if let Some(a) = action_filter {
546 q = q.bind(a);
547 }
548 q = q.bind(limit);
549
550 let rows = q.fetch_all(db.pool()).await?;
551 rows.iter().map(row_to_action).collect()
552}
553
554/// All actions for one `(model, object_id)`, newest first.
555pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
556 let rows = sqlx::query(
557 "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
558 a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
559 FROM rustio_admin_actions a
560 LEFT JOIN rustio_users u ON u.id = a.user_id
561 WHERE a.model_name = $1 AND a.object_id = $2
562 ORDER BY a.timestamp DESC, a.id DESC",
563 )
564 .bind(model_name)
565 .bind(object_id)
566 .fetch_all(db.pool())
567 .await?;
568 rows.iter().map(row_to_action).collect()
569}
570
571fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
572 Ok(AdminAction {
573 id: r.try_get("id")?,
574 user_id: r.try_get("user_id")?,
575 user_email: r.try_get("user_email")?,
576 action_type: r.try_get("action_type")?,
577 model_name: r.try_get("model_name")?,
578 object_id: r.try_get("object_id")?,
579 timestamp: r.try_get("timestamp")?,
580 ip_address: r.try_get("ip_address")?,
581 summary: r.try_get("summary")?,
582 })
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 /// Single source of truth for every `AuditEvent` variant the
590 /// framework currently exposes. Drift tests below iterate over
591 /// this constant; adding a new variant means adding it here.
592 /// CHANGELOG / DESIGN_AUDIT.md call out variant additions.
593 const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
594 AuditEvent::UserCreated,
595 AuditEvent::UserUpdated,
596 AuditEvent::UserDeleted,
597 AuditEvent::GroupCreated,
598 AuditEvent::GroupUpdated,
599 AuditEvent::GroupDeleted,
600 AuditEvent::PasswordChangedSelf,
601 AuditEvent::PasswordResetSelfRequest,
602 AuditEvent::PasswordResetSelfConsume,
603 AuditEvent::PasswordResetByOther,
604 AuditEvent::ForcedPasswordChangeCompleted,
605 AuditEvent::AccountLocked,
606 AuditEvent::AccountUnlocked,
607 AuditEvent::MfaEnabled,
608 AuditEvent::MfaDisabled,
609 AuditEvent::MfaResetByOther,
610 AuditEvent::MfaCodeConsumed,
611 AuditEvent::BackupCodesRegenerated,
612 AuditEvent::SessionsRevokedSelf,
613 AuditEvent::SessionsRevokedByOther,
614 AuditEvent::SessionLogout,
615 AuditEvent::EmergencyRecovery,
616 ];
617
618 /// Drift test (doctrine 18): every variant's `as_str()` is
619 /// unique. Catches copy-paste collisions when adding variants
620 /// — `password_reset_self_request` vs
621 /// `password_reset_self_consume` are easy to mis-paste.
622 #[test]
623 fn audit_event_strings_are_unique() {
624 let mut set = std::collections::HashSet::new();
625 for &e in ALL_AUDIT_EVENTS {
626 assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
627 }
628 assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
629 }
630
631 /// Every `AuditEvent` string is snake_case ASCII. Future SIEM
632 /// integrations tokenise on these — keep them pre-normalised.
633 #[test]
634 fn audit_event_strings_are_snake_case() {
635 for &e in ALL_AUDIT_EVENTS {
636 let s = e.as_str();
637 assert!(!s.is_empty(), "{e:?} as_str is empty");
638 assert!(
639 s.chars()
640 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
641 "{e:?}.as_str() = {s:?} is not snake_case"
642 );
643 }
644 }
645
646 /// R1 commit #6: `PasswordChangedSelf` maps to the locked string
647 /// `"password_changed_self"`. The string is part of the public
648 /// API contract from 0.5.0; renaming requires a major bump.
649 #[test]
650 fn audit_event_password_changed_self_maps_correctly() {
651 assert_eq!(
652 AuditEvent::PasswordChangedSelf.as_str(),
653 "password_changed_self"
654 );
655 }
656
657 /// Stability contract for the public API: every existing
658 /// variant's string value is locked-in here. A change to any of
659 /// these strings is a breaking change requiring a major bump
660 /// (the persisted `rustio_admin_actions.action_type` column
661 /// would have rows referencing the old string from prior
662 /// installations). New variants may extend this list; existing
663 /// rows must keep their strings.
664 #[test]
665 fn audit_event_existing_variants_have_stable_strings() {
666 assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
667 assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
668 assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
669 assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
670 assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
671 assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
672 assert_eq!(
673 AuditEvent::PasswordChangedSelf.as_str(),
674 "password_changed_self"
675 );
676 assert_eq!(
677 AuditEvent::PasswordResetSelfRequest.as_str(),
678 "password_reset_self_request"
679 );
680 assert_eq!(
681 AuditEvent::PasswordResetSelfConsume.as_str(),
682 "password_reset_self_consume"
683 );
684 assert_eq!(
685 AuditEvent::PasswordResetByOther.as_str(),
686 "password_reset_by_other"
687 );
688 assert_eq!(
689 AuditEvent::ForcedPasswordChangeCompleted.as_str(),
690 "forced_password_change_completed"
691 );
692 assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
693 assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
694 assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
695 assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
696 assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
697 assert_eq!(AuditEvent::MfaCodeConsumed.as_str(), "mfa_code_consumed");
698 assert_eq!(
699 AuditEvent::BackupCodesRegenerated.as_str(),
700 "backup_codes_regenerated"
701 );
702 assert_eq!(
703 AuditEvent::SessionsRevokedSelf.as_str(),
704 "sessions_revoked_self"
705 );
706 assert_eq!(
707 AuditEvent::SessionsRevokedByOther.as_str(),
708 "sessions_revoked_by_other"
709 );
710 assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
711 assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
712 }
713
714 /// `ActionType` and `AuditEvent` are intentionally separate
715 /// vocabularies — `ActionType` writes generic CRUD strings
716 /// (`"create" / "update" / "delete"`) for project-registered
717 /// models; `AuditEvent` writes the framework's richer authority,
718 /// identity, and recovery vocabulary. The two namespaces must
719 /// stay disjoint so a SIEM consumer can route on the string
720 /// alone without disambiguation.
721 #[test]
722 fn action_type_and_audit_event_vocabularies_dont_collide() {
723 let action_type_strs = [
724 ActionType::Create.as_str(),
725 ActionType::Update.as_str(),
726 ActionType::Delete.as_str(),
727 ];
728 let mut set = std::collections::HashSet::new();
729 for s in action_type_strs {
730 assert!(set.insert(s), "duplicate ActionType string {s:?}");
731 }
732 for &e in ALL_AUDIT_EVENTS {
733 assert!(
734 set.insert(e.as_str()),
735 "AuditEvent::{:?} ({:?}) collides with ActionType",
736 e,
737 e.as_str()
738 );
739 }
740 assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
741 }
742
743 /// Doctrine D12 enforcement (`DESIGN_R4_EMERGENCY.md` §10).
744 ///
745 /// `AuditEvent::EmergencyRecovery` is the CLI-only audit
746 /// signal: any emission from a framework HTTP handler would
747 /// collapse the "operator went around every other tier via
748 /// shell" distinction into the regular web-driven audit
749 /// vocabulary. This test walks `crates/rustio-admin/src/`
750 /// (the framework crate) and asserts that no source file —
751 /// other than this file, where the variant is declared,
752 /// documented, mapped, and stability-tested — names
753 /// `AuditEvent::EmergencyRecovery` in CODE.
754 ///
755 /// The narrower `AuditEvent::EmergencyRecovery` qualified
756 /// form (not bare `EmergencyRecovery`) is checked
757 /// deliberately: `SessionInvalidationReason::EmergencyRecovery`
758 /// is a separate enum the framework legitimately uses when
759 /// CLI-driven session invalidations land via
760 /// `auth::sessions::invalidate_sessions`. Doctrine D12
761 /// scopes to the audit variant alone.
762 ///
763 /// Implementation: line-comment-stripped grep. A real
764 /// emission cannot hide inside a `//` comment.
765 #[test]
766 fn emergency_recovery_is_cli_only() {
767 let framework_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
768 let self_file = framework_src.join("admin/audit.rs");
769
770 let mut offenders: Vec<String> = Vec::new();
771 walk_rs_files_admin_audit(&framework_src, &mut |path: &std::path::Path| {
772 if path == self_file {
773 return;
774 }
775 let content = std::fs::read_to_string(path)
776 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
777 for (line_no, raw_line) in content.lines().enumerate() {
778 // Strip `//` line comments. Don't try to parse `/*
779 // ... */`; framework code uses `//` and `///`
780 // exclusively (verified by inspection).
781 let code = match raw_line.find("//") {
782 Some(idx) => &raw_line[..idx],
783 None => raw_line,
784 };
785 if code.contains("AuditEvent::EmergencyRecovery") {
786 offenders.push(format!(
787 "{}:{}: {}",
788 path.display(),
789 line_no + 1,
790 raw_line.trim()
791 ));
792 }
793 }
794 });
795 assert!(
796 offenders.is_empty(),
797 "framework crate must not reference `AuditEvent::EmergencyRecovery` \
798 in CODE (it is a CLI-only audit variant per \
799 `DESIGN_R4_EMERGENCY.md` §10 D12). Move the emission to \
800 crates/rustio-admin-cli/, or introduce a new AuditEvent variant \
801 for the web-side action:\n {}",
802 offenders.join("\n ")
803 );
804 }
805
806 /// std-only recursive walker dedicated to this test. Mirrors
807 /// the `walk_rs_files` helper used by
808 /// `templates::tests::every_handler_rendered_template_resolves`;
809 /// duplicated here so the audit test stays self-contained.
810 fn walk_rs_files_admin_audit(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
811 let entries = match std::fs::read_dir(root) {
812 Ok(e) => e,
813 Err(_) => return,
814 };
815 for entry in entries.flatten() {
816 let path = entry.path();
817 let file_type = match entry.file_type() {
818 Ok(ft) => ft,
819 Err(_) => continue,
820 };
821 if file_type.is_dir() {
822 walk_rs_files_admin_audit(&path, visit);
823 } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
824 visit(&path);
825 }
826 }
827 }
828
829 // ---- LogEntry::with_event ----
830
831 #[test]
832 fn log_entry_with_event_overrides_action_type_persistence() {
833 // Without with_event(), the legacy ActionType wins.
834 let entry = LogEntry::new(1, ActionType::Update, "user", 1);
835 assert_eq!(entry.resolved_action_type(), "update");
836
837 // with_event() promotes to the richer AuditEvent string.
838 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
839 .with_event(AuditEvent::PasswordChangedSelf);
840 assert_eq!(entry.resolved_action_type(), "password_changed_self");
841
842 // Different events resolve to their canonical string.
843 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
844 .with_event(AuditEvent::PasswordResetSelfRequest);
845 assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
846
847 let entry = LogEntry::new(1, ActionType::Update, "user", 1)
848 .with_event(AuditEvent::PasswordResetSelfConsume);
849 assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
850 }
851
852 #[test]
853 fn log_entry_default_event_is_none() {
854 // Backwards-compat: legacy callers continue to work.
855 let entry = LogEntry::new(1, ActionType::Create, "post", 99);
856 assert!(entry.event.is_none());
857 assert_eq!(entry.resolved_action_type(), "create");
858 }
859
860 // ---- LogEntry::with_actor + build_persisted_metadata (R2 #7) -----------
861
862 #[test]
863 fn log_entry_with_actor_sets_field() {
864 let entry = LogEntry::new(1, ActionType::Update, "user", 1).with_actor(7);
865 assert_eq!(entry.actor_user_id, Some(7));
866 }
867
868 #[test]
869 fn log_entry_default_actor_user_id_is_none() {
870 // R0/R1 emissions leave actor_user_id None — only R2 admin
871 // actions opt in via .with_actor(...).
872 let entry = LogEntry::new(1, ActionType::Update, "user", 1);
873 assert!(entry.actor_user_id.is_none());
874 }
875
876 #[test]
877 fn merge_returns_metadata_unchanged_when_no_actor() {
878 // None actor means no merge — even an existing
879 // actor_user_id key in the input is preserved verbatim.
880 let original = serde_json::json!({"reason": "x", "actor_user_id": 99});
881 let out = build_persisted_metadata(Some(original.clone()), None);
882 assert_eq!(out.unwrap(), original);
883
884 // None metadata + None actor → None.
885 assert!(build_persisted_metadata(None, None).is_none());
886 }
887
888 #[test]
889 fn merge_synthesizes_object_when_metadata_is_none() {
890 let out = build_persisted_metadata(None, Some(7)).unwrap();
891 assert_eq!(out, serde_json::json!({"actor_user_id": 7}));
892 }
893
894 #[test]
895 fn merge_inserts_into_existing_object() {
896 let input = serde_json::json!({"reason": "support ticket", "mode": "email"});
897 let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
898 assert_eq!(
899 out,
900 serde_json::json!({
901 "reason": "support ticket",
902 "mode": "email",
903 "actor_user_id": 7
904 })
905 );
906 }
907
908 #[test]
909 fn merge_typed_actor_wins_over_existing_metadata_key() {
910 // Doctrine: the LogEntry struct field is the source of
911 // truth for the actor association. A handler that puts
912 // actor_user_id directly into metadata gets overridden
913 // when it also sets the typed field — preventing
914 // accidental inconsistency between the JSON key and the
915 // struct.
916 let input = serde_json::json!({"actor_user_id": 999, "extra": "x"});
917 let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
918 assert_eq!(out, serde_json::json!({"actor_user_id": 7, "extra": "x"}));
919 }
920
921 #[test]
922 fn merge_passes_through_non_object_metadata_with_warning() {
923 // Non-object metadata is a programming bug. The merge
924 // returns the original value unchanged so the row still
925 // writes (audit-row-loss is worse than missing actor
926 // metadata); a log::warn in the runtime path surfaces
927 // the bug. Here we just assert the row preserves shape.
928 let input = serde_json::json!(42);
929 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
930 assert_eq!(out, input);
931
932 let input = serde_json::json!(["a", "b"]);
933 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
934 assert_eq!(out, input);
935
936 let input = serde_json::json!("scalar");
937 let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
938 assert_eq!(out, input);
939 }
940
941 /// The legacy `ActionType::parse` is a partial parser — it only
942 /// recognises the original create/update/delete trio. Strings
943 /// emitted by `AuditEvent` (and any free-form legacy strings
944 /// already in older `rustio_admin_actions` rows) return `None`,
945 /// which the render layer maps to a neutral pill class without
946 /// panicking. This pins the property so a future change to
947 /// `ActionType::parse` doesn't accidentally start matching
948 /// AuditEvent strings.
949 #[test]
950 fn legacy_action_type_parser_returns_none_on_unknown_strings() {
951 // Legacy trio still parses.
952 assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
953 assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
954 assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
955
956 // Every AuditEvent string is unrecognised by the legacy
957 // parser — the render layer falls through to "badge-neutral"
958 // for these, which is the documented behaviour.
959 for &e in ALL_AUDIT_EVENTS {
960 assert!(
961 ActionType::parse(e.as_str()).is_none(),
962 "ActionType::parse should not recognise AuditEvent string {:?}",
963 e.as_str()
964 );
965 }
966
967 // Pure garbage and free-form legacy strings.
968 assert!(ActionType::parse("garbage").is_none());
969 assert!(ActionType::parse("").is_none());
970 assert!(ActionType::parse("CREATE").is_none()); // case-sensitive
971 }
972}