Skip to main content

postcrate_core/db/
audit.rs

1//! Append-only audit log.
2
3use chrono::Utc;
4use serde::Serialize;
5use sqlx::{Row, SqlitePool};
6
7use crate::error::Result;
8
9#[derive(Debug, Clone, Serialize)]
10#[cfg_attr(feature = "specta", derive(specta::Type))]
11#[serde(rename_all = "camelCase")]
12pub struct AuditEntry {
13    pub id: i64,
14    pub at: i64,
15    pub actor: String,
16    pub action: String,
17    pub target_kind: Option<String>,
18    pub target_id: Option<String>,
19    pub metadata: Option<serde_json::Value>,
20}
21
22#[derive(Debug, Clone)]
23pub(crate) struct AuditAppend {
24    pub actor: String,
25    pub action: String,
26    pub target_kind: Option<String>,
27    pub target_id: Option<String>,
28    pub metadata: Option<serde_json::Value>,
29}
30
31pub(crate) async fn append(pool: &SqlitePool, entry: AuditAppend) -> Result<AuditEntry> {
32    let now = Utc::now().timestamp_millis();
33    let metadata_json = entry
34        .metadata
35        .as_ref()
36        .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "null".into()));
37
38    let res = sqlx::query(
39        r"INSERT INTO audit_log (at, actor, action, target_kind, target_id, metadata_json)
40          VALUES (?, ?, ?, ?, ?, ?)",
41    )
42    .bind(now)
43    .bind(&entry.actor)
44    .bind(&entry.action)
45    .bind(&entry.target_kind)
46    .bind(&entry.target_id)
47    .bind(&metadata_json)
48    .execute(pool)
49    .await?;
50
51    Ok(AuditEntry {
52        id: res.last_insert_rowid(),
53        at: now,
54        actor: entry.actor,
55        action: entry.action,
56        target_kind: entry.target_kind,
57        target_id: entry.target_id,
58        metadata: entry.metadata,
59    })
60}
61
62pub(crate) async fn list(pool: &SqlitePool, limit: u32, offset: u32) -> Result<Vec<AuditEntry>> {
63    let rows = sqlx::query(
64        r"SELECT id, at, actor, action, target_kind, target_id, metadata_json
65          FROM audit_log
66          ORDER BY at DESC
67          LIMIT ? OFFSET ?",
68    )
69    .bind(i64::from(limit))
70    .bind(i64::from(offset))
71    .fetch_all(pool)
72    .await?;
73    Ok(rows.iter().map(row_to_entry).collect())
74}
75
76pub(crate) async fn prune_older_than(pool: &SqlitePool, days: u32) -> Result<u64> {
77    let cutoff = Utc::now().timestamp_millis() - (i64::from(days) * 86_400_000);
78    let res = sqlx::query("DELETE FROM audit_log WHERE at < ?")
79        .bind(cutoff)
80        .execute(pool)
81        .await?;
82    Ok(res.rows_affected())
83}
84
85pub(crate) async fn clear_all(pool: &SqlitePool) -> Result<u64> {
86    let res = sqlx::query("DELETE FROM audit_log").execute(pool).await?;
87    Ok(res.rows_affected())
88}
89
90fn row_to_entry(row: &sqlx::sqlite::SqliteRow) -> AuditEntry {
91    let metadata_json: Option<String> = row.try_get("metadata_json").ok();
92    let metadata = metadata_json
93        .as_deref()
94        .and_then(|s| serde_json::from_str(s).ok());
95    AuditEntry {
96        id: row.try_get("id").unwrap_or(0),
97        at: row.try_get("at").unwrap_or(0),
98        actor: row.try_get("actor").unwrap_or_default(),
99        action: row.try_get("action").unwrap_or_default(),
100        target_kind: row.try_get("target_kind").ok(),
101        target_id: row.try_get("target_id").ok(),
102        metadata,
103    }
104}