Skip to main content

rustio_core/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/actions` — project-wide timeline with filters.
6//! - `GET /admin/<model>/<id>/history` — per-object history.
7//!
8//! The table ships in [`crate::auth::ensure_core_tables`] and is
9//! FK-cascaded to `rustio_users`: deleting a user wipes the log
10//! entries they produced, matching how sessions cascade.
11//!
12//! ## Integrity
13//!
14//! [`record`] rejects entries that are missing any of `user_id`,
15//! `model_name`, or `object_id`. The caller gets an
16//! [`Error::Internal`] so the admin handler can fail loudly rather
17//! than silently losing the audit trail — that's what the spec
18//! means by *"No logging = FAIL"*.
19//!
20//! ## Not included in 0.4
21//!
22//! - Per-field diff of what changed on update (requires reading the
23//!   pre-update row and diffing; deferred).
24//! - Retention / pruning (no cron). Projects that need a bounded
25//!   log should run `DELETE FROM rustio_admin_actions WHERE
26//!   timestamp < …` on their own cadence.
27
28use chrono::{DateTime, Utc};
29use sqlx::Row as _;
30
31use crate::error::{Error, Result};
32use crate::orm::Db;
33
34/// `CREATE TABLE` for the action log. Kept co-located with the audit
35/// module rather than in `auth::*` so every file that talks to
36/// `rustio_admin_actions` is within one `grep` of the schema it
37/// depends on. Applied via [`ensure_table`] — mirrors the runtime
38/// `CREATE TABLE IF NOT EXISTS` pattern already used by
39/// `auth::init_user_tables` / `auth::init_sessions_table`.
40pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
41    id          BIGSERIAL   PRIMARY KEY,
42    user_id     BIGINT      NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
43    action_type TEXT        NOT NULL,
44    model_name  TEXT        NOT NULL,
45    object_id   BIGINT      NOT NULL,
46    timestamp   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
47    ip_address  TEXT,
48    summary     TEXT        NOT NULL DEFAULT ''
49)";
50
51pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
52    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
53     ON rustio_admin_actions(model_name, object_id)";
54
55pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
56    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
57     ON rustio_admin_actions(timestamp DESC)";
58
59/// Ensure the `rustio_admin_actions` table and its indexes exist.
60/// Idempotent — uses `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`.
61/// Depends on `rustio_users` existing first (the FK target); callers
62/// should run `auth::init_user_tables` before this.
63pub async fn ensure_table(db: &Db) -> Result<()> {
64    sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
65    sqlx::query(CREATE_MODEL_INDEX_SQL)
66        .execute(db.pool())
67        .await?;
68    sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
69        .execute(db.pool())
70        .await?;
71    Ok(())
72}
73
74/// The three classes of admin mutation we track. `delete` covers
75/// both individual and bulk deletions — each bulk-delete row writes
76/// its own `Delete` entry so object history is per-row complete.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum ActionType {
79    Create,
80    Update,
81    Delete,
82}
83
84impl ActionType {
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Create => "create",
88            Self::Update => "update",
89            Self::Delete => "delete",
90        }
91    }
92
93    /// Parse the DB-level string back into a typed `ActionType`. Named
94    /// `parse` rather than `from_str` so it doesn't shadow the standard
95    /// `FromStr` trait (which returns `Result<_, _>`, not `Option<_>`).
96    pub fn parse(s: &str) -> Option<Self> {
97        match s {
98            "create" => Some(Self::Create),
99            "update" => Some(Self::Update),
100            "delete" => Some(Self::Delete),
101            _ => None,
102        }
103    }
104
105    /// Human-readable label for the timeline.
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    /// CSS pill class used by the renderer so the Recent Actions
115    /// timeline reads at a glance.
116    pub fn pill_class(self) -> &'static str {
117        match self {
118            Self::Create => "badge-success",
119            Self::Update => "badge-neutral",
120            Self::Delete => "badge-danger",
121        }
122    }
123}
124
125/// One action-log row as loaded from the DB. The `user_email` is
126/// joined in by [`recent`] and [`for_object`] so the timeline can
127/// render the acting user without a second round-trip.
128#[derive(Debug, Clone)]
129pub struct AdminAction {
130    pub id: i64,
131    pub user_id: i64,
132    pub user_email: Option<String>,
133    pub action_type: String,
134    pub model_name: String,
135    pub object_id: i64,
136    pub timestamp: DateTime<Utc>,
137    pub ip_address: Option<String>,
138    pub summary: String,
139}
140
141/// What callers hand to [`record`]. Kept as a borrow-friendly
142/// struct so handlers don't need to clone field strings.
143pub struct LogEntry<'a> {
144    pub user_id: i64,
145    pub action_type: ActionType,
146    pub model_name: &'a str,
147    pub object_id: i64,
148    pub ip_address: Option<&'a str>,
149    pub summary: String,
150}
151
152/// Write one row to the action log.
153///
154/// Validates that `user_id`, `model_name`, and `object_id` are all
155/// present before touching the DB — a missing field returns
156/// [`Error::Internal`] and the caller propagates that as a 500. That
157/// behaviour is deliberate: the admin spec requires "no logging =
158/// FAIL", so a broken audit pipeline must be visible, not silent.
159pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
160    if entry.user_id <= 0 {
161        return Err(Error::Internal("admin audit: missing user_id".to_string()));
162    }
163    if entry.model_name.trim().is_empty() {
164        return Err(Error::Internal(
165            "admin audit: missing model_name".to_string(),
166        ));
167    }
168    if entry.object_id <= 0 {
169        return Err(Error::Internal(
170            "admin audit: missing object_id".to_string(),
171        ));
172    }
173
174    let now = Utc::now();
175    sqlx::query(
176        "INSERT INTO rustio_admin_actions
177             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
178         VALUES ($1, $2, $3, $4, $5, $6, $7)",
179    )
180    .bind(entry.user_id)
181    .bind(entry.action_type.as_str())
182    .bind(entry.model_name)
183    .bind(entry.object_id)
184    .bind(now)
185    .bind(entry.ip_address)
186    .bind(&entry.summary)
187    .execute(db.pool())
188    .await?;
189    Ok(())
190}
191
192/// Fetch the most recent `limit` admin actions, newest first.
193/// Optional filters by `model_name` and by `action_type` string
194/// (the UI passes both through as URL query params, so we take
195/// them as `&str` rather than typed enums).
196pub async fn recent(
197    db: &Db,
198    limit: i64,
199    model_filter: Option<&str>,
200    action_filter: Option<&str>,
201) -> Result<Vec<AdminAction>> {
202    // We build the query defensively with bound params — string
203    // interpolation is confined to `WHERE` branches that only ever
204    // interpolate `$N` placeholders, never user input.
205    let mut sql = String::from(
206        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
207                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
208         FROM rustio_admin_actions a
209         LEFT JOIN rustio_users u ON u.id = a.user_id",
210    );
211    let mut clauses: Vec<String> = Vec::new();
212    let mut param_idx: usize = 1;
213    if model_filter.is_some() {
214        clauses.push(format!("a.model_name = ${param_idx}"));
215        param_idx += 1;
216    }
217    if action_filter.is_some() {
218        clauses.push(format!("a.action_type = ${param_idx}"));
219        param_idx += 1;
220    }
221    if !clauses.is_empty() {
222        sql.push_str(" WHERE ");
223        sql.push_str(&clauses.join(" AND "));
224    }
225    sql.push_str(&format!(
226        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
227    ));
228
229    let mut q = sqlx::query(&sql);
230    if let Some(m) = model_filter {
231        q = q.bind(m);
232    }
233    if let Some(a) = action_filter {
234        q = q.bind(a);
235    }
236    q = q.bind(limit);
237
238    let rows = q.fetch_all(db.pool()).await?;
239    rows.iter().map(row_to_action).collect()
240}
241
242/// All actions for one `(model, object_id)`, newest first.
243pub async fn for_object(
244    db: &Db,
245    model_name: &str,
246    object_id: i64,
247) -> Result<Vec<AdminAction>> {
248    let rows = sqlx::query(
249        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
250                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
251         FROM rustio_admin_actions a
252         LEFT JOIN rustio_users u ON u.id = a.user_id
253         WHERE a.model_name = $1 AND a.object_id = $2
254         ORDER BY a.timestamp DESC, a.id DESC",
255    )
256    .bind(model_name)
257    .bind(object_id)
258    .fetch_all(db.pool())
259    .await?;
260    rows.iter().map(row_to_action).collect()
261}
262
263fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
264    Ok(AdminAction {
265        id: r.try_get("id")?,
266        user_id: r.try_get("user_id")?,
267        user_email: r.try_get("user_email")?,
268        action_type: r.try_get("action_type")?,
269        model_name: r.try_get("model_name")?,
270        object_id: r.try_get("object_id")?,
271        timestamp: r.try_get("timestamp")?,
272        ip_address: r.try_get("ip_address")?,
273        summary: r.try_get("summary")?,
274    })
275}
276