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