Skip to main content

oxicode_sdk/security/
audit_sink.rs

1//! Unified audit sink — single destination for all security events.
2//!
3//! All security decisions flow through `AuditSink` into the Merkle-chain
4//! `AuditTrail` for tamper-evidence.
5
6use 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// ─── Audit Event ────────────────────────────────────────────────────────────
15
16/// Unified security audit event.
17#[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    /// Agent/subject responsible.
77    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    /// Convert to AuditAction for the Merkle chain.
89    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
134// ─── Audit Sink Trait ───────────────────────────────────────────────────────
135
136/// Destination for all security audit events.
137pub trait AuditSink: Send + Sync {
138    /// Record a security audit event.
139    fn record(&self, event: AuditEvent);
140}
141
142// ─── Trail Audit Sink ───────────────────────────────────────────────────────
143
144/// Production sink: Merkle chain + async JSONL file writer.
145pub struct TrailAuditSink {
146    trail: Arc<AuditTrail>,
147    file_tx: tokio::sync::mpsc::Sender<String>,
148}
149
150impl TrailAuditSink {
151    /// Create a new sink. Spawns a background task writing JSONL to `audit_path`.
152    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
194/// Minimal sink that logs denied tool access via tracing.
195pub 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    /// No-op sink for tests.
222    #[allow(dead_code)] // test helper, not all test binaries use it
223    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}