Skip to main content

batuta/serve/banco/
audit.rs

1//! Request audit logging for Banco.
2//!
3//! Logs every API request as a JSON line to an in-memory buffer.
4//! Phase 1: in-memory ring buffer. Phase 2+: append to `~/.banco/audit.jsonl`.
5
6use axum::{
7    body::Body,
8    http::{Method, Request, Response, StatusCode},
9    middleware::Next,
10};
11use serde::Serialize;
12use std::sync::{Arc, Mutex};
13use std::time::{Instant, SystemTime, UNIX_EPOCH};
14
15/// A single audit log entry.
16#[derive(Debug, Clone, Serialize)]
17pub struct AuditEntry {
18    pub ts: String,
19    pub method: String,
20    pub path: String,
21    pub status: u16,
22    pub latency_ms: u64,
23}
24
25/// In-memory audit log (ring buffer, max 10,000 entries) with optional disk persistence.
26#[derive(Debug, Clone)]
27pub struct AuditLog {
28    entries: Arc<Mutex<Vec<AuditEntry>>>,
29    /// Path to append audit entries as JSONL (None = in-memory only).
30    log_path: Option<std::path::PathBuf>,
31}
32
33const MAX_ENTRIES: usize = 10_000;
34
35impl AuditLog {
36    #[must_use]
37    pub fn new() -> Self {
38        Self { entries: Arc::new(Mutex::new(Vec::with_capacity(256))), log_path: None }
39    }
40
41    /// Create an audit log that also appends to a JSONL file.
42    #[must_use]
43    pub fn with_file(path: std::path::PathBuf) -> Self {
44        if let Some(parent) = path.parent() {
45            let _ = std::fs::create_dir_all(parent);
46        }
47        Self { entries: Arc::new(Mutex::new(Vec::with_capacity(256))), log_path: Some(path) }
48    }
49
50    pub fn push(&self, entry: AuditEntry) {
51        // Append to disk if configured
52        if let Some(ref path) = self.log_path {
53            if let Ok(json) = serde_json::to_string(&entry) {
54                let _ = std::fs::OpenOptions::new().create(true).append(true).open(path).and_then(
55                    |mut f| {
56                        use std::io::Write;
57                        writeln!(f, "{json}")
58                    },
59                );
60            }
61        }
62
63        if let Ok(mut entries) = self.entries.lock() {
64            if entries.len() >= MAX_ENTRIES {
65                entries.remove(0);
66            }
67            entries.push(entry);
68        }
69    }
70
71    #[must_use]
72    pub fn recent(&self, limit: usize) -> Vec<AuditEntry> {
73        self.entries
74            .lock()
75            .map(|e| e.iter().rev().take(limit).cloned().collect())
76            .unwrap_or_default()
77    }
78
79    #[must_use]
80    pub fn len(&self) -> usize {
81        self.entries.lock().map(|e| e.len()).unwrap_or(0)
82    }
83
84    #[must_use]
85    pub fn is_empty(&self) -> bool {
86        self.len() == 0
87    }
88
89    /// Get the log file path (if configured).
90    #[must_use]
91    pub fn log_path(&self) -> Option<&std::path::Path> {
92        self.log_path.as_deref()
93    }
94}
95
96impl Default for AuditLog {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102/// Axum middleware that logs every request to the audit log.
103pub async fn audit_layer(
104    audit_log: AuditLog,
105    request: Request<Body>,
106    next: Next,
107) -> Response<Body> {
108    let method = request.method().clone();
109    let path = request.uri().path().to_string();
110    let start = Instant::now();
111
112    let response = next.run(request).await;
113
114    let entry = AuditEntry {
115        ts: iso_now(),
116        method: method.to_string(),
117        path,
118        status: response.status().as_u16(),
119        latency_ms: start.elapsed().as_millis() as u64,
120    };
121    audit_log.push(entry);
122
123    response
124}
125
126fn iso_now() -> String {
127    let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
128    // Simple ISO-ish format without chrono dependency
129    format!("{secs}")
130}