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.
42pub async fn ensure_table(db: &Db) -> Result<()> {
43    sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
44    sqlx::query(CREATE_MODEL_INDEX_SQL)
45        .execute(db.pool())
46        .await?;
47    sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
48        .execute(db.pool())
49        .await?;
50    Ok(())
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ActionType {
55    Create,
56    Update,
57    Delete,
58}
59
60impl ActionType {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Create => "create",
64            Self::Update => "update",
65            Self::Delete => "delete",
66        }
67    }
68
69    pub fn parse(s: &str) -> Option<Self> {
70        match s {
71            "create" => Some(Self::Create),
72            "update" => Some(Self::Update),
73            "delete" => Some(Self::Delete),
74            _ => None,
75        }
76    }
77
78    pub fn label(self) -> &'static str {
79        match self {
80            Self::Create => "Created",
81            Self::Update => "Updated",
82            Self::Delete => "Deleted",
83        }
84    }
85
86    pub fn pill_class(self) -> &'static str {
87        match self {
88            Self::Create => "badge-success",
89            Self::Update => "badge-neutral",
90            Self::Delete => "badge-danger",
91        }
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct AdminAction {
97    pub id: i64,
98    pub user_id: i64,
99    pub user_email: Option<String>,
100    pub action_type: String,
101    pub model_name: String,
102    pub object_id: i64,
103    pub timestamp: DateTime<Utc>,
104    pub ip_address: Option<String>,
105    pub summary: String,
106}
107
108pub struct LogEntry<'a> {
109    pub user_id: i64,
110    pub action_type: ActionType,
111    pub model_name: &'a str,
112    pub object_id: i64,
113    pub ip_address: Option<&'a str>,
114    pub summary: String,
115}
116
117/// Write one row to the action log. Validates required fields before
118/// touching the DB so a broken audit pipeline becomes visible.
119pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
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 ($1, $2, $3, $4, $5, $6, $7)",
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.
153pub async fn recent(
154    db: &Db,
155    limit: i64,
156    model_filter: Option<&str>,
157    action_filter: Option<&str>,
158) -> Result<Vec<AdminAction>> {
159    let mut sql = String::from(
160        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
161                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
162         FROM rustio_admin_actions a
163         LEFT JOIN rustio_users u ON u.id = a.user_id",
164    );
165    let mut clauses: Vec<String> = Vec::new();
166    let mut param_idx: usize = 1;
167    if model_filter.is_some() {
168        clauses.push(format!("a.model_name = ${param_idx}"));
169        param_idx += 1;
170    }
171    if action_filter.is_some() {
172        clauses.push(format!("a.action_type = ${param_idx}"));
173        param_idx += 1;
174    }
175    if !clauses.is_empty() {
176        sql.push_str(" WHERE ");
177        sql.push_str(&clauses.join(" AND "));
178    }
179    sql.push_str(&format!(
180        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
181    ));
182
183    let mut q = sqlx::query(&sql);
184    if let Some(m) = model_filter {
185        q = q.bind(m);
186    }
187    if let Some(a) = action_filter {
188        q = q.bind(a);
189    }
190    q = q.bind(limit);
191
192    let rows = q.fetch_all(db.pool()).await?;
193    rows.iter().map(row_to_action).collect()
194}
195
196/// All actions for one `(model, object_id)`, newest first.
197pub async fn for_object(db: &Db, model_name: &str, object_id: i64) -> Result<Vec<AdminAction>> {
198    let rows = sqlx::query(
199        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
200                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
201         FROM rustio_admin_actions a
202         LEFT JOIN rustio_users u ON u.id = a.user_id
203         WHERE a.model_name = $1 AND a.object_id = $2
204         ORDER BY a.timestamp DESC, a.id DESC",
205    )
206    .bind(model_name)
207    .bind(object_id)
208    .fetch_all(db.pool())
209    .await?;
210    rows.iter().map(row_to_action).collect()
211}
212
213fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
214    Ok(AdminAction {
215        id: r.try_get("id")?,
216        user_id: r.try_get("user_id")?,
217        user_email: r.try_get("user_email")?,
218        action_type: r.try_get("action_type")?,
219        model_name: r.try_get("model_name")?,
220        object_id: r.try_get("object_id")?,
221        timestamp: r.try_get("timestamp")?,
222        ip_address: r.try_get("ip_address")?,
223        summary: r.try_get("summary")?,
224    })
225}