1use std::path::PathBuf;
7use std::sync::Arc;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::observability::audit_trail::{AuditAction, AuditTrail};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "kind")]
19#[allow(missing_docs)]
20pub enum AuditEvent {
21 ToolAccess {
22 #[serde(with = "chrono::serde::ts_milliseconds")]
23 timestamp: DateTime<Utc>,
24 agent: String,
25 tool: String,
26 allowed: bool,
27 layer: Option<String>,
28 reason: Option<String>,
29 },
30 PathAccess {
31 #[serde(with = "chrono::serde::ts_milliseconds")]
32 timestamp: DateTime<Utc>,
33 agent: String,
34 path: String,
35 mode: String,
36 allowed: bool,
37 layer: Option<String>,
38 reason: Option<String>,
39 },
40 ExecAccess {
41 #[serde(with = "chrono::serde::ts_milliseconds")]
42 timestamp: DateTime<Utc>,
43 agent: String,
44 binary: String,
45 allowed: bool,
46 layer: Option<String>,
47 reason: Option<String>,
48 },
49 RbacDecision {
50 #[serde(with = "chrono::serde::ts_milliseconds")]
51 timestamp: DateTime<Utc>,
52 subject: String,
53 action: String,
54 resource: String,
55 allowed: bool,
56 reason: Option<String>,
57 },
58 SandboxViolation {
59 #[serde(with = "chrono::serde::ts_milliseconds")]
60 timestamp: DateTime<Utc>,
61 agent: String,
62 path: String,
63 workspace: String,
64 },
65 Approval {
66 #[serde(with = "chrono::serde::ts_milliseconds")]
67 timestamp: DateTime<Utc>,
68 approval_id: String,
69 subject: String,
70 action: String,
71 status: String,
72 },
73}
74
75impl AuditEvent {
76 pub fn actor(&self) -> &str {
78 match self {
79 AuditEvent::ToolAccess { agent, .. } => agent,
80 AuditEvent::PathAccess { agent, .. } => agent,
81 AuditEvent::ExecAccess { agent, .. } => agent,
82 AuditEvent::RbacDecision { subject, .. } => subject,
83 AuditEvent::SandboxViolation { agent, .. } => agent,
84 AuditEvent::Approval { subject, .. } => subject,
85 }
86 }
87
88 pub fn to_audit_action(&self) -> AuditAction {
90 match self {
91 AuditEvent::ToolAccess { tool, allowed, .. } => AuditAction::Other {
92 detail: format!("tool_access:{tool}:allowed={allowed}"),
93 },
94 AuditEvent::PathAccess {
95 path,
96 mode,
97 allowed,
98 ..
99 } => AuditAction::Other {
100 detail: format!("path_access:{path}:{mode}:allowed={allowed}"),
101 },
102 AuditEvent::ExecAccess {
103 binary, allowed, ..
104 } => AuditAction::Other {
105 detail: format!("exec_access:{binary}:allowed={allowed}"),
106 },
107 AuditEvent::RbacDecision {
108 subject,
109 action,
110 allowed,
111 ..
112 } => AuditAction::Other {
113 detail: format!("rbac:{subject}:{action}:allowed={allowed}"),
114 },
115 AuditEvent::SandboxViolation {
116 agent,
117 path,
118 workspace,
119 ..
120 } => AuditAction::Other {
121 detail: format!("sandbox_violation:{agent}:{path}:ws={workspace}"),
122 },
123 AuditEvent::Approval {
124 approval_id,
125 status,
126 ..
127 } => AuditAction::Other {
128 detail: format!("approval:{approval_id}:{status}"),
129 },
130 }
131 }
132}
133
134pub trait AuditSink: Send + Sync {
138 fn record(&self, event: AuditEvent);
140}
141
142pub struct TrailAuditSink {
146 trail: Arc<AuditTrail>,
147 file_tx: tokio::sync::mpsc::Sender<String>,
148}
149
150impl TrailAuditSink {
151 pub fn new(trail: Arc<AuditTrail>, audit_path: PathBuf) -> Self {
153 let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(1000);
154
155 tokio::spawn(async move {
156 if let Ok(mut file) = tokio::fs::OpenOptions::new()
157 .create(true)
158 .append(true)
159 .open(&audit_path)
160 .await
161 {
162 use tokio::io::AsyncWriteExt;
163 while let Some(line) = rx.recv().await {
164 let _ = file.write_all(line.as_bytes()).await;
165 let _ = file.write_all(b"\n").await;
166 }
167 }
168 });
169
170 Self { trail, file_tx: tx }
171 }
172}
173
174impl AuditSink for TrailAuditSink {
175 fn record(&self, event: AuditEvent) {
176 let actor = event.actor().to_string();
177 let action = event.to_audit_action();
178 self.trail.append(actor, action, "access_gate".into());
179
180 if let Ok(line) = serde_json::to_string(&event) {
181 match self.file_tx.try_send(line) {
182 Ok(()) => {}
183 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
184 tracing::warn!("Audit sink channel full — event still in Merkle chain");
185 }
186 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
187 tracing::warn!("Audit sink channel closed");
188 }
189 }
190 }
191 }
192}
193
194pub struct TracingAuditSink;
196
197impl AuditSink for TracingAuditSink {
198 fn record(&self, event: AuditEvent) {
199 if let AuditEvent::ToolAccess {
200 agent,
201 tool,
202 allowed: false,
203 layer,
204 ..
205 } = &event
206 {
207 tracing::warn!(
208 agent = %agent,
209 tool = %tool,
210 layer = ?layer,
211 "Access denied"
212 );
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[allow(dead_code)] pub struct NoOpAuditSink;
224
225 #[cfg(test)]
226 impl AuditSink for NoOpAuditSink {
227 fn record(&self, _event: AuditEvent) {}
228 }
229
230 #[test]
231 fn test_event_actor() {
232 let event = AuditEvent::ToolAccess {
233 timestamp: Utc::now(),
234 agent: "test-agent".into(),
235 tool: "exec".into(),
236 allowed: true,
237 layer: None,
238 reason: None,
239 };
240 assert_eq!(event.actor(), "test-agent");
241 }
242
243 #[test]
244 fn test_event_serialization() {
245 let event = AuditEvent::ExecAccess {
246 timestamp: Utc::now(),
247 agent: "test".into(),
248 binary: "git".into(),
249 allowed: true,
250 layer: None,
251 reason: None,
252 };
253 let json = serde_json::to_string(&event).unwrap();
254 let de: AuditEvent = serde_json::from_str(&json).unwrap();
255 assert!(matches!(de, AuditEvent::ExecAccess { .. }));
256 }
257}