Skip to main content

dragoon_server/
audit.rs

1//! Append-only audit log table.
2//!
3//! Mirrors `python/.../server/audit.py`. There is no UPDATE / DELETE
4//! path on this table — the log is meant to be cryptographically reviewable.
5
6use anyhow::Result;
7use chrono::Utc;
8use rusqlite::Connection;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditRow {
14    pub ts: String,
15    pub actor: Option<String>,
16    pub action: String,
17    pub target: Option<String>,
18    pub key_fingerprint: Option<String>,
19    pub metadata: Value,
20}
21
22/// Append a single audit row.
23pub fn log(
24    conn: &Connection,
25    actor: Option<&str>,
26    action: &str,
27    target: Option<&str>,
28    key_fingerprint: Option<&str>,
29    metadata: &Value,
30) -> Result<()> {
31    let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
32    let metadata_json = serde_json::to_string(metadata)?;
33    conn.execute(
34        "INSERT INTO audit_log (ts, actor, action, target, key_fingerprint, metadata_json)
35         VALUES (?,?,?,?,?,?)",
36        rusqlite::params![ts, actor, action, target, key_fingerprint, metadata_json],
37    )?;
38    Ok(())
39}
40
41/// Read the most recent `n` rows in reverse-chronological order.
42pub fn tail(conn: &Connection, n: i64) -> Result<Vec<AuditRow>> {
43    let mut stmt = conn.prepare(
44        "SELECT ts, actor, action, target, key_fingerprint, metadata_json
45         FROM audit_log ORDER BY id DESC LIMIT ?",
46    )?;
47    let rows = stmt
48        .query_map([n], |r| {
49            let metadata_json: Option<String> = r.get(5)?;
50            Ok(AuditRow {
51                ts: r.get(0)?,
52                actor: r.get(1)?,
53                action: r.get(2)?,
54                target: r.get(3)?,
55                key_fingerprint: r.get(4)?,
56                metadata: serde_json::from_str(metadata_json.as_deref().unwrap_or("{}"))
57                    .unwrap_or(Value::Null),
58            })
59        })?
60        .collect::<rusqlite::Result<Vec<_>>>()?;
61    Ok(rows)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    fn fresh() -> Connection {
69        let c = crate::db::connect_in_memory().unwrap();
70        crate::db::bootstrap(&c).unwrap();
71        c
72    }
73
74    #[test]
75    fn write_then_tail() {
76        let c = fresh();
77        log(
78            &c,
79            Some("alice"),
80            "login",
81            Some("/v1/auth/login"),
82            Some("SHA256:fp"),
83            &serde_json::json!({"ok": true}),
84        )
85        .unwrap();
86        log(
87            &c,
88            None,
89            "signed_request",
90            Some("/v1/workers"),
91            None,
92            &serde_json::json!({"reason": "missing_headers"}),
93        )
94        .unwrap();
95        let rows = tail(&c, 5).unwrap();
96        assert_eq!(rows.len(), 2);
97        // most recent first
98        assert_eq!(rows[0].action, "signed_request");
99        assert_eq!(rows[1].action, "login");
100        assert_eq!(rows[1].actor.as_deref(), Some("alice"));
101    }
102}