1use std::path::PathBuf;
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use oxi_sdk::observability::{AuditAction, AuditTrail};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "kind")]
23#[allow(missing_docs)]
24pub enum AuditEvent {
25 ToolAccess {
27 #[serde(with = "chrono::serde::ts_milliseconds")]
28 timestamp: DateTime<Utc>,
29 agent: String,
30 tool: String,
31 allowed: bool,
32 layer: Option<String>,
33 reason: Option<String>,
34 },
35 PathAccess {
37 #[serde(with = "chrono::serde::ts_milliseconds")]
38 timestamp: DateTime<Utc>,
39 agent: String,
40 path: String,
41 mode: String,
42 allowed: bool,
43 layer: Option<String>,
44 reason: Option<String>,
45 },
46 ExecAccess {
48 #[serde(with = "chrono::serde::ts_milliseconds")]
49 timestamp: DateTime<Utc>,
50 agent: String,
51 binary: String,
52 allowed: bool,
53 layer: Option<String>,
54 reason: Option<String>,
55 },
56 RbacDecision {
58 #[serde(with = "chrono::serde::ts_milliseconds")]
59 timestamp: DateTime<Utc>,
60 subject: String,
61 action: String,
62 resource: String,
63 allowed: bool,
64 reason: Option<String>,
65 },
66 SandboxViolation {
68 #[serde(with = "chrono::serde::ts_milliseconds")]
69 timestamp: DateTime<Utc>,
70 agent: String,
71 path: String,
72 workspace: String,
73 },
74 Approval {
76 #[serde(with = "chrono::serde::ts_milliseconds")]
77 timestamp: DateTime<Utc>,
78 approval_id: String,
79 subject: String,
80 action: String,
81 status: String,
82 },
83}
84
85impl AuditEvent {
86 pub fn actor(&self) -> &str {
88 match self {
89 AuditEvent::ToolAccess { agent, .. } => agent,
90 AuditEvent::PathAccess { agent, .. } => agent,
91 AuditEvent::ExecAccess { agent, .. } => agent,
92 AuditEvent::RbacDecision { subject, .. } => subject,
93 AuditEvent::SandboxViolation { agent, .. } => agent,
94 AuditEvent::Approval { subject, .. } => subject,
95 }
96 }
97
98 pub fn to_audit_action(&self) -> AuditAction {
100 match self {
101 AuditEvent::ToolAccess { tool, allowed, .. } => AuditAction::Other {
102 detail: format!("tool_access:{tool}:allowed={allowed}"),
103 },
104 AuditEvent::PathAccess {
105 path,
106 mode,
107 allowed,
108 ..
109 } => AuditAction::Other {
110 detail: format!("path_access:{path}:{mode}:allowed={allowed}"),
111 },
112 AuditEvent::ExecAccess {
113 binary, allowed, ..
114 } => AuditAction::Other {
115 detail: format!("exec_access:{binary}:allowed={allowed}"),
116 },
117 AuditEvent::RbacDecision {
118 subject,
119 action,
120 allowed,
121 ..
122 } => AuditAction::Other {
123 detail: format!("rbac:{subject}:{action}:allowed={allowed}"),
124 },
125 AuditEvent::SandboxViolation {
126 agent,
127 path,
128 workspace,
129 ..
130 } => AuditAction::Other {
131 detail: format!("sandbox_violation:{agent}:{path}:ws={workspace}"),
132 },
133 AuditEvent::Approval {
134 approval_id,
135 status,
136 ..
137 } => AuditAction::Other {
138 detail: format!("approval:{approval_id}:{status}"),
139 },
140 }
141 }
142
143 #[cfg(test)]
144 fn now() -> DateTime<Utc> {
145 Utc::now()
146 }
147}
148
149pub trait AuditSink: Send + Sync {
155 fn record(&self, event: AuditEvent);
157}
158
159pub struct TrailAuditSink {
170 trail: Arc<AuditTrail>,
172 file_tx: tokio::sync::mpsc::Sender<String>,
174}
175
176impl TrailAuditSink {
177 pub fn new(trail: Arc<AuditTrail>, audit_path: PathBuf) -> Self {
182 let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(1000);
183
184 let path = audit_path.clone();
185 tokio::spawn(async move {
186 if let Ok(mut file) = tokio::fs::OpenOptions::new()
187 .create(true)
188 .append(true)
189 .open(&path)
190 .await
191 {
192 use tokio::io::AsyncWriteExt;
193 while let Some(line) = rx.recv().await {
194 let _ = file.write_all(line.as_bytes()).await;
195 let _ = file.write_all(b"\n").await;
196 }
197 }
198 });
199
200 Self { trail, file_tx: tx }
201 }
202}
203
204impl AuditSink for TrailAuditSink {
205 fn record(&self, event: AuditEvent) {
206 let actor = event.actor().to_string();
208 let action = event.to_audit_action();
209 self.trail.append(actor, action, "access_gate".into());
210
211 if let Ok(line) = serde_json::to_string(&event) {
213 match self.file_tx.try_send(line) {
214 Ok(()) => {}
215 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
216 tracing::warn!("Audit sink channel full — event still in Merkle chain");
217 }
218 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
219 tracing::warn!("Audit sink channel closed");
220 }
221 }
222 }
223 }
224}
225
226#[cfg(test)]
230pub struct NoOpAuditSink;
231
232#[cfg(test)]
233impl AuditSink for NoOpAuditSink {
234 fn record(&self, _event: AuditEvent) {}
235}
236
237pub struct TracingAuditSink;
239
240impl AuditSink for TracingAuditSink {
241 fn record(&self, event: AuditEvent) {
242 match &event {
245 AuditEvent::ToolAccess {
246 agent,
247 tool,
248 allowed,
249 layer,
250 ..
251 } => {
252 if *allowed {
253 tracing::debug!(
254 agent = %agent, tool = %tool, layer = ?layer,
255 "Tool access allowed (no persistent audit sink configured)"
256 );
257 } else {
258 tracing::warn!(
259 agent = %agent, tool = %tool, layer = ?layer,
260 "Access denied (no persistent audit sink configured)"
261 );
262 }
263 }
264 AuditEvent::PathAccess {
265 agent,
266 path,
267 allowed,
268 ..
269 } => {
270 tracing::debug!(
271 agent = %agent, path = %path, allowed = allowed,
272 "Path access decision"
273 );
274 }
275 AuditEvent::ExecAccess {
276 agent,
277 binary,
278 allowed,
279 ..
280 } => {
281 if *allowed {
282 tracing::info!(
283 agent = %agent, binary = %binary,
284 "Exec access allowed"
285 );
286 } else {
287 tracing::warn!(
288 agent = %agent, binary = %binary,
289 "Exec access denied"
290 );
291 }
292 }
293 AuditEvent::RbacDecision {
294 subject,
295 action,
296 allowed,
297 ..
298 } => {
299 tracing::debug!(
300 subject = %subject, action = %action, allowed = allowed,
301 "RBAC decision"
302 );
303 }
304 AuditEvent::SandboxViolation {
305 agent,
306 path,
307 workspace,
308 ..
309 } => {
310 tracing::warn!(
311 agent = %agent, path = %path, workspace = %workspace,
312 "Sandbox boundary violation"
313 );
314 }
315 AuditEvent::Approval {
316 subject,
317 approval_id,
318 status,
319 ..
320 } => {
321 tracing::info!(
322 subject = %subject,
323 approval_id = %approval_id,
324 status = ?status,
325 "Approval decision"
326 );
327 }
328 }
329 }
330}
331
332#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_tool_access_event() {
340 let event = AuditEvent::ToolAccess {
341 timestamp: AuditEvent::now(),
342 agent: "test-agent".into(),
343 tool: "exec".into(),
344 allowed: true,
345 layer: None,
346 reason: None,
347 };
348 assert_eq!(event.actor(), "test-agent");
349 let action = event.to_audit_action();
350 assert!(matches!(action, AuditAction::Other { .. }));
351 }
352
353 #[test]
354 fn test_rbac_decision_event() {
355 let event = AuditEvent::RbacDecision {
356 timestamp: AuditEvent::now(),
357 subject: "user:alice".into(),
358 action: "UseTool(exec)".into(),
359 resource: "exec".into(),
360 allowed: false,
361 reason: Some("role User does not allow".into()),
362 };
363 assert_eq!(event.actor(), "user:alice");
364 }
365
366 #[test]
367 fn test_sandbox_violation_event() {
368 let event = AuditEvent::SandboxViolation {
369 timestamp: AuditEvent::now(),
370 agent: "rogue-agent".into(),
371 path: "/etc/passwd".into(),
372 workspace: "project-alpha".into(),
373 };
374 assert_eq!(event.actor(), "rogue-agent");
375 }
376
377 #[test]
378 fn test_event_serialization_roundtrip() {
379 let event = AuditEvent::ExecAccess {
380 timestamp: AuditEvent::now(),
381 agent: "test".into(),
382 binary: "git".into(),
383 allowed: true,
384 layer: None,
385 reason: None,
386 };
387 let json = serde_json::to_string(&event).unwrap();
388 assert!(json.contains("ExecAccess"));
389 let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
390 assert!(matches!(deserialized, AuditEvent::ExecAccess { .. }));
391 }
392
393 #[test]
394 fn test_noop_sink() {
395 let sink = NoOpAuditSink;
396 sink.record(AuditEvent::ToolAccess {
397 timestamp: AuditEvent::now(),
398 agent: "test".into(),
399 tool: "exec".into(),
400 allowed: true,
401 layer: None,
402 reason: None,
403 });
404 }
406}