use std::fs::OpenOptions;
use std::io::Write as _;
use std::path::PathBuf;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
pub time: String,
pub token_id: String,
pub label: String,
pub provider: String,
pub surface: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AuditLog {
path: Option<PathBuf>,
}
impl AuditLog {
#[must_use]
pub const fn disabled() -> Self {
Self { path: None }
}
#[must_use]
pub fn to_path(path: Option<&str>) -> Self {
Self {
path: path.filter(|p| !p.is_empty()).map(PathBuf::from),
}
}
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.path.is_some()
}
#[must_use]
pub fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
}
pub fn record(&self, event: &AuditEvent) {
let Some(path) = self.path.as_ref() else {
return;
};
let Ok(line) = serde_json::to_string(event) else {
return;
};
let write = open_append_only(path).and_then(|mut file| writeln!(file, "{line}"));
if let Err(e) = write {
tracing::warn!("audit log write failed ({}): {e}", path.display());
}
}
}
fn open_append_only(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
options.mode(0o600);
let file = options.open(path)?;
#[cfg(unix)]
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
Ok(file)
}
#[must_use]
pub fn event(
token_id: &str,
label: &str,
provider: &str,
surface: &str,
path: &str,
model: Option<&str>,
) -> AuditEvent {
AuditEvent {
time: chrono::Utc::now().to_rfc3339(),
token_id: token_id.to_string(),
label: label.to_string(),
provider: provider.to_string(),
surface: surface.to_string(),
path: path.to_string(),
model: model.map(String::from),
}
}
#[must_use]
pub const fn surface_name(surface: crate::metrics::Surface) -> &'static str {
match surface {
crate::metrics::Surface::Anthropic => "anthropic",
crate::metrics::Surface::OpenAIChat => "openai_chat",
crate::metrics::Surface::OpenAIResponses => "openai_responses",
}
}
pub fn record_authorised_request(
state: &crate::app_state::AppState,
claims: &crate::token::TokenClaims,
surface: crate::metrics::Surface,
path: &str,
body: Option<&serde_json::Value>,
) {
state
.metrics
.record_token_request(&claims.sub, &claims.label);
if !state.audit.is_enabled() {
return;
}
let model = body
.and_then(|b| b.get("model"))
.and_then(serde_json::Value::as_str);
state.audit.record(&event(
&claims.sub,
&claims.label,
state.upstream_provider.as_str(),
surface_name(surface),
path,
model,
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_log_writes_nothing() {
let log = AuditLog::disabled();
assert!(!log.is_enabled());
log.record(&event(
"id",
"task-1",
"codex",
"anthropic",
"/v1/messages",
None,
));
}
#[test]
fn empty_path_is_treated_as_disabled() {
assert!(!AuditLog::to_path(Some("")).is_enabled());
assert!(!AuditLog::to_path(None).is_enabled());
}
#[test]
fn enabled_log_appends_one_json_line_per_event() {
let dir = std::env::temp_dir().join(format!("la-audit-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("temp dir");
let file = dir.join("audit.jsonl");
let log = AuditLog::to_path(file.to_str());
assert!(log.is_enabled());
log.record(&event(
"tok-1",
"task-a",
"codex",
"anthropic",
"/v1/messages",
Some("claude-sonnet-4"),
));
log.record(&event(
"tok-2",
"task-b",
"anthropic",
"anthropic",
"/v1/messages",
None,
));
let body = std::fs::read_to_string(&file).expect("read audit log");
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let first: serde_json::Value = serde_json::from_str(lines[0]).expect("json line");
assert_eq!(first["token_id"], "tok-1");
assert_eq!(first["label"], "task-a");
assert_eq!(first["provider"], "codex");
assert_eq!(first["model"], "claude-sonnet-4");
assert!(first["time"].as_str().is_some_and(|t| t.contains('T')));
let second: serde_json::Value = serde_json::from_str(lines[1]).expect("json line");
assert_eq!(second["token_id"], "tok-2");
assert!(second.get("model").is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn enabled_log_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("audit.jsonl");
std::fs::write(&file, "").expect("seed audit log");
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644))
.expect("set permissive mode");
let log = AuditLog::to_path(file.to_str());
log.record(&event(
"tok-1",
"task-a",
"anthropic",
"anthropic",
"/v1/messages",
None,
));
let mode = std::fs::metadata(file)
.expect("audit metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn events_never_carry_the_token_string_or_credentials() {
let e = event(
"tok-1",
"task-a",
"codex",
"anthropic",
"/v1/messages",
None,
);
let json = serde_json::to_string(&e).expect("serialize");
assert!(!json.contains("la_sk_"));
assert!(!json.contains("Bearer"));
}
}