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;
32use crate::orm::Db;
33
34/// The three classes of admin mutation we track. `delete` covers
35/// both individual and bulk deletions — each bulk-delete row writes
36/// its own `Delete` entry so object history is per-row complete.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ActionType {
39    Create,
40    Update,
41    Delete,
42}
43
44impl ActionType {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Create => "create",
48            Self::Update => "update",
49            Self::Delete => "delete",
50        }
51    }
52
53    /// Parse the DB-level string back into a typed `ActionType`. Named
54    /// `parse` rather than `from_str` so it doesn't shadow the standard
55    /// `FromStr` trait (which returns `Result<_, _>`, not `Option<_>`).
56    pub fn parse(s: &str) -> Option<Self> {
57        match s {
58            "create" => Some(Self::Create),
59            "update" => Some(Self::Update),
60            "delete" => Some(Self::Delete),
61            _ => None,
62        }
63    }
64
65    /// Human-readable label for the timeline.
66    pub fn label(self) -> &'static str {
67        match self {
68            Self::Create => "Created",
69            Self::Update => "Updated",
70            Self::Delete => "Deleted",
71        }
72    }
73
74    /// CSS pill class used by the renderer so the Recent Actions
75    /// timeline reads at a glance.
76    pub fn pill_class(self) -> &'static str {
77        match self {
78            Self::Create => "rio-pill rio-pill-emerald",
79            Self::Update => "rio-pill rio-pill-indigo",
80            Self::Delete => "rio-pill rio-pill-rose",
81        }
82    }
83}
84
85/// One action-log row as loaded from the DB. The `user_email` is
86/// joined in by [`recent`] and [`for_object`] so the timeline can
87/// render the acting user without a second round-trip.
88#[derive(Debug, Clone)]
89pub struct AdminAction {
90    pub id: i64,
91    pub user_id: i64,
92    pub user_email: Option<String>,
93    pub action_type: String,
94    pub model_name: String,
95    pub object_id: i64,
96    pub timestamp: DateTime<Utc>,
97    pub ip_address: Option<String>,
98    pub summary: String,
99}
100
101/// What callers hand to [`record`]. Kept as a borrow-friendly
102/// struct so handlers don't need to clone field strings.
103pub struct LogEntry<'a> {
104    pub user_id: i64,
105    pub action_type: ActionType,
106    pub model_name: &'a str,
107    pub object_id: i64,
108    pub ip_address: Option<&'a str>,
109    pub summary: String,
110}
111
112/// Write one row to the action log.
113///
114/// Validates that `user_id`, `model_name`, and `object_id` are all
115/// present before touching the DB — a missing field returns
116/// [`Error::Internal`] and the caller propagates that as a 500. That
117/// behaviour is deliberate: the admin spec requires "no logging =
118/// FAIL", so a broken audit pipeline must be visible, not silent.
119pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<(), Error> {
120    if entry.user_id <= 0 {
121        return Err(Error::Internal("admin audit: missing user_id".to_string()));
122    }
123    if entry.model_name.trim().is_empty() {
124        return Err(Error::Internal(
125            "admin audit: missing model_name".to_string(),
126        ));
127    }
128    if entry.object_id <= 0 {
129        return Err(Error::Internal(
130            "admin audit: missing object_id".to_string(),
131        ));
132    }
133
134    let now = Utc::now();
135    sqlx::query(
136        "INSERT INTO rustio_admin_actions
137             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
138         VALUES (?, ?, ?, ?, ?, ?, ?)",
139    )
140    .bind(entry.user_id)
141    .bind(entry.action_type.as_str())
142    .bind(entry.model_name)
143    .bind(entry.object_id)
144    .bind(now)
145    .bind(entry.ip_address)
146    .bind(&entry.summary)
147    .execute(db.pool())
148    .await?;
149    Ok(())
150}
151
152/// Fetch the most recent `limit` admin actions, newest first.
153/// Optional filters by `model_name` and by `action_type` string
154/// (the UI passes both through as URL query params, so we take
155/// them as `&str` rather than typed enums).
156pub async fn recent(
157    db: &Db,
158    limit: i64,
159    model_filter: Option<&str>,
160    action_filter: Option<&str>,
161) -> Result<Vec<AdminAction>, Error> {
162    // We build the query defensively with bound params — string
163    // interpolation is confined to `WHERE` branches that only ever
164    // interpolate known column names, never user input.
165    let mut sql = String::from(
166        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
167                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
168         FROM rustio_admin_actions a
169         LEFT JOIN rustio_users u ON u.id = a.user_id",
170    );
171    let mut clauses: Vec<&'static str> = Vec::new();
172    if model_filter.is_some() {
173        clauses.push("a.model_name = ?");
174    }
175    if action_filter.is_some() {
176        clauses.push("a.action_type = ?");
177    }
178    if !clauses.is_empty() {
179        sql.push_str(" WHERE ");
180        sql.push_str(&clauses.join(" AND "));
181    }
182    sql.push_str(" ORDER BY a.timestamp DESC, a.id DESC LIMIT ?");
183
184    let mut q = sqlx::query(&sql);
185    if let Some(m) = model_filter {
186        q = q.bind(m);
187    }
188    if let Some(a) = action_filter {
189        q = q.bind(a);
190    }
191    q = q.bind(limit);
192
193    let rows = q.fetch_all(db.pool()).await?;
194    rows.iter().map(row_to_action).collect()
195}
196
197/// All actions for one `(model, object_id)`, newest first.
198pub async fn for_object(
199    db: &Db,
200    model_name: &str,
201    object_id: i64,
202) -> Result<Vec<AdminAction>, Error> {
203    let rows = sqlx::query(
204        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
205                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
206         FROM rustio_admin_actions a
207         LEFT JOIN rustio_users u ON u.id = a.user_id
208         WHERE a.model_name = ? AND a.object_id = ?
209         ORDER BY a.timestamp DESC, a.id DESC",
210    )
211    .bind(model_name)
212    .bind(object_id)
213    .fetch_all(db.pool())
214    .await?;
215    rows.iter().map(row_to_action).collect()
216}
217
218fn row_to_action(r: &sqlx::sqlite::SqliteRow) -> Result<AdminAction, Error> {
219    Ok(AdminAction {
220        id: r.try_get("id")?,
221        user_id: r.try_get("user_id")?,
222        user_email: r.try_get("user_email")?,
223        action_type: r.try_get("action_type")?,
224        model_name: r.try_get("model_name")?,
225        object_id: r.try_get("object_id")?,
226        timestamp: r.try_get("timestamp")?,
227        ip_address: r.try_get("ip_address")?,
228        summary: r.try_get("summary")?,
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::auth;
236
237    async fn setup() -> Db {
238        let db = Db::memory().await.unwrap();
239        auth::ensure_core_tables(&db).await.unwrap();
240        db
241    }
242
243    async fn seeded_user(db: &Db) -> i64 {
244        auth::user::create(db, "x@y.co", "pw", auth::ROLE_ADMIN)
245            .await
246            .unwrap()
247            .id
248    }
249
250    #[tokio::test]
251    async fn record_round_trip_returns_through_recent() {
252        let db = setup().await;
253        let uid = seeded_user(&db).await;
254        record(
255            &db,
256            LogEntry {
257                user_id: uid,
258                action_type: ActionType::Create,
259                model_name: "tasks",
260                object_id: 1,
261                ip_address: Some("127.0.0.1"),
262                summary: "Created Task #1: Ship".to_string(),
263            },
264        )
265        .await
266        .unwrap();
267
268        let rs = recent(&db, 10, None, None).await.unwrap();
269        assert_eq!(rs.len(), 1);
270        assert_eq!(rs[0].user_id, uid);
271        assert_eq!(rs[0].user_email.as_deref(), Some("x@y.co"));
272        assert_eq!(rs[0].action_type, "create");
273        assert_eq!(rs[0].model_name, "tasks");
274        assert_eq!(rs[0].object_id, 1);
275        assert_eq!(rs[0].summary, "Created Task #1: Ship");
276    }
277
278    #[tokio::test]
279    async fn recent_filters_by_model() {
280        let db = setup().await;
281        let uid = seeded_user(&db).await;
282        for (model, obj) in [("tasks", 1), ("users", 1), ("tasks", 2)] {
283            record(
284                &db,
285                LogEntry {
286                    user_id: uid,
287                    action_type: ActionType::Create,
288                    model_name: model,
289                    object_id: obj,
290                    ip_address: None,
291                    summary: format!("Created {model} #{obj}"),
292                },
293            )
294            .await
295            .unwrap();
296        }
297        let tasks_only = recent(&db, 10, Some("tasks"), None).await.unwrap();
298        assert_eq!(tasks_only.len(), 2);
299        assert!(tasks_only.iter().all(|a| a.model_name == "tasks"));
300    }
301
302    #[tokio::test]
303    async fn recent_filters_by_action_type() {
304        let db = setup().await;
305        let uid = seeded_user(&db).await;
306        record(
307            &db,
308            LogEntry {
309                user_id: uid,
310                action_type: ActionType::Create,
311                model_name: "tasks",
312                object_id: 1,
313                ip_address: None,
314                summary: "c".into(),
315            },
316        )
317        .await
318        .unwrap();
319        record(
320            &db,
321            LogEntry {
322                user_id: uid,
323                action_type: ActionType::Delete,
324                model_name: "tasks",
325                object_id: 1,
326                ip_address: None,
327                summary: "d".into(),
328            },
329        )
330        .await
331        .unwrap();
332        let deletes = recent(&db, 10, None, Some("delete")).await.unwrap();
333        assert_eq!(deletes.len(), 1);
334        assert_eq!(deletes[0].action_type, "delete");
335    }
336
337    #[tokio::test]
338    async fn for_object_returns_newest_first() {
339        let db = setup().await;
340        let uid = seeded_user(&db).await;
341        record(
342            &db,
343            LogEntry {
344                user_id: uid,
345                action_type: ActionType::Create,
346                model_name: "tasks",
347                object_id: 7,
348                ip_address: None,
349                summary: "first".into(),
350            },
351        )
352        .await
353        .unwrap();
354        // tiny sleep so timestamps differ
355        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
356        record(
357            &db,
358            LogEntry {
359                user_id: uid,
360                action_type: ActionType::Update,
361                model_name: "tasks",
362                object_id: 7,
363                ip_address: None,
364                summary: "second".into(),
365            },
366        )
367        .await
368        .unwrap();
369        let hist = for_object(&db, "tasks", 7).await.unwrap();
370        assert_eq!(hist.len(), 2);
371        assert_eq!(hist[0].summary, "second");
372        assert_eq!(hist[1].summary, "first");
373    }
374
375    #[tokio::test]
376    async fn record_rejects_missing_user_id() {
377        let db = setup().await;
378        let err = record(
379            &db,
380            LogEntry {
381                user_id: 0,
382                action_type: ActionType::Create,
383                model_name: "tasks",
384                object_id: 1,
385                ip_address: None,
386                summary: "nope".into(),
387            },
388        )
389        .await;
390        assert!(matches!(err, Err(Error::Internal(_))));
391    }
392
393    #[tokio::test]
394    async fn record_rejects_missing_model() {
395        let db = setup().await;
396        let err = record(
397            &db,
398            LogEntry {
399                user_id: 1,
400                action_type: ActionType::Create,
401                model_name: "",
402                object_id: 1,
403                ip_address: None,
404                summary: "nope".into(),
405            },
406        )
407        .await;
408        assert!(matches!(err, Err(Error::Internal(_))));
409    }
410
411    #[tokio::test]
412    async fn record_rejects_missing_object_id() {
413        let db = setup().await;
414        let err = record(
415            &db,
416            LogEntry {
417                user_id: 1,
418                action_type: ActionType::Create,
419                model_name: "tasks",
420                object_id: 0,
421                ip_address: None,
422                summary: "nope".into(),
423            },
424        )
425        .await;
426        assert!(matches!(err, Err(Error::Internal(_))));
427    }
428
429    #[tokio::test]
430    async fn deleting_a_user_cascades_to_their_actions() {
431        let db = setup().await;
432        let uid = seeded_user(&db).await;
433        record(
434            &db,
435            LogEntry {
436                user_id: uid,
437                action_type: ActionType::Create,
438                model_name: "tasks",
439                object_id: 1,
440                ip_address: None,
441                summary: "c".into(),
442            },
443        )
444        .await
445        .unwrap();
446        sqlx::query("DELETE FROM rustio_users WHERE id = ?")
447            .bind(uid)
448            .execute(db.pool())
449            .await
450            .unwrap();
451        let rs = recent(&db, 10, None, None).await.unwrap();
452        assert!(
453            rs.is_empty(),
454            "FK cascade should have removed the action log entry"
455        );
456    }
457}