1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! Append-only per-token audit log.
//!
//! Issue #45 asks for "each task a separate token … for
//! audit/monitoring/security/isolation". Per-token counters live in
//! [`crate::metrics`]; this module adds the durable half: one JSON object per
//! line (JSONL), appended as requests are authorised, so an operator can
//! reconstruct after the fact which task token did what.
//!
//! The log is **off by default** and only writes when a path is configured
//! (`--audit-log` / `AUDIT_LOG`). It records the token *id* (the JWT `sub`)
//! and its label — never the token string, never any upstream credential — so
//! the file is safe to ship to a log collector.
use std::fs::OpenOptions;
use std::io::Write as _;
use std::path::PathBuf;
use serde::Serialize;
/// One audit record, serialised as a single JSON line.
#[derive(Debug, Clone, Serialize)]
pub struct AuditEvent {
/// RFC 3339 timestamp of the authorisation.
pub time: String,
/// Router token id (JWT `sub`) — not the token itself.
pub token_id: String,
/// Human label given when the token was issued.
pub label: String,
/// Upstream provider that served the request.
pub provider: String,
/// Client-facing API surface (`anthropic`, `openai_chat`, …).
pub surface: String,
/// Request path as seen by the router.
pub path: String,
/// Model requested by the client, when the body carried one.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// Append-only JSONL audit sink.
///
/// Cloning is cheap; every call re-opens the file in append mode so an
/// external rotator (logrotate, `copytruncate`, …) can move it underneath a
/// running router without restarting the process.
#[derive(Debug, Clone, Default)]
pub struct AuditLog {
path: Option<PathBuf>,
}
impl AuditLog {
/// An audit log that discards everything (the default).
#[must_use]
pub const fn disabled() -> Self {
Self { path: None }
}
/// An audit log appending to `path`. An empty path disables the log.
#[must_use]
pub fn to_path(path: Option<&str>) -> Self {
Self {
path: path.filter(|p| !p.is_empty()).map(PathBuf::from),
}
}
/// Whether any record will actually be written.
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.path.is_some()
}
/// Configured destination, if any.
#[must_use]
pub fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
}
/// Append one event. Failures are logged and otherwise ignored: auditing
/// must never take the proxy down.
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 = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut file| writeln!(file, "{line}"));
if let Err(e) = write {
tracing::warn!("audit log write failed ({}): {e}", path.display());
}
}
}
/// Build an event for `claims` at the current wall-clock time.
#[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),
}
}
/// Name used for a surface in audit records.
#[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",
}
}
/// Record one authorised request against its router token.
///
/// This is the single place that keeps the two halves of issue #45's
/// "separate token per task" requirement in sync: the in-memory counter served
/// by `/metrics` and `/v1/usage`, and the optional durable JSONL trail. Call
/// it once per request, immediately after the token has been validated (and,
/// where applicable, its budget consumed).
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());
// Must not panic or create files.
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");
// `model` is omitted rather than written as null.
assert!(second.get("model").is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[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"));
}
}