1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::VecDeque;
11use std::path::PathBuf;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14use uuid::Uuid;
15
16const DEFAULT_MAX_ENTRIES: usize = 10_000;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AuditCategory {
23 Api,
25 ToolExecution,
27 Session,
29 Cognition,
31 Swarm,
33 Auth,
35 K8s,
37 Sandbox,
39 Config,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum AuditOutcome {
47 Success,
48 Failure,
49 Denied,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AuditEntry {
55 pub id: String,
57 pub timestamp: DateTime<Utc>,
59 pub category: AuditCategory,
61 pub action: String,
63 pub principal: Option<String>,
65 pub outcome: AuditOutcome,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub detail: Option<serde_json::Value>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub duration_ms: Option<u64>,
73}
74
75#[derive(Clone)]
77pub struct AuditLog {
78 entries: Arc<RwLock<VecDeque<AuditEntry>>>,
79 max_entries: usize,
80 sink_path: Option<PathBuf>,
82}
83
84impl std::fmt::Debug for AuditLog {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("AuditLog")
87 .field("max_entries", &self.max_entries)
88 .field("sink_path", &self.sink_path)
89 .finish()
90 }
91}
92
93impl Default for AuditLog {
94 fn default() -> Self {
95 Self::new(DEFAULT_MAX_ENTRIES, None)
96 }
97}
98
99impl AuditLog {
100 pub fn new(max_entries: usize, sink_path: Option<PathBuf>) -> Self {
102 Self {
103 entries: Arc::new(RwLock::new(VecDeque::with_capacity(
104 max_entries.min(DEFAULT_MAX_ENTRIES),
105 ))),
106 max_entries: max_entries.max(128),
107 sink_path,
108 }
109 }
110
111 pub fn from_env() -> Self {
113 let max = std::env::var("CODETETHER_AUDIT_MAX_ENTRIES")
114 .ok()
115 .and_then(|v| v.parse().ok())
116 .unwrap_or(DEFAULT_MAX_ENTRIES);
117
118 let path = std::env::var("CODETETHER_AUDIT_LOG_PATH")
119 .ok()
120 .map(PathBuf::from);
121
122 Self::new(max, path)
123 }
124
125 pub async fn record(&self, entry: AuditEntry) {
127 if let Some(ref path) = self.sink_path {
129 if let Ok(line) = serde_json::to_string(&entry) {
130 use tokio::io::AsyncWriteExt;
131 if let Ok(mut file) = tokio::fs::OpenOptions::new()
132 .create(true)
133 .append(true)
134 .open(path)
135 .await
136 {
137 let _ = file.write_all(line.as_bytes()).await;
138 let _ = file.write_all(b"\n").await;
139 }
140 }
141 }
142
143 tracing::info!(
145 audit_id = %entry.id,
146 category = ?entry.category,
147 action = %entry.action,
148 outcome = ?entry.outcome,
149 principal = entry.principal.as_deref().unwrap_or("-"),
150 "audit"
151 );
152
153 let mut lock = self.entries.write().await;
154 lock.push_back(entry);
155 while lock.len() > self.max_entries {
156 lock.pop_front();
157 }
158 }
159
160 pub async fn log(
162 &self,
163 category: AuditCategory,
164 action: impl Into<String>,
165 outcome: AuditOutcome,
166 principal: Option<String>,
167 detail: Option<serde_json::Value>,
168 ) {
169 self.record(AuditEntry {
170 id: Uuid::new_v4().to_string(),
171 timestamp: Utc::now(),
172 category,
173 action: action.into(),
174 principal,
175 outcome,
176 detail,
177 duration_ms: None,
178 })
179 .await;
180 }
181
182 pub async fn recent(&self, limit: usize) -> Vec<AuditEntry> {
184 let lock = self.entries.read().await;
185 lock.iter().rev().take(limit).cloned().collect()
186 }
187
188 pub async fn count(&self) -> usize {
190 self.entries.read().await.len()
191 }
192
193 pub async fn by_category(&self, category: AuditCategory, limit: usize) -> Vec<AuditEntry> {
195 let lock = self.entries.read().await;
196 lock.iter()
197 .rev()
198 .filter(|e| e.category == category)
199 .take(limit)
200 .cloned()
201 .collect()
202 }
203}
204
205static AUDIT_LOG: tokio::sync::OnceCell<AuditLog> = tokio::sync::OnceCell::const_new();
207
208pub fn init_audit_log(log: AuditLog) -> Result<(), AuditLog> {
210 AUDIT_LOG.set(log).map_err(|_| AuditLog::default())
211}
212
213pub fn audit_log() -> &'static AuditLog {
215 AUDIT_LOG
216 .get()
217 .expect("Audit log not initialized — call init_audit_log() at startup")
218}
219
220pub fn try_audit_log() -> Option<&'static AuditLog> {
222 AUDIT_LOG.get()
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[tokio::test]
230 async fn audit_log_records_and_retrieves() {
231 let log = AuditLog::new(100, None);
232 log.log(
233 AuditCategory::Api,
234 "GET /health",
235 AuditOutcome::Success,
236 None,
237 None,
238 )
239 .await;
240
241 assert_eq!(log.count().await, 1);
242 let entries = log.recent(10).await;
243 assert_eq!(entries[0].action, "GET /health");
244 }
245
246 #[tokio::test]
247 async fn audit_log_evicts_oldest() {
248 let log = AuditLog::new(128, None);
249 for i in 0..200 {
250 log.log(
251 AuditCategory::Api,
252 format!("req-{}", i),
253 AuditOutcome::Success,
254 None,
255 None,
256 )
257 .await;
258 }
259 assert!(log.count().await <= 128);
260 }
261}