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    pub metadata: Option<serde_json::Value>,
153}
154
155impl<'a> LogEntry<'a> {
156    /// Builder helper for the common case (every field that R0
157    /// added defaults to `None`). Existing call sites can migrate
158    /// incrementally.
159    pub fn new(user_id: i64, action_type: ActionType, model_name: &'a str, object_id: i64) -> Self {
160        Self {
161            user_id,
162            action_type,
163            model_name,
164            object_id,
165            ip_address: None,
166            summary: String::new(),
167            correlation_id: None,
168            session_id: None,
169            metadata: None,
170        }
171    }
172}
173
174/// Write one row to the action log. Validates required fields before
175/// touching the DB so a broken audit pipeline becomes visible.
176pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
177    if entry.user_id <= 0 {
178        return Err(Error::Internal("admin audit: missing user_id".to_string()));
179    }
180    if entry.model_name.trim().is_empty() {
181        return Err(Error::Internal(
182            "admin audit: missing model_name".to_string(),
183        ));
184    }
185    if entry.object_id <= 0 {
186        return Err(Error::Internal(
187            "admin audit: missing object_id".to_string(),
188        ));
189    }
190
191    let now = Utc::now();
192    sqlx::query(
193        "INSERT INTO rustio_admin_actions
194             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary,
195              correlation_id, session_id, metadata)
196         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
197    )
198    .bind(entry.user_id)
199    .bind(entry.action_type.as_str())
200    .bind(entry.model_name)
201    .bind(entry.object_id)
202    .bind(now)
203    .bind(entry.ip_address)
204    .bind(&entry.summary)
205    .bind(entry.correlation_id)
206    .bind(entry.session_id)
207    .bind(entry.metadata.as_ref())
208    .execute(db.pool())
209    .await?;
210    Ok(())
211}
212
213/// Internal typed representation of every audit `action_type` the
214/// framework emits. `pub(crate)` for now — doctrine 18 commits to a
215/// future public typed surface, but we don't promote it until 0.5.x.
216///
217/// Every framework call site MUST go through `AuditEvent::as_str()`
218/// rather than writing the string literal inline. The drift test
219/// below enumerates every variant and asserts nothing else lands in
220/// the live `rustio_admin_actions` audit stream.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222#[allow(dead_code)] // not all variants have call sites yet (R1+)
223pub(crate) enum AuditEvent {
224    UserCreated,
225    UserUpdated,
226    UserDeleted,
227    GroupCreated,
228    GroupUpdated,
229    GroupDeleted,
230    PasswordResetSelfRequest,
231    PasswordResetSelfConsume,
232    PasswordResetByOther,
233    AccountLocked,
234    AccountUnlocked,
235    MfaEnabled,
236    MfaDisabled,
237    MfaResetByOther,
238    SessionsRevokedSelf,
239    SessionsRevokedByOther,
240    SessionLogout,
241    EmergencyRecovery,
242}
243
244impl AuditEvent {
245    /// Stable lowercase identifier persisted as
246    /// `rustio_admin_actions.action_type`. Distinct from
247    /// `ActionType::as_str()` (the legacy create/update/delete trio)
248    /// — the two enums coexist; AuditEvent strings are richer and
249    /// will eventually replace ActionType in the public API.
250    #[allow(dead_code)]
251    pub(crate) const fn as_str(self) -> &'static str {
252        match self {
253            Self::UserCreated => "user_created",
254            Self::UserUpdated => "user_updated",
255            Self::UserDeleted => "user_deleted",
256            Self::GroupCreated => "group_created",
257            Self::GroupUpdated => "group_updated",
258            Self::GroupDeleted => "group_deleted",
259            Self::PasswordResetSelfRequest => "password_reset_self_request",
260            Self::PasswordResetSelfConsume => "password_reset_self_consume",
261            Self::PasswordResetByOther => "password_reset_by_other",
262            Self::AccountLocked => "account_locked",
263            Self::AccountUnlocked => "account_unlocked",
264            Self::MfaEnabled => "mfa_enabled",
265            Self::MfaDisabled => "mfa_disabled",
266            Self::MfaResetByOther => "mfa_reset_by_other",
267            Self::SessionsRevokedSelf => "sessions_revoked_self",
268            Self::SessionsRevokedByOther => "sessions_revoked_by_other",
269            Self::SessionLogout => "session_logout",
270            Self::EmergencyRecovery => "emergency_recovery",
271        }
272    }
273}
274
275/// Fetch the most recent `limit` admin actions, newest first.
276pub async fn recent(
277    db: &Db,
278    limit: i64,
279    model_filter: Option<&str>,
280    action_filter: Option<&str>,
281) -> Result<Vec<AdminAction>> {
282    let mut sql = String::from(
283        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
284                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
285         FROM rustio_admin_actions a
286         LEFT JOIN rustio_users u ON u.id = a.user_id",
287    );
288    let mut clauses: Vec<String> = Vec::new();
289    let mut param_idx: usize = 1;
290    if model_filter.is_some() {
291        clauses.push(format!("a.model_name = ${param_idx}"));
292        param_idx += 1;
293    }
294    if action_filter.is_some() {
295        clauses.push(format!("a.action_type = ${param_idx}"));
296        param_idx += 1;
297    }
298    if !clauses.is_empty() {
299        sql.push_str(" WHERE ");
300        sql.push_str(&clauses.join(" AND "));
301    }
302    sql.push_str(&format!(
303        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
304    ));
305
306    let mut q = sqlx::query(&sql);
307    if let Some(m) = model_filter {
308        q = q.bind(m);
309    }
310    if let Some(a) = action_filter {
311        q = q.bind(a);
312    }
313    q = q.bind(limit);
314
315    let rows = q.fetch_all(db.pool()).await?;
316    rows.iter().map(row_to_action).collect()
317}
318
319/// All actions for one `(model, object_id)`, newest first.
320pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
321    let rows = sqlx::query(
322        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
323                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
324         FROM rustio_admin_actions a
325         LEFT JOIN rustio_users u ON u.id = a.user_id
326         WHERE a.model_name = $1 AND a.object_id = $2
327         ORDER BY a.timestamp DESC, a.id DESC",
328    )
329    .bind(model_name)
330    .bind(object_id)
331    .fetch_all(db.pool())
332    .await?;
333    rows.iter().map(row_to_action).collect()
334}
335
336fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
337    Ok(AdminAction {
338        id: r.try_get("id")?,
339        user_id: r.try_get("user_id")?,
340        user_email: r.try_get("user_email")?,
341        action_type: r.try_get("action_type")?,
342        model_name: r.try_get("model_name")?,
343        object_id: r.try_get("object_id")?,
344        timestamp: r.try_get("timestamp")?,
345        ip_address: r.try_get("ip_address")?,
346        summary: r.try_get("summary")?,
347    })
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    /// Drift test for the internal AuditEvent enum (doctrine 18).
355    ///
356    /// Property: every variant's `as_str()` is unique across the
357    /// enum. Catches accidental copy-paste collisions during R1+
358    /// (`password_reset_self_request` vs
359    /// `password_reset_self_consume` — easy to mis-paste).
360    #[test]
361    fn audit_event_strings_are_unique() {
362        let events = [
363            AuditEvent::UserCreated,
364            AuditEvent::UserUpdated,
365            AuditEvent::UserDeleted,
366            AuditEvent::GroupCreated,
367            AuditEvent::GroupUpdated,
368            AuditEvent::GroupDeleted,
369            AuditEvent::PasswordResetSelfRequest,
370            AuditEvent::PasswordResetSelfConsume,
371            AuditEvent::PasswordResetByOther,
372            AuditEvent::AccountLocked,
373            AuditEvent::AccountUnlocked,
374            AuditEvent::MfaEnabled,
375            AuditEvent::MfaDisabled,
376            AuditEvent::MfaResetByOther,
377            AuditEvent::SessionsRevokedSelf,
378            AuditEvent::SessionsRevokedByOther,
379            AuditEvent::SessionLogout,
380            AuditEvent::EmergencyRecovery,
381        ];
382        let mut set = std::collections::HashSet::new();
383        for e in events {
384            assert!(set.insert(e.as_str()), "duplicate as_str() for {e:?}");
385        }
386        assert_eq!(set.len(), events.len());
387    }
388
389    /// All AuditEvent strings are snake_case ASCII (no whitespace, no
390    /// uppercase, no punctuation beyond `_`). Future SIEM integrations
391    /// will tokenize on these — keep them pre-normalised.
392    #[test]
393    fn audit_event_strings_are_snake_case() {
394        let events = [
395            AuditEvent::UserCreated,
396            AuditEvent::UserUpdated,
397            AuditEvent::UserDeleted,
398            AuditEvent::GroupCreated,
399            AuditEvent::GroupUpdated,
400            AuditEvent::GroupDeleted,
401            AuditEvent::PasswordResetSelfRequest,
402            AuditEvent::PasswordResetSelfConsume,
403            AuditEvent::PasswordResetByOther,
404            AuditEvent::AccountLocked,
405            AuditEvent::AccountUnlocked,
406            AuditEvent::MfaEnabled,
407            AuditEvent::MfaDisabled,
408            AuditEvent::MfaResetByOther,
409            AuditEvent::SessionsRevokedSelf,
410            AuditEvent::SessionsRevokedByOther,
411            AuditEvent::SessionLogout,
412            AuditEvent::EmergencyRecovery,
413        ];
414        for e in events {
415            let s = e.as_str();
416            assert!(!s.is_empty(), "{e:?} as_str is empty");
417            assert!(
418                s.chars()
419                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
420                "{e:?}.as_str() = {s:?} is not snake_case"
421            );
422        }
423    }
424}