batuta/serve/banco/
audit.rs1use 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#[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#[derive(Debug, Clone)]
27pub struct AuditLog {
28 entries: Arc<Mutex<Vec<AuditEntry>>>,
29 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 #[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 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 #[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
102pub 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 format!("{secs}")
130}