use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
const DEFAULT_MAX_ENTRIES: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditCategory {
Api,
ToolExecution,
Session,
Cognition,
Swarm,
Auth,
K8s,
Sandbox,
Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
Success,
Failure,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub timestamp: DateTime<Utc>,
pub category: AuditCategory,
pub action: String,
pub principal: Option<String>,
pub outcome: AuditOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub okr_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub okr_run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
#[derive(Clone)]
pub struct AuditLog {
entries: Arc<RwLock<VecDeque<AuditEntry>>>,
max_entries: usize,
sink_path: Option<PathBuf>,
}
impl std::fmt::Debug for AuditLog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditLog")
.field("max_entries", &self.max_entries)
.field("sink_path", &self.sink_path)
.finish()
}
}
impl Default for AuditLog {
fn default() -> Self {
Self::new(DEFAULT_MAX_ENTRIES, None)
}
}
impl AuditLog {
pub fn new(max_entries: usize, sink_path: Option<PathBuf>) -> Self {
Self {
entries: Arc::new(RwLock::new(VecDeque::with_capacity(
max_entries.min(DEFAULT_MAX_ENTRIES),
))),
max_entries: max_entries.max(128),
sink_path,
}
}
pub fn from_env() -> Self {
let max = std::env::var("CODETETHER_AUDIT_MAX_ENTRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_ENTRIES);
let path = std::env::var("CODETETHER_AUDIT_LOG_PATH")
.ok()
.map(PathBuf::from);
Self::new(max, path)
}
pub async fn record(&self, entry: AuditEntry) {
if let Some(ref path) = self.sink_path {
if let Ok(line) = serde_json::to_string(&entry) {
use tokio::io::AsyncWriteExt;
if let Ok(mut file) = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.await
{
let _ = file.write_all(line.as_bytes()).await;
let _ = file.write_all(b"\n").await;
}
}
}
tracing::info!(
audit_id = %entry.id,
category = ?entry.category,
action = %entry.action,
outcome = ?entry.outcome,
principal = entry.principal.as_deref().unwrap_or("-"),
"audit"
);
let mut lock = self.entries.write().await;
lock.push_back(entry);
while lock.len() > self.max_entries {
lock.pop_front();
}
}
pub async fn log(
&self,
category: AuditCategory,
action: impl Into<String>,
outcome: AuditOutcome,
principal: Option<String>,
detail: Option<serde_json::Value>,
) {
self.record(AuditEntry {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
category,
action: action.into(),
principal,
outcome,
detail,
duration_ms: None,
okr_id: None,
okr_run_id: None,
relay_id: None,
session_id: None,
})
.await;
}
pub async fn log_with_correlation(
&self,
category: AuditCategory,
action: impl Into<String>,
outcome: AuditOutcome,
principal: Option<String>,
detail: Option<serde_json::Value>,
okr_id: Option<String>,
okr_run_id: Option<String>,
relay_id: Option<String>,
session_id: Option<String>,
) {
self.record(AuditEntry {
id: Uuid::new_v4().to_string(),
timestamp: Utc::now(),
category,
action: action.into(),
principal,
outcome,
detail,
duration_ms: None,
okr_id,
okr_run_id,
relay_id,
session_id,
})
.await;
}
pub async fn recent(&self, limit: usize) -> Vec<AuditEntry> {
let lock = self.entries.read().await;
lock.iter().rev().take(limit).cloned().collect()
}
pub async fn count(&self) -> usize {
self.entries.read().await.len()
}
pub async fn by_category(&self, category: AuditCategory, limit: usize) -> Vec<AuditEntry> {
let lock = self.entries.read().await;
lock.iter()
.rev()
.filter(|e| e.category == category)
.take(limit)
.cloned()
.collect()
}
}
static AUDIT_LOG: tokio::sync::OnceCell<AuditLog> = tokio::sync::OnceCell::const_new();
pub fn init_audit_log(log: AuditLog) -> Result<(), AuditLog> {
AUDIT_LOG.set(log).map_err(|_| AuditLog::default())
}
pub fn audit_log() -> &'static AuditLog {
AUDIT_LOG
.get()
.expect("Audit log not initialized — call init_audit_log() at startup")
}
pub fn try_audit_log() -> Option<&'static AuditLog> {
AUDIT_LOG.get()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn audit_log_records_and_retrieves() {
let log = AuditLog::new(100, None);
log.log(
AuditCategory::Api,
"GET /health",
AuditOutcome::Success,
None,
None,
)
.await;
assert_eq!(log.count().await, 1);
let entries = log.recent(10).await;
assert_eq!(entries[0].action, "GET /health");
}
#[tokio::test]
async fn audit_log_evicts_oldest() {
let log = AuditLog::new(128, None);
for i in 0..200 {
log.log(
AuditCategory::Api,
format!("req-{}", i),
AuditOutcome::Success,
None,
None,
)
.await;
}
assert!(log.count().await <= 128);
}
}