rustio-core 1.3.1

RustIO runtime library: HTTP, router, Postgres ORM, admin, RBAC, search, migrations, AI planner.
Documentation
//! Admin action log — every create / update / delete driven through
//! the admin writes a row to `rustio_admin_actions`. The audit trail
//! powers two user-visible surfaces:
//!
//! - `GET /admin/actions` — project-wide timeline with filters.
//! - `GET /admin/<model>/<id>/history` — per-object history.
//!
//! The table ships in [`crate::auth::ensure_core_tables`] and is
//! FK-cascaded to `rustio_users`: deleting a user wipes the log
//! entries they produced, matching how sessions cascade.
//!
//! ## Integrity
//!
//! [`record`] rejects entries that are missing any of `user_id`,
//! `model_name`, or `object_id`. The caller gets an
//! [`Error::Internal`] so the admin handler can fail loudly rather
//! than silently losing the audit trail — that's what the spec
//! means by *"No logging = FAIL"*.
//!
//! ## Not included in 0.4
//!
//! - Per-field diff of what changed on update (requires reading the
//!   pre-update row and diffing; deferred).
//! - Retention / pruning (no cron). Projects that need a bounded
//!   log should run `DELETE FROM rustio_admin_actions WHERE
//!   timestamp < …` on their own cadence.

use chrono::{DateTime, Utc};
use sqlx::Row as _;

use crate::error::{Error, Result};
use crate::orm::Db;

/// `CREATE TABLE` for the action log. Kept co-located with the audit
/// module rather than in `auth::*` so every file that talks to
/// `rustio_admin_actions` is within one `grep` of the schema it
/// depends on. Applied via [`ensure_table`] — mirrors the runtime
/// `CREATE TABLE IF NOT EXISTS` pattern already used by
/// `auth::init_user_tables` / `auth::init_sessions_table`.
pub(crate) const CREATE_TABLE_SQL: &str = "CREATE TABLE IF NOT EXISTS rustio_admin_actions (
    id          BIGSERIAL   PRIMARY KEY,
    user_id     BIGINT      NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
    action_type TEXT        NOT NULL,
    model_name  TEXT        NOT NULL,
    object_id   BIGINT      NOT NULL,
    timestamp   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ip_address  TEXT,
    summary     TEXT        NOT NULL DEFAULT ''
)";

pub(crate) const CREATE_MODEL_INDEX_SQL: &str =
    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_model_idx \
     ON rustio_admin_actions(model_name, object_id)";

pub(crate) const CREATE_TIMESTAMP_INDEX_SQL: &str =
    "CREATE INDEX IF NOT EXISTS rustio_admin_actions_timestamp_idx \
     ON rustio_admin_actions(timestamp DESC)";

/// Ensure the `rustio_admin_actions` table and its indexes exist.
/// Idempotent — uses `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`.
/// Depends on `rustio_users` existing first (the FK target); callers
/// should run `auth::init_user_tables` before this.
pub async fn ensure_table(db: &Db) -> Result<()> {
    sqlx::query(CREATE_TABLE_SQL).execute(db.pool()).await?;
    sqlx::query(CREATE_MODEL_INDEX_SQL)
        .execute(db.pool())
        .await?;
    sqlx::query(CREATE_TIMESTAMP_INDEX_SQL)
        .execute(db.pool())
        .await?;
    Ok(())
}

/// The three classes of admin mutation we track. `delete` covers
/// both individual and bulk deletions — each bulk-delete row writes
/// its own `Delete` entry so object history is per-row complete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionType {
    Create,
    Update,
    Delete,
}

impl ActionType {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Create => "create",
            Self::Update => "update",
            Self::Delete => "delete",
        }
    }

    /// Parse the DB-level string back into a typed `ActionType`. Named
    /// `parse` rather than `from_str` so it doesn't shadow the standard
    /// `FromStr` trait (which returns `Result<_, _>`, not `Option<_>`).
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "create" => Some(Self::Create),
            "update" => Some(Self::Update),
            "delete" => Some(Self::Delete),
            _ => None,
        }
    }

    /// Human-readable label for the timeline.
    pub fn label(self) -> &'static str {
        match self {
            Self::Create => "Created",
            Self::Update => "Updated",
            Self::Delete => "Deleted",
        }
    }

    /// CSS pill class used by the renderer so the Recent Actions
    /// timeline reads at a glance.
    pub fn pill_class(self) -> &'static str {
        match self {
            Self::Create => "badge-success",
            Self::Update => "badge-neutral",
            Self::Delete => "badge-danger",
        }
    }
}

/// One action-log row as loaded from the DB. The `user_email` is
/// joined in by [`recent`] and [`for_object`] so the timeline can
/// render the acting user without a second round-trip.
#[derive(Debug, Clone)]
pub struct AdminAction {
    pub id: i64,
    pub user_id: i64,
    pub user_email: Option<String>,
    pub action_type: String,
    pub model_name: String,
    pub object_id: i64,
    pub timestamp: DateTime<Utc>,
    pub ip_address: Option<String>,
    pub summary: String,
}

/// What callers hand to [`record`]. Kept as a borrow-friendly
/// struct so handlers don't need to clone field strings.
pub struct LogEntry<'a> {
    pub user_id: i64,
    pub action_type: ActionType,
    pub model_name: &'a str,
    pub object_id: i64,
    pub ip_address: Option<&'a str>,
    pub summary: String,
}

/// Write one row to the action log.
///
/// Validates that `user_id`, `model_name`, and `object_id` are all
/// present before touching the DB — a missing field returns
/// [`Error::Internal`] and the caller propagates that as a 500. That
/// behaviour is deliberate: the admin spec requires "no logging =
/// FAIL", so a broken audit pipeline must be visible, not silent.
pub async fn record(db: &Db, entry: LogEntry<'_>) -> Result<()> {
    if entry.user_id <= 0 {
        return Err(Error::Internal("admin audit: missing user_id".to_string()));
    }
    if entry.model_name.trim().is_empty() {
        return Err(Error::Internal(
            "admin audit: missing model_name".to_string(),
        ));
    }
    if entry.object_id <= 0 {
        return Err(Error::Internal(
            "admin audit: missing object_id".to_string(),
        ));
    }

    let now = Utc::now();
    sqlx::query(
        "INSERT INTO rustio_admin_actions
             (user_id, action_type, model_name, object_id, timestamp, ip_address, summary)
         VALUES ($1, $2, $3, $4, $5, $6, $7)",
    )
    .bind(entry.user_id)
    .bind(entry.action_type.as_str())
    .bind(entry.model_name)
    .bind(entry.object_id)
    .bind(now)
    .bind(entry.ip_address)
    .bind(&entry.summary)
    .execute(db.pool())
    .await?;
    Ok(())
}

/// Fetch the most recent `limit` admin actions, newest first.
/// Optional filters by `model_name` and by `action_type` string
/// (the UI passes both through as URL query params, so we take
/// them as `&str` rather than typed enums).
pub async fn recent(
    db: &Db,
    limit: i64,
    model_filter: Option<&str>,
    action_filter: Option<&str>,
) -> Result<Vec<AdminAction>> {
    // We build the query defensively with bound params — string
    // interpolation is confined to `WHERE` branches that only ever
    // interpolate `$N` placeholders, never user input.
    let mut sql = String::from(
        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
         FROM rustio_admin_actions a
         LEFT JOIN rustio_users u ON u.id = a.user_id",
    );
    let mut clauses: Vec<String> = Vec::new();
    let mut param_idx: usize = 1;
    if model_filter.is_some() {
        clauses.push(format!("a.model_name = ${param_idx}"));
        param_idx += 1;
    }
    if action_filter.is_some() {
        clauses.push(format!("a.action_type = ${param_idx}"));
        param_idx += 1;
    }
    if !clauses.is_empty() {
        sql.push_str(" WHERE ");
        sql.push_str(&clauses.join(" AND "));
    }
    sql.push_str(&format!(
        " ORDER BY a.timestamp DESC, a.id DESC LIMIT ${param_idx}"
    ));

    let mut q = sqlx::query(&sql);
    if let Some(m) = model_filter {
        q = q.bind(m);
    }
    if let Some(a) = action_filter {
        q = q.bind(a);
    }
    q = q.bind(limit);

    let rows = q.fetch_all(db.pool()).await?;
    rows.iter().map(row_to_action).collect()
}

/// All actions for one `(model, object_id)`, newest first.
pub async fn for_object(
    db: &Db,
    model_name: &str,
    object_id: i64,
) -> Result<Vec<AdminAction>> {
    let rows = sqlx::query(
        "SELECT a.id, a.user_id, u.email AS user_email, a.action_type,
                a.model_name, a.object_id, a.timestamp, a.ip_address, a.summary
         FROM rustio_admin_actions a
         LEFT JOIN rustio_users u ON u.id = a.user_id
         WHERE a.model_name = $1 AND a.object_id = $2
         ORDER BY a.timestamp DESC, a.id DESC",
    )
    .bind(model_name)
    .bind(object_id)
    .fetch_all(db.pool())
    .await?;
    rows.iter().map(row_to_action).collect()
}

fn row_to_action(r: &sqlx::postgres::PgRow) -> Result<AdminAction> {
    Ok(AdminAction {
        id: r.try_get("id")?,
        user_id: r.try_get("user_id")?,
        user_email: r.try_get("user_email")?,
        action_type: r.try_get("action_type")?,
        model_name: r.try_get("model_name")?,
        object_id: r.try_get("object_id")?,
        timestamp: r.try_get("timestamp")?,
        ip_address: r.try_get("ip_address")?,
        summary: r.try_get("summary")?,
    })
}