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