Skip to main content

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}
571
572impl AuditEvent {
573    // public:
574    /// Stable lowercase identifier persisted as
575    /// `rustio_admin_actions.action_type`.
576    ///
577    /// **Stability contract:** every string returned here is
578    /// part of the public API from 0.5.0 onwards. Existing values
579    /// are locked-in by
580    /// `audit_event_existing_variants_have_stable_strings` and
581    /// changing one is a breaking change requiring a major bump.
582    /// New `AuditEvent` variants may be added in minor versions
583    /// (the enum is `#[non_exhaustive]`); each new variant ships
584    /// with its locked string from day one.
585    pub const fn as_str(self) -> &'static str {
586        match self {
587            Self::UserCreated => "user_created",
588            Self::UserUpdated => "user_updated",
589            Self::UserDeleted => "user_deleted",
590            Self::GroupCreated => "group_created",
591            Self::GroupUpdated => "group_updated",
592            Self::GroupDeleted => "group_deleted",
593            Self::PasswordChangedSelf => "password_changed_self",
594            Self::PasswordResetSelfRequest => "password_reset_self_request",
595            Self::PasswordResetSelfConsume => "password_reset_self_consume",
596            Self::PasswordResetByOther => "password_reset_by_other",
597            Self::ForcedPasswordChangeCompleted => "forced_password_change_completed",
598            Self::AccountLocked => "account_locked",
599            Self::AccountUnlocked => "account_unlocked",
600            Self::MfaEnabled => "mfa_enabled",
601            Self::MfaDisabled => "mfa_disabled",
602            Self::MfaResetByOther => "mfa_reset_by_other",
603            Self::MfaCodeConsumed => "mfa_code_consumed",
604            Self::BackupCodesRegenerated => "backup_codes_regenerated",
605            Self::SessionsRevokedSelf => "sessions_revoked_self",
606            Self::SessionsRevokedByOther => "sessions_revoked_by_other",
607            Self::SessionLogout => "session_logout",
608            Self::LoginSucceeded => "login_succeeded",
609            Self::LoginFailed => "login_failed",
610            Self::EmergencyRecovery => "emergency_recovery",
611            Self::AiProposalApproved => "ai_proposal_approved",
612            Self::AiProposalRejected => "ai_proposal_rejected",
613            Self::AiProposalApplied => "ai_proposal_applied",
614        }
615    }
616}
617
618// public:
619/// Fetch the most recent `limit` admin actions, newest first.
620///
621/// Each `Option` filter narrows the WHERE clause when set:
622/// `model_filter` matches `model_name` exactly; `action_filter`
623/// matches `action_type` exactly; `user_filter` matches the actor's
624/// `user_id`. All filters compose with AND.
625pub async fn recent(
626    db: &Db,
627    limit: i64,
628    model_filter: Option<&str>,
629    action_filter: Option<&str>,
630    user_filter: Option<i64>,
631) -> Result<Vec<AdminAction>> {
632    let mut sql = String::from(
633        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
634                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary,
635                a.metadata
636         FROM rustio_admin_actions a
637         LEFT JOIN rustio_users u ON u.id = a.user_id",
638    );
639    let mut clauses: Vec<String> = Vec::new();
640    let mut param_idx: usize = 1;
641    if model_filter.is_some() {
642        clauses.push(format!("a.model_name = ${param_idx}"));
643        param_idx += 1;
644    }
645    if action_filter.is_some() {
646        clauses.push(format!("a.action_type = ${param_idx}"));
647        param_idx += 1;
648    }
649    if user_filter.is_some() {
650        clauses.push(format!("a.user_id = ${param_idx}"));
651        param_idx += 1;
652    }
653    if !clauses.is_empty() {
654        sql.push_str(" WHERE ");
655        sql.push_str(&clauses.join(" AND "));
656    }
657    sql.push_str(&format!(
658        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
659    ));
660
661    let mut q = sqlx::query(&sql);
662    if let Some(m) = model_filter {
663        q = q.bind(m);
664    }
665    if let Some(a) = action_filter {
666        q = q.bind(a);
667    }
668    if let Some(u) = user_filter {
669        q = q.bind(u);
670    }
671    q = q.bind(limit);
672
673    let rows = q.fetch_all(db.pool()).await?;
674    rows.iter().map(row_to_action).collect()
675}
676
677// public:
678/// All actions for one `(model, object_id)`, newest first.
679pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
680    let rows = sqlx::query(
681        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
682                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary,
683                a.metadata
684         FROM rustio_admin_actions a
685         LEFT JOIN rustio_users u ON u.id = a.user_id
686         WHERE a.model_name = $1 AND a.object_id = $2
687         ORDER BY a.timestamp DESC, a.id DESC",
688    )
689    .bind(model_name)
690    .bind(object_id)
691    .fetch_all(db.pool())
692    .await?;
693    rows.iter().map(row_to_action).collect()
694}
695
696fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
697    Ok(AdminAction {
698        id: r.try_get("id")?,
699        user_id: r.try_get("user_id")?,
700        user_email: r.try_get("user_email")?,
701        action_type: r.try_get("action_type")?,
702        model_name: r.try_get("model_name")?,
703        object_id: r.try_get("object_id")?,
704        timestamp: r.try_get("timestamp")?,
705        ip_address: r.try_get("ip_address")?,
706        summary: r.try_get("summary")?,
707        // Metadata is a JSONB column; `try_get` decodes it as
708        // `serde_json::Value`. Rows written before the migration
709        // (no column yet) get `None` naturally via the option type.
710        metadata: r.try_get("metadata").ok(),
711    })
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    /// Single source of truth for every `AuditEvent` variant the
719    /// framework currently exposes. Drift tests below iterate over
720    /// this constant; adding a new variant means adding it here.
721    /// CHANGELOG / DESIGN_AUDIT.md call out variant additions.
722    const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
723        AuditEvent::UserCreated,
724        AuditEvent::UserUpdated,
725        AuditEvent::UserDeleted,
726        AuditEvent::GroupCreated,
727        AuditEvent::GroupUpdated,
728        AuditEvent::GroupDeleted,
729        AuditEvent::PasswordChangedSelf,
730        AuditEvent::PasswordResetSelfRequest,
731        AuditEvent::PasswordResetSelfConsume,
732        AuditEvent::PasswordResetByOther,
733        AuditEvent::ForcedPasswordChangeCompleted,
734        AuditEvent::AccountLocked,
735        AuditEvent::AccountUnlocked,
736        AuditEvent::MfaEnabled,
737        AuditEvent::MfaDisabled,
738        AuditEvent::MfaResetByOther,
739        AuditEvent::MfaCodeConsumed,
740        AuditEvent::BackupCodesRegenerated,
741        AuditEvent::SessionsRevokedSelf,
742        AuditEvent::SessionsRevokedByOther,
743        AuditEvent::SessionLogout,
744        AuditEvent::LoginSucceeded,
745        AuditEvent::LoginFailed,
746        AuditEvent::EmergencyRecovery,
747        AuditEvent::AiProposalApproved,
748        AuditEvent::AiProposalRejected,
749        AuditEvent::AiProposalApplied,
750    ];
751
752    /// Drift test (doctrine 18): every variant's `as_str()` is
753    /// unique. Catches copy-paste collisions when adding variants
754    /// — `password_reset_self_request` vs
755    /// `password_reset_self_consume` are easy to mis-paste.
756    #[test]
757    fn audit_event_strings_are_unique() {
758        let mut set = std::collections::HashSet::new();
759        for &e in ALL_AUDIT_EVENTS {
760            assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
761        }
762        assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
763    }
764
765    /// Every `AuditEvent` string is snake_case ASCII. Future SIEM
766    /// integrations tokenise on these — keep them pre-normalised.
767    #[test]
768    fn audit_event_strings_are_snake_case() {
769        for &e in ALL_AUDIT_EVENTS {
770            let s = e.as_str();
771            assert!(!s.is_empty(), "{e:?} as_str is empty");
772            assert!(
773                s.chars()
774                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
775                "{e:?}.as_str() = {s:?} is not snake_case"
776            );
777        }
778    }
779
780    /// R1 commit #6: `PasswordChangedSelf` maps to the locked string
781    /// `"password_changed_self"`. The string is part of the public
782    /// API contract from 0.5.0; renaming requires a major bump.
783    #[test]
784    fn audit_event_password_changed_self_maps_correctly() {
785        assert_eq!(
786            AuditEvent::PasswordChangedSelf.as_str(),
787            "password_changed_self"
788        );
789    }
790
791    /// Stability contract for the public API: every existing
792    /// variant's string value is locked-in here. A change to any of
793    /// these strings is a breaking change requiring a major bump
794    /// (the persisted `rustio_admin_actions.action_type` column
795    /// would have rows referencing the old string from prior
796    /// installations). New variants may extend this list; existing
797    /// rows must keep their strings.
798    #[test]
799    fn audit_event_existing_variants_have_stable_strings() {
800        assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
801        assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
802        assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
803        assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
804        assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
805        assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
806        assert_eq!(
807            AuditEvent::PasswordChangedSelf.as_str(),
808            "password_changed_self"
809        );
810        assert_eq!(
811            AuditEvent::PasswordResetSelfRequest.as_str(),
812            "password_reset_self_request"
813        );
814        assert_eq!(
815            AuditEvent::PasswordResetSelfConsume.as_str(),
816            "password_reset_self_consume"
817        );
818        assert_eq!(
819            AuditEvent::PasswordResetByOther.as_str(),
820            "password_reset_by_other"
821        );
822        assert_eq!(
823            AuditEvent::ForcedPasswordChangeCompleted.as_str(),
824            "forced_password_change_completed"
825        );
826        assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
827        assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
828        assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
829        assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
830        assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
831        assert_eq!(AuditEvent::MfaCodeConsumed.as_str(), "mfa_code_consumed");
832        assert_eq!(
833            AuditEvent::BackupCodesRegenerated.as_str(),
834            "backup_codes_regenerated"
835        );
836        assert_eq!(
837            AuditEvent::SessionsRevokedSelf.as_str(),
838            "sessions_revoked_self"
839        );
840        assert_eq!(
841            AuditEvent::SessionsRevokedByOther.as_str(),
842            "sessions_revoked_by_other"
843        );
844        assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
845        assert_eq!(AuditEvent::LoginSucceeded.as_str(), "login_succeeded");
846        assert_eq!(AuditEvent::LoginFailed.as_str(), "login_failed");
847        assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
848        assert_eq!(
849            AuditEvent::AiProposalApproved.as_str(),
850            "ai_proposal_approved"
851        );
852        assert_eq!(
853            AuditEvent::AiProposalRejected.as_str(),
854            "ai_proposal_rejected"
855        );
856        assert_eq!(
857            AuditEvent::AiProposalApplied.as_str(),
858            "ai_proposal_applied"
859        );
860    }
861
862    /// `ActionType` and `AuditEvent` are intentionally separate
863    /// vocabularies — `ActionType` writes generic CRUD strings
864    /// (`"create" / "update" / "delete"`) for project-registered
865    /// models; `AuditEvent` writes the framework's richer authority,
866    /// identity, and recovery vocabulary. The two namespaces must
867    /// stay disjoint so a SIEM consumer can route on the string
868    /// alone without disambiguation.
869    #[test]
870    fn action_type_and_audit_event_vocabularies_dont_collide() {
871        let action_type_strs = [
872            ActionType::Create.as_str(),
873            ActionType::Update.as_str(),
874            ActionType::Delete.as_str(),
875        ];
876        let mut set = std::collections::HashSet::new();
877        for s in action_type_strs {
878            assert!(set.insert(s), "duplicate ActionType string {s:?}");
879        }
880        for &e in ALL_AUDIT_EVENTS {
881            assert!(
882                set.insert(e.as_str()),
883                "AuditEvent::{:?} ({:?}) collides with ActionType",
884                e,
885                e.as_str()
886            );
887        }
888        assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
889    }
890
891    /// Doctrine D12 enforcement (`DESIGN_R4_EMERGENCY.md` §10).
892    ///
893    /// `AuditEvent::EmergencyRecovery` is the CLI-only audit
894    /// signal: any emission from a framework HTTP handler would
895    /// collapse the "operator went around every other tier via
896    /// shell" distinction into the regular web-driven audit
897    /// vocabulary. This test walks `crates/rustio-admin/src/`
898    /// (the framework crate) and asserts that no source file —
899    /// other than this file, where the variant is declared,
900    /// documented, mapped, and stability-tested — names
901    /// `AuditEvent::EmergencyRecovery` in CODE.
902    ///
903    /// The narrower `AuditEvent::EmergencyRecovery` qualified
904    /// form (not bare `EmergencyRecovery`) is checked
905    /// deliberately: `SessionInvalidationReason::EmergencyRecovery`
906    /// is a separate enum the framework legitimately uses when
907    /// CLI-driven session invalidations land via
908    /// `auth::sessions::invalidate_sessions`. Doctrine D12
909    /// scopes to the audit variant alone.
910    ///
911    /// Implementation: line-comment-stripped grep. A real
912    /// emission cannot hide inside a `//` comment.
913    #[test]
914    fn emergency_recovery_is_cli_only() {
915        let framework_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
916        let self_file = framework_src.join("admin/audit.rs");
917
918        let mut offenders: Vec<String> = Vec::new();
919        walk_rs_files_admin_audit(&framework_src, &mut |path: &std::path::Path| {
920            if path == self_file {
921                return;
922            }
923            let content = std::fs::read_to_string(path)
924                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
925            for (line_no, raw_line) in content.lines().enumerate() {
926                // Strip `//` line comments. Don't try to parse `/*
927                // ... */`; framework code uses `//` and `///`
928                // exclusively (verified by inspection).
929                let code = match raw_line.find("//") {
930                    Some(idx) => &raw_line[..idx],
931                    None => raw_line,
932                };
933                if code.contains("AuditEvent::EmergencyRecovery") {
934                    offenders.push(format!(
935                        "{}:{}: {}",
936                        path.display(),
937                        line_no + 1,
938                        raw_line.trim()
939                    ));
940                }
941            }
942        });
943        assert!(
944            offenders.is_empty(),
945            "framework crate must not reference `AuditEvent::EmergencyRecovery` \
946             in CODE (it is a CLI-only audit variant per \
947             `DESIGN_R4_EMERGENCY.md` §10 D12). Move the emission to \
948             crates/rustio-admin-cli/, or introduce a new AuditEvent variant \
949             for the web-side action:\n  {}",
950            offenders.join("\n  ")
951        );
952    }
953
954    /// `VISIBILITY_AUDIT.md` finding F1 enforcement.
955    ///
956    /// `rustio_admin_actions.model_name` MUST be the admin slug
957    /// (the value the dispatcher's `admin.find(model_name)` looks
958    /// up), so the History page's link `<a href="/admin/{model_name}/{id}">`
959    /// resolves to a real admin entry. Pre-0.8.1, callsites
960    /// drifted into four conventions: `"User"` (struct name),
961    /// `"user"` (lowercase struct), `"rustio_users"` (SQL table
962    /// name from the R4 CLI), and the correct `"users"` (admin
963    /// slug). Three of the four 404'd.
964    ///
965    /// This test walks every `.rs` file in `crates/rustio-admin/src/`
966    /// looking for `model_name: "<legacy>"` and `LogEntry::new(_, _, "<legacy>", _)`
967    /// patterns. Doc comments and tests in this file (`admin/audit.rs`)
968    /// are skipped because the file is the contract surface where
969    /// the legacy strings are documented as failure cases.
970    ///
971    /// If this test fails with a new offender, the fix is one of:
972    ///   - change the literal to the admin slug (`"users"` /
973    ///     `"groups"` / your project's `M::ADMIN_NAME`);
974    ///   - if the model is a project-registered model not built into
975    ///     the framework, use the value of `M::ADMIN_NAME` from the
976    ///     `RustioAdmin` derive directly.
977    #[test]
978    fn model_name_uses_admin_slug_not_struct_name() {
979        let framework_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
980        let self_file = framework_src.join("admin/audit.rs");
981
982        // Patterns that previously appeared in code and resolve to
983        // 404 against the dispatcher. The negative list intentionally
984        // excludes `"users"` and `"groups"` — those ARE the
985        // canonical admin slugs.
986        let legacy_patterns: &[&str] = &[
987            r#""User""#,
988            r#""user""#,
989            r#""Group""#,
990            r#""group""#,
991            r#""rustio_users""#,
992            r#""rustio_groups""#,
993        ];
994
995        let mut offenders: Vec<String> = Vec::new();
996        walk_rs_files_admin_audit(&framework_src, &mut |path: &std::path::Path| {
997            if path == self_file {
998                return;
999            }
1000            let content = std::fs::read_to_string(path)
1001                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1002            for (line_no, raw_line) in content.lines().enumerate() {
1003                // Strip `//` line comments.
1004                let code = match raw_line.find("//") {
1005                    Some(idx) => &raw_line[..idx],
1006                    None => raw_line,
1007                };
1008                // Only flag lines that ALSO look like audit-emission
1009                // contexts. Bare `"user"` strings in role.rs etc. are
1010                // legitimate (Role::User stringification, not audit
1011                // model_name) — those callsites do NOT contain
1012                // either `LogEntry` or `model_name:`.
1013                let looks_like_audit_emission = code.contains("LogEntry")
1014                    || code.contains("model_name:")
1015                    || code.contains("model_name ==")
1016                    || code.contains("model_name =");
1017                if !looks_like_audit_emission {
1018                    continue;
1019                }
1020                for pat in legacy_patterns {
1021                    if code.contains(pat) {
1022                        offenders.push(format!(
1023                            "{}:{}: {}",
1024                            path.display(),
1025                            line_no + 1,
1026                            raw_line.trim()
1027                        ));
1028                        break;
1029                    }
1030                }
1031            }
1032        });
1033        assert!(
1034            offenders.is_empty(),
1035            "audit row emissions must write the admin slug (not struct \
1036             name / SQL table name) as `model_name`. The History page \
1037             renders this column as a URL slug; non-slug values 404. \
1038             See `VISIBILITY_AUDIT.md` F1.\n  {}",
1039            offenders.join("\n  ")
1040        );
1041    }
1042
1043    /// std-only recursive walker dedicated to this test. Mirrors
1044    /// the `walk_rs_files` helper used by
1045    /// `templates::tests::every_handler_rendered_template_resolves`;
1046    /// duplicated here so the audit test stays self-contained.
1047    fn walk_rs_files_admin_audit(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
1048        let entries = match std::fs::read_dir(root) {
1049            Ok(e) => e,
1050            Err(_) => return,
1051        };
1052        for entry in entries.flatten() {
1053            let path = entry.path();
1054            let file_type = match entry.file_type() {
1055                Ok(ft) => ft,
1056                Err(_) => continue,
1057            };
1058            if file_type.is_dir() {
1059                walk_rs_files_admin_audit(&path, visit);
1060            } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
1061                visit(&path);
1062            }
1063        }
1064    }
1065
1066    // ---- LogEntry::with_event ----
1067
1068    #[test]
1069    fn log_entry_with_event_overrides_action_type_persistence() {
1070        // Without with_event(), the legacy ActionType wins.
1071        let entry = LogEntry::new(1, ActionType::Update, "users", 1);
1072        assert_eq!(entry.resolved_action_type(), "update");
1073
1074        // with_event() promotes to the richer AuditEvent string.
1075        let entry = LogEntry::new(1, ActionType::Update, "users", 1)
1076            .with_event(AuditEvent::PasswordChangedSelf);
1077        assert_eq!(entry.resolved_action_type(), "password_changed_self");
1078
1079        // Different events resolve to their canonical string.
1080        let entry = LogEntry::new(1, ActionType::Update, "users", 1)
1081            .with_event(AuditEvent::PasswordResetSelfRequest);
1082        assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
1083
1084        let entry = LogEntry::new(1, ActionType::Update, "users", 1)
1085            .with_event(AuditEvent::PasswordResetSelfConsume);
1086        assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
1087    }
1088
1089    #[test]
1090    fn log_entry_default_event_is_none() {
1091        // Backwards-compat: legacy callers continue to work.
1092        let entry = LogEntry::new(1, ActionType::Create, "post", 99);
1093        assert!(entry.event.is_none());
1094        assert_eq!(entry.resolved_action_type(), "create");
1095    }
1096
1097    // ---- LogEntry::with_actor + build_persisted_metadata (R2 #7) -----------
1098
1099    #[test]
1100    fn log_entry_with_actor_sets_field() {
1101        let entry = LogEntry::new(1, ActionType::Update, "users", 1).with_actor(7);
1102        assert_eq!(entry.actor_user_id, Some(7));
1103    }
1104
1105    #[test]
1106    fn log_entry_default_actor_user_id_is_none() {
1107        // R0/R1 emissions leave actor_user_id None — only R2 admin
1108        // actions opt in via .with_actor(...).
1109        let entry = LogEntry::new(1, ActionType::Update, "users", 1);
1110        assert!(entry.actor_user_id.is_none());
1111    }
1112
1113    #[test]
1114    fn merge_returns_metadata_unchanged_when_no_actor() {
1115        // None actor means no merge — even an existing
1116        // actor_user_id key in the input is preserved verbatim.
1117        let original = serde_json::json!({"reason": "x", "actor_user_id": 99});
1118        let out = build_persisted_metadata(Some(original.clone()), None);
1119        assert_eq!(out.unwrap(), original);
1120
1121        // None metadata + None actor → None.
1122        assert!(build_persisted_metadata(None, None).is_none());
1123    }
1124
1125    #[test]
1126    fn merge_synthesizes_object_when_metadata_is_none() {
1127        let out = build_persisted_metadata(None, Some(7)).unwrap();
1128        assert_eq!(out, serde_json::json!({"actor_user_id": 7}));
1129    }
1130
1131    #[test]
1132    fn merge_inserts_into_existing_object() {
1133        let input = serde_json::json!({"reason": "support ticket", "mode": "email"});
1134        let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
1135        assert_eq!(
1136            out,
1137            serde_json::json!({
1138                "reason": "support ticket",
1139                "mode": "email",
1140                "actor_user_id": 7
1141            })
1142        );
1143    }
1144
1145    #[test]
1146    fn merge_typed_actor_wins_over_existing_metadata_key() {
1147        // Doctrine: the LogEntry struct field is the source of
1148        // truth for the actor association. A handler that puts
1149        // actor_user_id directly into metadata gets overridden
1150        // when it also sets the typed field — preventing
1151        // accidental inconsistency between the JSON key and the
1152        // struct.
1153        let input = serde_json::json!({"actor_user_id": 999, "extra": "x"});
1154        let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
1155        assert_eq!(out, serde_json::json!({"actor_user_id": 7, "extra": "x"}));
1156    }
1157
1158    #[test]
1159    fn merge_passes_through_non_object_metadata_with_warning() {
1160        // Non-object metadata is a programming bug. The merge
1161        // returns the original value unchanged so the row still
1162        // writes (audit-row-loss is worse than missing actor
1163        // metadata); a log::warn in the runtime path surfaces
1164        // the bug. Here we just assert the row preserves shape.
1165        let input = serde_json::json!(42);
1166        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
1167        assert_eq!(out, input);
1168
1169        let input = serde_json::json!(["a", "b"]);
1170        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
1171        assert_eq!(out, input);
1172
1173        let input = serde_json::json!("scalar");
1174        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
1175        assert_eq!(out, input);
1176    }
1177
1178    /// The legacy `ActionType::parse` is a partial parser — it only
1179    /// recognises the original create/update/delete trio. Strings
1180    /// emitted by `AuditEvent` (and any free-form legacy strings
1181    /// already in older `rustio_admin_actions` rows) return `None`,
1182    /// which the render layer maps to a neutral pill class without
1183    /// panicking. This pins the property so a future change to
1184    /// `ActionType::parse` doesn't accidentally start matching
1185    /// AuditEvent strings.
1186    #[test]
1187    fn legacy_action_type_parser_returns_none_on_unknown_strings() {
1188        // Legacy trio still parses.
1189        assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
1190        assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
1191        assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
1192
1193        // Every AuditEvent string is unrecognised by the legacy
1194        // parser — the render layer falls through to "badge-neutral"
1195        // for these, which is the documented behaviour.
1196        for &e in ALL_AUDIT_EVENTS {
1197            assert!(
1198                ActionType::parse(e.as_str()).is_none(),
1199                "ActionType::parse should not recognise AuditEvent string {:?}",
1200                e.as_str()
1201            );
1202        }
1203
1204        // Pure garbage and free-form legacy strings.
1205        assert!(ActionType::parse("garbage").is_none());
1206        assert!(ActionType::parse("").is_none());
1207        assert!(ActionType::parse("CREATE").is_none()); // case-sensitive
1208    }
1209}