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