Skip to main content

codetether_agent/audit/
mod.rs

1//! System-wide audit trail
2//!
3//! Every action taken by the server — API calls, tool executions, session
4//! operations, cognition decisions — is recorded in a tamper-evident append-only
5//! log.  Entries are held in a bounded in-memory ring buffer and optionally
6//! flushed to a JSONL file on disk.
7
8use 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
16/// Maximum number of entries kept in memory before oldest are evicted.
17const DEFAULT_MAX_ENTRIES: usize = 10_000;
18
19/// Categories of auditable actions.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AuditCategory {
23    /// HTTP API request/response
24    Api,
25    /// Tool invocation
26    ToolExecution,
27    /// Session lifecycle (create, load, prompt)
28    Session,
29    /// Cognition loop events
30    Cognition,
31    /// Swarm persona operations
32    Swarm,
33    /// Authentication events
34    Auth,
35    /// Kubernetes self-deployment actions
36    K8s,
37    /// Plugin sandbox events
38    Sandbox,
39    /// Configuration changes
40    Config,
41}
42
43/// Outcome of an audited action.
44#[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/// A single entry in the audit log.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AuditEntry {
55    /// Unique entry ID.
56    pub id: String,
57    /// When the action occurred.
58    pub timestamp: DateTime<Utc>,
59    /// Category of the action.
60    pub category: AuditCategory,
61    /// Human-readable description (e.g. "POST /api/session").
62    pub action: String,
63    /// Authenticated principal, if any.
64    pub principal: Option<String>,
65    /// Outcome of the action.
66    pub outcome: AuditOutcome,
67    /// Additional structured details.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub detail: Option<serde_json::Value>,
70    /// Duration of the action in milliseconds, if measured.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub duration_ms: Option<u64>,
73}
74
75/// Thread-safe, append-only audit log.
76#[derive(Clone)]
77pub struct AuditLog {
78    entries: Arc<RwLock<VecDeque<AuditEntry>>>,
79    max_entries: usize,
80    /// Optional path for JSONL persistence.
81    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    /// Create a new audit log.
101    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    /// Build from environment variables.
112    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    /// Record an audit entry.
126    pub async fn record(&self, entry: AuditEntry) {
127        // Persist to disk first (best effort).
128        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        // Log to tracing so structured log aggregators pick it up.
144        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    /// Convenience: record a simple action.
161    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    /// Return recent entries (newest first), up to `limit`.
183    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    /// Return total entry count.
189    pub async fn count(&self) -> usize {
190        self.entries.read().await.len()
191    }
192
193    /// Filter entries by category.
194    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
205/// Global audit log singleton.
206static AUDIT_LOG: tokio::sync::OnceCell<AuditLog> = tokio::sync::OnceCell::const_new();
207
208/// Initialize the global audit log.
209pub fn init_audit_log(log: AuditLog) -> Result<(), AuditLog> {
210    AUDIT_LOG.set(log).map_err(|_| AuditLog::default())
211}
212
213/// Get the global audit log (panics if not initialized).
214pub fn audit_log() -> &'static AuditLog {
215    AUDIT_LOG
216        .get()
217        .expect("Audit log not initialized — call init_audit_log() at startup")
218}
219
220/// Get the global audit log if initialized.
221pub 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}