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    Ok(())
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ActionType {
83    Create,
84    Update,
85    Delete,
86}
87
88impl ActionType {
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Self::Create => "create",
92            Self::Update => "update",
93            Self::Delete => "delete",
94        }
95    }
96
97    pub fn parse(s: &str) -> Option<Self> {
98        match s {
99            "create" => Some(Self::Create),
100            "update" => Some(Self::Update),
101            "delete" => Some(Self::Delete),
102            _ => None,
103        }
104    }
105
106    pub fn label(self) -> &'static str {
107        match self {
108            Self::Create => "Created",
109            Self::Update => "Updated",
110            Self::Delete => "Deleted",
111        }
112    }
113
114    pub fn pill_class(self) -> &'static str {
115        match self {
116            Self::Create => "badge-success",
117            Self::Update => "badge-neutral",
118            Self::Delete => "badge-danger",
119        }
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct AdminAction {
125    pub id: i64,
126    pub user_id: i64,
127    pub user_email: Option<String>,
128    pub action_type: String,
129    pub model_name: String,
130    pub object_id: i64,
131    pub timestamp: DateTime<Utc>,
132    pub ip_address: Option<String>,
133    pub summary: String,
134}
135
136pub struct LogEntry<'a> {
137    pub user_id: i64,
138    pub action_type: ActionType,
139    pub model_name: &'a str,
140    pub object_id: i64,
141    pub ip_address: Option<&'a str>,
142    pub summary: String,
143    /// Per-request UUID (R0). All audit rows written under one HTTP
144    /// request share this id so a future `/admin/history/<id>` page
145    /// can reconstruct the chain of events ("admin reset password →
146    /// all sessions revoked → security email dispatched").
147    pub correlation_id: Option<&'a str>,
148    /// The session that performed the action, when applicable. CLI
149    /// emergency actions write `None`.
150    pub session_id: Option<i64>,
151    /// Structured before/after / extra metadata. JSONB column.
152    /// R2 emissions populate this with an *object* (not a scalar
153    /// or array) — the merge layer in [`record`] inserts
154    /// `actor_user_id` and similar typed sidecars into the object
155    /// before persistence, and a non-object value disables that
156    /// merge (the row still writes; a warning is logged).
157    pub metadata: Option<serde_json::Value>,
158    /// The acting principal when this row records one user
159    /// performing an action on another (admin password reset,
160    /// admin lock/unlock, admin revoke-sessions, etc.). Persisted
161    /// under `metadata.actor_user_id` — the column itself doesn't
162    /// change.
163    ///
164    /// R2 emissions set this via [`LogEntry::with_actor`]; R0/R1
165    /// emissions leave it `None`. The legacy [`Self::user_id`]
166    /// field continues to carry the actor for backwards-compat
167    /// with `/admin/history`'s "who did what" view; `actor_user_id`
168    /// is the typed mirror that lets metadata consumers (SIEM,
169    /// future per-user audit pivots) read the actor without
170    /// relying on heuristics about which row-shape sets
171    /// `user_id` to actor vs. target.
172    ///
173    /// When `Some(id)` is set and `metadata` is also `Some(obj)`,
174    /// [`record`] inserts the key into the object. If the existing
175    /// metadata already contains `actor_user_id`, the typed-field
176    /// value wins — the struct field is the source of truth.
177    pub actor_user_id: Option<i64>,
178    /// When `Some`, supersedes `action_type.as_str()` as the
179    /// persisted `rustio_admin_actions.action_type` string. Set via
180    /// [`LogEntry::with_event`]; the `action_type` field becomes a
181    /// placeholder in that case (the convention is to pass
182    /// `ActionType::Update`). Used by R1+ recovery / authority /
183    /// identity emissions that need the richer typed vocabulary —
184    /// see `DESIGN_AUDIT.md` §3 + `DESIGN_RECOVERY.md` §6.
185    pub event: Option<AuditEvent>,
186}
187
188impl<'a> LogEntry<'a> {
189    /// Builder helper for the common case (every field that R0
190    /// added defaults to `None`). Existing call sites can migrate
191    /// incrementally.
192    pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
193        Self {
194            user_id,
195            action_type,
196            model_name,
197            object_id,
198            ip_address: None,
199            summary: String::new(),
200            correlation_id: None,
201            session_id: None,
202            metadata: None,
203            actor_user_id: None,
204            event: None,
205        }
206    }
207
208    /// Mark this entry as an admin acting on another user. The id
209    /// is persisted under `metadata.actor_user_id` by [`record`],
210    /// not as a separate column. Pair with `.with_event(...)` for
211    /// the canonical R2 admin-action shape, e.g.
212    ///
213    /// ```ignore
214    /// LogEntry::new(target_id, ActionType::Update, "user", target_id)
215    ///     .with_event(AuditEvent::PasswordResetByOther)
216    ///     .with_actor(admin_identity.user_id)
217    /// ```
218    ///
219    /// Auto-throttle (no actor) leaves this `None`; the row's
220    /// `user_id` column carries the affected user as the
221    /// closest-reasonable subject. See `DESIGN_R2_ORGANISATIONAL.md`
222    /// §5.2.
223    pub fn with_actor(mut self, actor_user_id: i64) -> Self {
224        self.actor_user_id = Some(actor_user_id);
225        self
226    }
227
228    /// Promote this entry's persisted `action_type` string from the
229    /// legacy [`ActionType`] (create/update/delete) trio to the
230    /// richer typed [`AuditEvent`]. The `action_type` field becomes
231    /// a placeholder; the convention is to pass `ActionType::Update`
232    /// to [`Self::new`] and chain `.with_event(...)`.
233    ///
234    /// ```ignore
235    /// let entry = LogEntry::new(user_id, ActionType::Update, "user", user_id)
236    ///     .with_event(AuditEvent::PasswordChangedSelf);
237    /// ```
238    ///
239    /// Use this for framework-internal authority + identity +
240    /// recovery audit rows per `DESIGN_AUDIT.md` §3 +
241    /// `DESIGN_RECOVERY.md` §6. Project code that records generic
242    /// CRUD on its own models continues to use [`Self::new`] alone
243    /// with the legacy `ActionType` trio.
244    pub fn with_event(mut self, event: AuditEvent) -> Self {
245        self.event = Some(event);
246        self
247    }
248
249    /// Resolve the persisted `action_type` string. The `event`
250    /// override wins when set; otherwise the legacy `action_type`
251    /// trio's lowercase string is used. Pulled out as a small helper
252    /// so the `record()` insert and any future read-side rendering
253    /// share one resolution rule.
254    pub(crate) fn resolved_action_type(&self) -> &'static str {
255        match self.event {
256            Some(e) => e.as_str(),
257            None => self.action_type.as_str(),
258        }
259    }
260}
261
262/// Merge `actor_user_id` into the persisted `metadata` JSONB column.
263///
264/// - When `actor_user_id` is `None`: returns `metadata` unchanged.
265/// - When `actor_user_id` is `Some(id)` and `metadata` is `None`:
266///   synthesizes `{"actor_user_id": id}`.
267/// - When `actor_user_id` is `Some(id)` and `metadata` is
268///   `Some(object)`: inserts the key, replacing any existing
269///   `actor_user_id` (the typed field wins — it is the source of
270///   truth for the actor association).
271/// - When `actor_user_id` is `Some(id)` and `metadata` is
272///   `Some(scalar | array)`: returns the original metadata
273///   unchanged and emits a `log::warn!`. The merge requires an
274///   object; non-object metadata is a programming error and the
275///   audit row is preferable to silent loss.
276///
277/// Pulled out as a free function so the merge contract is
278/// unit-testable without a database.
279fn build_persisted_metadata(
280    metadata: Option<serde_json::Value>,
281    actor_user_id: Option<i64>,
282) -> Option<serde_json::Value> {
283    let actor = match actor_user_id {
284        None => return metadata,
285        Some(id) => id,
286    };
287
288    match metadata {
289        None => Some(serde_json::json!({ "actor_user_id": actor })),
290        Some(mut value) => {
291            if let Some(obj) = value.as_object_mut() {
292                obj.insert("actor_user_id".to_string(), serde_json::json!(actor));
293                Some(value)
294            } else {
295                log::warn!(
296                    "audit::record: actor_user_id={} set but metadata is not a JSON object \
297                     ({:?}); writing row without merging actor — fix the call site",
298                    actor,
299                    value
300                );
301                Some(value)
302            }
303        }
304    }
305}
306
307/// Write one row to the action log. Validates required fields before
308/// touching the DB so a broken audit pipeline becomes visible.
309pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
310    if entry.user_id <= 0 {
311        return Err(Error::Internal("admin audit: missing user_id".to_string()));
312    }
313    if entry.model_name.trim().is_empty() {
314        return Err(Error::Internal(
315            "admin audit: missing model_name".to_string(),
316        ));
317    }
318    if entry.object_id <= 0 {
319        return Err(Error::Internal(
320            "admin audit: missing object_id".to_string(),
321        ));
322    }
323
324    let now = Utc::now();
325    let action_type_str = entry.resolved_action_type();
326    let metadata = build_persisted_metadata(entry.metadata, entry.actor_user_id);
327    sqlx::query(
328        "INSERT INTO rustio_admin_actions
329             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
330              correlation_id, session_id, metadata)
331         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
332    )
333    .bind(entry.user_id)
334    .bind(action_type_str)
335    .bind(entry.model_name)
336    .bind(entry.object_id)
337    .bind(now)
338    .bind(entry.ip_address)
339    .bind(&entry.summary)
340    .bind(entry.correlation_id)
341    .bind(entry.session_id)
342    .bind(metadata.as_ref())
343    .execute(db.pool())
344    .await?;
345    Ok(())
346}
347
348/// Typed representation of every audit `action_type` the framework
349/// emits for authority + identity + recovery actions.
350///
351/// **Public-API stability (0.5.0):** the enum is `pub` from R1
352/// onwards (doctrine 18). External consumers — SIEM tooling, custom
353/// dashboards, integration tests — can match on these variants
354/// instead of (or in addition to) the persisted strings. The
355/// `as_str()` mapping is the single canonical boundary between the
356/// typed surface and the `rustio_admin_actions.action_type` TEXT
357/// column. Every existing variant's string is locked-in by the
358/// `audit_event_existing_variants_have_stable_strings` test below;
359/// renaming a string is a breaking change requiring a major version
360/// bump.
361///
362/// **Coexistence with `ActionType`:** the legacy
363/// `ActionType::{Create, Update, Delete}` trio writes the strings
364/// `"create" / "update" / "delete"`, used for generic CRUD on
365/// project-registered models. `AuditEvent` strings are richer
366/// (`"user_created"`, `"password_reset_self_consume"`, …) and used
367/// for the framework's own authority + identity + recovery surfaces.
368/// The two vocabularies are disjoint by design;
369/// `action_type_and_audit_event_vocabularies_dont_collide` asserts
370/// the disjointness.
371///
372/// **Future-extensibility:** `#[non_exhaustive]` lets future
373/// R-phases (R2 / R3 / R4) add variants without breaking external
374/// matchers. Variants whose call-sites haven't shipped yet are
375/// listed here in anticipation — `as_str()` returns the canonical
376/// string regardless of whether anything emits it. The roadmap
377/// in `DESIGN_RECOVERY.md` §16 + `ROADMAP.md` covers when each
378/// variant lights up.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
380#[non_exhaustive]
381pub enum AuditEvent {
382    // ---- User / Group authority CRUD (R0+) ----
383    UserCreated,
384    UserUpdated,
385    UserDeleted,
386    GroupCreated,
387    GroupUpdated,
388    GroupDeleted,
389    // ---- Password lifecycle (R1+) ----
390    /// Authenticated user changed their own password via
391    /// `/admin/password_change`. R1 commit #11 wires emission.
392    PasswordChangedSelf,
393    /// Anonymous user requested a password-reset email via
394    /// `/admin/forgot-password`. R1 commit #7 wires emission.
395    PasswordResetSelfRequest,
396    /// Anonymous user consumed a reset token + set a new password
397    /// via `/admin/reset-password/<token>`. R1 commit #7 wires
398    /// emission.
399    PasswordResetSelfConsume,
400    /// An administrator reset another user's password. R2 wires
401    /// emission via the dedicated `/admin/users/<id>/reset-password`
402    /// route.
403    PasswordResetByOther,
404    /// A user with `must_change_password = TRUE` completed the
405    /// forced rotation via `POST /admin/must-change-password`,
406    /// clearing the flag and (per `DESIGN_R2_ORGANISATIONAL.md`
407    /// §3.4) revoking every other session for the same user.
408    /// `metadata.triggered_by_audit_id` links back to the
409    /// originating `PasswordResetByOther` row;
410    /// `metadata.invalidated_session_count` records how many
411    /// sessions were revoked. R2 commit #12 wires emission.
412    ForcedPasswordChangeCompleted,
413    // ---- Account state (R2+) ----
414    AccountLocked,
415    AccountUnlocked,
416    // ---- MFA (R3+) ----
417    MfaEnabled,
418    MfaDisabled,
419    MfaResetByOther,
420    // ---- Session lifecycle (R0/R1+) ----
421    SessionsRevokedSelf,
422    SessionsRevokedByOther,
423    SessionLogout,
424    // ---- Layer-3 CLI (R4+) ----
425    EmergencyRecovery,
426}
427
428impl AuditEvent {
429    /// Stable lowercase identifier persisted as
430    /// `rustio_admin_actions.action_type`.
431    ///
432    /// **Stability contract:** every string returned here is
433    /// part of the public API from 0.5.0 onwards. Existing values
434    /// are locked-in by
435    /// `audit_event_existing_variants_have_stable_strings` and
436    /// changing one is a breaking change requiring a major bump.
437    /// New `AuditEvent` variants may be added in minor versions
438    /// (the enum is `#[non_exhaustive]`); each new variant ships
439    /// with its locked string from day one.
440    pub const fn as_str(self) -> &'static str {
441        match self {
442            Self::UserCreated => "user_created",
443            Self::UserUpdated => "user_updated",
444            Self::UserDeleted => "user_deleted",
445            Self::GroupCreated => "group_created",
446            Self::GroupUpdated => "group_updated",
447            Self::GroupDeleted => "group_deleted",
448            Self::PasswordChangedSelf => "password_changed_self",
449            Self::PasswordResetSelfRequest => "password_reset_self_request",
450            Self::PasswordResetSelfConsume => "password_reset_self_consume",
451            Self::PasswordResetByOther => "password_reset_by_other",
452            Self::ForcedPasswordChangeCompleted => "forced_password_change_completed",
453            Self::AccountLocked => "account_locked",
454            Self::AccountUnlocked => "account_unlocked",
455            Self::MfaEnabled => "mfa_enabled",
456            Self::MfaDisabled => "mfa_disabled",
457            Self::MfaResetByOther => "mfa_reset_by_other",
458            Self::SessionsRevokedSelf => "sessions_revoked_self",
459            Self::SessionsRevokedByOther => "sessions_revoked_by_other",
460            Self::SessionLogout => "session_logout",
461            Self::EmergencyRecovery => "emergency_recovery",
462        }
463    }
464}
465
466/// Fetch the most recent `limit` admin actions, newest first.
467pub async fn recent(
468    db: &Db,
469    limit: i64,
470    model_filter: Option<&str>,
471    action_filter: Option<&str>,
472) -> Result<Vec<AdminAction>> {
473    let mut sql = String::from(
474        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
475                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
476         FROM rustio_admin_actions a
477         LEFT JOIN rustio_users u ON u.id = a.user_id",
478    );
479    let mut clauses: Vec<String> = Vec::new();
480    let mut param_idx: usize = 1;
481    if model_filter.is_some() {
482        clauses.push(format!("a.model_name = ${param_idx}"));
483        param_idx += 1;
484    }
485    if action_filter.is_some() {
486        clauses.push(format!("a.action_type = ${param_idx}"));
487        param_idx += 1;
488    }
489    if !clauses.is_empty() {
490        sql.push_str(" WHERE ");
491        sql.push_str(&clauses.join(" AND "));
492    }
493    sql.push_str(&format!(
494        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
495    ));
496
497    let mut q = sqlx::query(&sql);
498    if let Some(m) = model_filter {
499        q = q.bind(m);
500    }
501    if let Some(a) = action_filter {
502        q = q.bind(a);
503    }
504    q = q.bind(limit);
505
506    let rows = q.fetch_all(db.pool()).await?;
507    rows.iter().map(row_to_action).collect()
508}
509
510/// All actions for one `(model, object_id)`, newest first.
511pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
512    let rows = sqlx::query(
513        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
514                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
515         FROM rustio_admin_actions a
516         LEFT JOIN rustio_users u ON u.id = a.user_id
517         WHERE a.model_name = $1 AND a.object_id = $2
518         ORDER BY a.timestamp DESC, a.id DESC",
519    )
520    .bind(model_name)
521    .bind(object_id)
522    .fetch_all(db.pool())
523    .await?;
524    rows.iter().map(row_to_action).collect()
525}
526
527fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
528    Ok(AdminAction {
529        id: r.try_get("id")?,
530        user_id: r.try_get("user_id")?,
531        user_email: r.try_get("user_email")?,
532        action_type: r.try_get("action_type")?,
533        model_name: r.try_get("model_name")?,
534        object_id: r.try_get("object_id")?,
535        timestamp: r.try_get("timestamp")?,
536        ip_address: r.try_get("ip_address")?,
537        summary: r.try_get("summary")?,
538    })
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    /// Single source of truth for every `AuditEvent` variant the
546    /// framework currently exposes. Drift tests below iterate over
547    /// this constant; adding a new variant means adding it here.
548    /// CHANGELOG / DESIGN_AUDIT.md call out variant additions.
549    const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
550        AuditEvent::UserCreated,
551        AuditEvent::UserUpdated,
552        AuditEvent::UserDeleted,
553        AuditEvent::GroupCreated,
554        AuditEvent::GroupUpdated,
555        AuditEvent::GroupDeleted,
556        AuditEvent::PasswordChangedSelf,
557        AuditEvent::PasswordResetSelfRequest,
558        AuditEvent::PasswordResetSelfConsume,
559        AuditEvent::PasswordResetByOther,
560        AuditEvent::ForcedPasswordChangeCompleted,
561        AuditEvent::AccountLocked,
562        AuditEvent::AccountUnlocked,
563        AuditEvent::MfaEnabled,
564        AuditEvent::MfaDisabled,
565        AuditEvent::MfaResetByOther,
566        AuditEvent::SessionsRevokedSelf,
567        AuditEvent::SessionsRevokedByOther,
568        AuditEvent::SessionLogout,
569        AuditEvent::EmergencyRecovery,
570    ];
571
572    /// Drift test (doctrine 18): every variant's `as_str()` is
573    /// unique. Catches copy-paste collisions when adding variants
574    /// — `password_reset_self_request` vs
575    /// `password_reset_self_consume` are easy to mis-paste.
576    #[test]
577    fn audit_event_strings_are_unique() {
578        let mut set = std::collections::HashSet::new();
579        for &e in ALL_AUDIT_EVENTS {
580            assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
581        }
582        assert_eq!(set.len(), ALL_AUDIT_EVENTS.len());
583    }
584
585    /// Every `AuditEvent` string is snake_case ASCII. Future SIEM
586    /// integrations tokenise on these — keep them pre-normalised.
587    #[test]
588    fn audit_event_strings_are_snake_case() {
589        for &e in ALL_AUDIT_EVENTS {
590            let s = e.as_str();
591            assert!(!s.is_empty(), "{e:?} as_str is empty");
592            assert!(
593                s.chars()
594                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
595                "{e:?}.as_str() = {s:?} is not snake_case"
596            );
597        }
598    }
599
600    /// R1 commit #6: `PasswordChangedSelf` maps to the locked string
601    /// `"password_changed_self"`. The string is part of the public
602    /// API contract from 0.5.0; renaming requires a major bump.
603    #[test]
604    fn audit_event_password_changed_self_maps_correctly() {
605        assert_eq!(
606            AuditEvent::PasswordChangedSelf.as_str(),
607            "password_changed_self"
608        );
609    }
610
611    /// Stability contract for the public API: every existing
612    /// variant's string value is locked-in here. A change to any of
613    /// these strings is a breaking change requiring a major bump
614    /// (the persisted `rustio_admin_actions.action_type` column
615    /// would have rows referencing the old string from prior
616    /// installations). New variants may extend this list; existing
617    /// rows must keep their strings.
618    #[test]
619    fn audit_event_existing_variants_have_stable_strings() {
620        assert_eq!(AuditEvent::UserCreated.as_str(), "user_created");
621        assert_eq!(AuditEvent::UserUpdated.as_str(), "user_updated");
622        assert_eq!(AuditEvent::UserDeleted.as_str(), "user_deleted");
623        assert_eq!(AuditEvent::GroupCreated.as_str(), "group_created");
624        assert_eq!(AuditEvent::GroupUpdated.as_str(), "group_updated");
625        assert_eq!(AuditEvent::GroupDeleted.as_str(), "group_deleted");
626        assert_eq!(
627            AuditEvent::PasswordChangedSelf.as_str(),
628            "password_changed_self"
629        );
630        assert_eq!(
631            AuditEvent::PasswordResetSelfRequest.as_str(),
632            "password_reset_self_request"
633        );
634        assert_eq!(
635            AuditEvent::PasswordResetSelfConsume.as_str(),
636            "password_reset_self_consume"
637        );
638        assert_eq!(
639            AuditEvent::PasswordResetByOther.as_str(),
640            "password_reset_by_other"
641        );
642        assert_eq!(
643            AuditEvent::ForcedPasswordChangeCompleted.as_str(),
644            "forced_password_change_completed"
645        );
646        assert_eq!(AuditEvent::AccountLocked.as_str(), "account_locked");
647        assert_eq!(AuditEvent::AccountUnlocked.as_str(), "account_unlocked");
648        assert_eq!(AuditEvent::MfaEnabled.as_str(), "mfa_enabled");
649        assert_eq!(AuditEvent::MfaDisabled.as_str(), "mfa_disabled");
650        assert_eq!(AuditEvent::MfaResetByOther.as_str(), "mfa_reset_by_other");
651        assert_eq!(
652            AuditEvent::SessionsRevokedSelf.as_str(),
653            "sessions_revoked_self"
654        );
655        assert_eq!(
656            AuditEvent::SessionsRevokedByOther.as_str(),
657            "sessions_revoked_by_other"
658        );
659        assert_eq!(AuditEvent::SessionLogout.as_str(), "session_logout");
660        assert_eq!(AuditEvent::EmergencyRecovery.as_str(), "emergency_recovery");
661    }
662
663    /// `ActionType` and `AuditEvent` are intentionally separate
664    /// vocabularies — `ActionType` writes generic CRUD strings
665    /// (`"create" / "update" / "delete"`) for project-registered
666    /// models; `AuditEvent` writes the framework's richer authority,
667    /// identity, and recovery vocabulary. The two namespaces must
668    /// stay disjoint so a SIEM consumer can route on the string
669    /// alone without disambiguation.
670    #[test]
671    fn action_type_and_audit_event_vocabularies_dont_collide() {
672        let action_type_strs = [
673            ActionType::Create.as_str(),
674            ActionType::Update.as_str(),
675            ActionType::Delete.as_str(),
676        ];
677        let mut set = std::collections::HashSet::new();
678        for s in action_type_strs {
679            assert!(set.insert(s), "duplicate ActionType string {s:?}");
680        }
681        for &e in ALL_AUDIT_EVENTS {
682            assert!(
683                set.insert(e.as_str()),
684                "AuditEvent::{:?} ({:?}) collides with ActionType",
685                e,
686                e.as_str()
687            );
688        }
689        assert_eq!(set.len(), action_type_strs.len() + ALL_AUDIT_EVENTS.len());
690    }
691
692    // ---- LogEntry::with_event ----
693
694    #[test]
695    fn log_entry_with_event_overrides_action_type_persistence() {
696        // Without with_event(), the legacy ActionType wins.
697        let entry = LogEntry::new(1, ActionType::Update, "user", 1);
698        assert_eq!(entry.resolved_action_type(), "update");
699
700        // with_event() promotes to the richer AuditEvent string.
701        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
702            .with_event(AuditEvent::PasswordChangedSelf);
703        assert_eq!(entry.resolved_action_type(), "password_changed_self");
704
705        // Different events resolve to their canonical string.
706        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
707            .with_event(AuditEvent::PasswordResetSelfRequest);
708        assert_eq!(entry.resolved_action_type(), "password_reset_self_request");
709
710        let entry = LogEntry::new(1, ActionType::Update, "user", 1)
711            .with_event(AuditEvent::PasswordResetSelfConsume);
712        assert_eq!(entry.resolved_action_type(), "password_reset_self_consume");
713    }
714
715    #[test]
716    fn log_entry_default_event_is_none() {
717        // Backwards-compat: legacy callers continue to work.
718        let entry = LogEntry::new(1, ActionType::Create, "post", 99);
719        assert!(entry.event.is_none());
720        assert_eq!(entry.resolved_action_type(), "create");
721    }
722
723    // ---- LogEntry::with_actor + build_persisted_metadata (R2 #7) -----------
724
725    #[test]
726    fn log_entry_with_actor_sets_field() {
727        let entry = LogEntry::new(1, ActionType::Update, "user", 1).with_actor(7);
728        assert_eq!(entry.actor_user_id, Some(7));
729    }
730
731    #[test]
732    fn log_entry_default_actor_user_id_is_none() {
733        // R0/R1 emissions leave actor_user_id None — only R2 admin
734        // actions opt in via .with_actor(...).
735        let entry = LogEntry::new(1, ActionType::Update, "user", 1);
736        assert!(entry.actor_user_id.is_none());
737    }
738
739    #[test]
740    fn merge_returns_metadata_unchanged_when_no_actor() {
741        // None actor means no merge — even an existing
742        // actor_user_id key in the input is preserved verbatim.
743        let original = serde_json::json!({"reason": "x", "actor_user_id": 99});
744        let out = build_persisted_metadata(Some(original.clone()), None);
745        assert_eq!(out.unwrap(), original);
746
747        // None metadata + None actor → None.
748        assert!(build_persisted_metadata(None, None).is_none());
749    }
750
751    #[test]
752    fn merge_synthesizes_object_when_metadata_is_none() {
753        let out = build_persisted_metadata(None, Some(7)).unwrap();
754        assert_eq!(out, serde_json::json!({"actor_user_id": 7}));
755    }
756
757    #[test]
758    fn merge_inserts_into_existing_object() {
759        let input = serde_json::json!({"reason": "support ticket", "mode": "email"});
760        let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
761        assert_eq!(
762            out,
763            serde_json::json!({
764                "reason": "support ticket",
765                "mode": "email",
766                "actor_user_id": 7
767            })
768        );
769    }
770
771    #[test]
772    fn merge_typed_actor_wins_over_existing_metadata_key() {
773        // Doctrine: the LogEntry struct field is the source of
774        // truth for the actor association. A handler that puts
775        // actor_user_id directly into metadata gets overridden
776        // when it also sets the typed field — preventing
777        // accidental inconsistency between the JSON key and the
778        // struct.
779        let input = serde_json::json!({"actor_user_id": 999, "extra": "x"});
780        let out = build_persisted_metadata(Some(input), Some(7)).unwrap();
781        assert_eq!(out, serde_json::json!({"actor_user_id": 7, "extra": "x"}));
782    }
783
784    #[test]
785    fn merge_passes_through_non_object_metadata_with_warning() {
786        // Non-object metadata is a programming bug. The merge
787        // returns the original value unchanged so the row still
788        // writes (audit-row-loss is worse than missing actor
789        // metadata); a log::warn in the runtime path surfaces
790        // the bug. Here we just assert the row preserves shape.
791        let input = serde_json::json!(42);
792        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
793        assert_eq!(out, input);
794
795        let input = serde_json::json!(["a", "b"]);
796        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
797        assert_eq!(out, input);
798
799        let input = serde_json::json!("scalar");
800        let out = build_persisted_metadata(Some(input.clone()), Some(7)).unwrap();
801        assert_eq!(out, input);
802    }
803
804    /// The legacy `ActionType::parse` is a partial parser — it only
805    /// recognises the original create/update/delete trio. Strings
806    /// emitted by `AuditEvent` (and any free-form legacy strings
807    /// already in older `rustio_admin_actions` rows) return `None`,
808    /// which the render layer maps to a neutral pill class without
809    /// panicking. This pins the property so a future change to
810    /// `ActionType::parse` doesn't accidentally start matching
811    /// AuditEvent strings.
812    #[test]
813    fn legacy_action_type_parser_returns_none_on_unknown_strings() {
814        // Legacy trio still parses.
815        assert_eq!(ActionType::parse("create"), Some(ActionType::Create));
816        assert_eq!(ActionType::parse("update"), Some(ActionType::Update));
817        assert_eq!(ActionType::parse("delete"), Some(ActionType::Delete));
818
819        // Every AuditEvent string is unrecognised by the legacy
820        // parser — the render layer falls through to "badge-neutral"
821        // for these, which is the documented behaviour.
822        for &e in ALL_AUDIT_EVENTS {
823            assert!(
824                ActionType::parse(e.as_str()).is_none(),
825                "ActionType::parse should not recognise AuditEvent string {:?}",
826                e.as_str()
827            );
828        }
829
830        // Pure garbage and free-form legacy strings.
831        assert!(ActionType::parse("garbage").is_none());
832        assert!(ActionType::parse("").is_none());
833        assert!(ActionType::parse("CREATE").is_none()); // case-sensitive
834    }
835}