dragoon-server 0.1.0

Public-relay server for the dragoon remote-executor: axum + rusqlite + ed25519 task signing + per-user message inbox.
Documentation
//! Append-only audit log table.
//!
//! Mirrors `python/.../server/audit.py`. There is no UPDATE / DELETE
//! path on this table — the log is meant to be cryptographically reviewable.

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,
}

/// Append a single audit row.
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(())
}

/// Read the most recent `n` rows in reverse-chronological order.
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);
        // most recent first
        assert_eq!(rows[0].action, "signed_request");
        assert_eq!(rows[1].action, "login");
        assert_eq!(rows[1].actor.as_deref(), Some("alice"));
    }
}