use anyhow::Result;
use chrono::Utc;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditRow {
pub ts: String,
pub actor: Option<String>,
pub action: String,
pub target: Option<String>,
pub key_fingerprint: Option<String>,
pub metadata: Value,
}
pub fn log(
conn: &Connection,
actor: Option<&str>,
action: &str,
target: Option<&str>,
key_fingerprint: Option<&str>,
metadata: &Value,
) -> Result<()> {
let ts = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
let metadata_json = serde_json::to_string(metadata)?;
conn.execute(
"INSERT INTO audit_log (ts, actor, action, target, key_fingerprint, metadata_json)
VALUES (?,?,?,?,?,?)",
rusqlite::params![ts, actor, action, target, key_fingerprint, metadata_json],
)?;
Ok(())
}
pub fn tail(conn: &Connection, n: i64) -> Result<Vec<AuditRow>> {
let mut stmt = conn.prepare(
"SELECT ts, actor, action, target, key_fingerprint, metadata_json
FROM audit_log ORDER BY id DESC LIMIT ?",
)?;
let rows = stmt
.query_map([n], |r| {
let metadata_json: Option<String> = r.get(5)?;
Ok(AuditRow {
ts: r.get(0)?,
actor: r.get(1)?,
action: r.get(2)?,
target: r.get(3)?,
key_fingerprint: r.get(4)?,
metadata: serde_json::from_str(metadata_json.as_deref().unwrap_or("{}"))
.unwrap_or(Value::Null),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh() -> Connection {
let c = crate::db::connect_in_memory().unwrap();
crate::db::bootstrap(&c).unwrap();
c
}
#[test]
fn write_then_tail() {
let c = fresh();
log(
&c,
Some("alice"),
"login",
Some("/v1/auth/login"),
Some("SHA256:fp"),
&serde_json::json!({"ok": true}),
)
.unwrap();
log(
&c,
None,
"signed_request",
Some("/v1/workers"),
None,
&serde_json::json!({"reason": "missing_headers"}),
)
.unwrap();
let rows = tail(&c, 5).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].action, "signed_request");
assert_eq!(rows[1].action, "login");
assert_eq!(rows[1].actor.as_deref(), Some("alice"));
}
}