Skip to main content

oxios_kernel/access_manager/
audit_sink.rs

1//! Unified audit sink — single destination for all security events.
2//!
3//! Eliminates the three-way split between `AccessManager.audit_log`,
4//! `RbacManager.audit_log`, and `AuditTrail`. All security events
5//! flow through `AuditSink` into the Merkle chain and JSONL file.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use chrono::{DateTime, Utc};
11use oxi_sdk::observability::{AuditAction, AuditTrail};
12use serde::{Deserialize, Serialize};
13
14// ─── Audit Event ────────────────────────────────────────────────────────────
15
16/// Unified security audit event.
17///
18/// Every security-relevant decision produces one of these variants.
19/// Serialized as JSONL for file persistence and ingested into the
20/// Merkle-chain `AuditTrail` for tamper-evidence.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "kind")]
23#[allow(missing_docs)]
24pub enum AuditEvent {
25    /// Tool access decision.
26    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    /// Path access decision.
36    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    /// Command execution decision.
47    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    /// RBAC authorization decision.
57    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    /// Workspace sandbox violation.
67    SandboxViolation {
68        #[serde(with = "chrono::serde::ts_milliseconds")]
69        timestamp: DateTime<Utc>,
70        agent: String,
71        path: String,
72        workspace: String,
73    },
74    /// Human-in-the-loop approval event.
75    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    /// Returns the agent/subject responsible for this event.
87    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    /// Convert to an AuditAction for the Merkle-chain AuditTrail.
99    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
149// ─── Audit Sink Trait ───────────────────────────────────────────────────────
150
151/// Destination for all security audit events.
152///
153/// Implementations persist events to Merkle chain + file, or are no-ops for tests.
154pub trait AuditSink: Send + Sync {
155    /// Record a security audit event.
156    fn record(&self, event: AuditEvent);
157}
158
159// ─── Trail Audit Sink ───────────────────────────────────────────────────────
160
161/// Production audit sink: Merkle chain + async JSONL file writer.
162///
163/// Events are:
164/// 1. Appended to the `AuditTrail` (Merkle chain, tamper-evident)
165/// 2. Sent to a background file writer via bounded channel (JSONL)
166///
167/// If the channel is full, a warning is logged and the event is still
168/// recorded in the Merkle chain (just not persisted to file immediately).
169pub struct TrailAuditSink {
170    /// Merkle-chain audit trail — always succeeds (in-memory).
171    trail: Arc<AuditTrail>,
172    /// Bounded channel to background file writer.
173    file_tx: tokio::sync::mpsc::Sender<String>,
174}
175
176impl TrailAuditSink {
177    /// Create a new `TrailAuditSink`.
178    ///
179    /// Spawns a background tokio task that reads from the bounded channel
180    /// and appends JSONL entries to `audit_path`.
181    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        // 1. Merkle chain (always succeeds)
207        let actor = event.actor().to_string();
208        let action = event.to_audit_action();
209        self.trail.append(actor, action, "access_gate".into());
210
211        // 2. JSONL file (fire-and-forget, may drop if channel full)
212        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// ─── No-op Sink (tests) ────────────────────────────────────────────────────
227
228/// No-op audit sink for tests — discards all events.
229#[cfg(test)]
230pub struct NoOpAuditSink;
231
232#[cfg(test)]
233impl AuditSink for NoOpAuditSink {
234    fn record(&self, _event: AuditEvent) {}
235}
236
237/// Minimal audit sink that logs to tracing — used as default when no file sink is configured.
238pub struct TracingAuditSink;
239
240impl AuditSink for TracingAuditSink {
241    fn record(&self, event: AuditEvent) {
242        // With no persistent sink configured we still surface every decision
243        // via tracing so allowed/denied activity is observable post-incident.
244        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// ─── Tests ──────────────────────────────────────────────────────────────────
333
334#[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        // No panic = success
405    }
406}