use std::path::PathBuf;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::audit_trail::{AuditAction, AuditTrail};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
#[allow(missing_docs)]
pub enum AuditEvent {
ToolAccess {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
agent: String,
tool: String,
allowed: bool,
layer: Option<String>,
reason: Option<String>,
},
PathAccess {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
agent: String,
path: String,
mode: String,
allowed: bool,
layer: Option<String>,
reason: Option<String>,
},
ExecAccess {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
agent: String,
binary: String,
allowed: bool,
layer: Option<String>,
reason: Option<String>,
},
RbacDecision {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
subject: String,
action: String,
resource: String,
allowed: bool,
reason: Option<String>,
},
SandboxViolation {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
agent: String,
path: String,
workspace: String,
},
Approval {
#[serde(with = "chrono::serde::ts_milliseconds")]
timestamp: DateTime<Utc>,
approval_id: String,
subject: String,
action: String,
status: String,
},
}
impl AuditEvent {
pub fn actor(&self) -> &str {
match self {
AuditEvent::ToolAccess { agent, .. } => agent,
AuditEvent::PathAccess { agent, .. } => agent,
AuditEvent::ExecAccess { agent, .. } => agent,
AuditEvent::RbacDecision { subject, .. } => subject,
AuditEvent::SandboxViolation { agent, .. } => agent,
AuditEvent::Approval { subject, .. } => subject,
}
}
pub fn to_audit_action(&self) -> AuditAction {
match self {
AuditEvent::ToolAccess { tool, allowed, .. } => AuditAction::Other {
detail: format!("tool_access:{tool}:allowed={allowed}"),
},
AuditEvent::PathAccess {
path,
mode,
allowed,
..
} => AuditAction::Other {
detail: format!("path_access:{path}:{mode}:allowed={allowed}"),
},
AuditEvent::ExecAccess {
binary, allowed, ..
} => AuditAction::Other {
detail: format!("exec_access:{binary}:allowed={allowed}"),
},
AuditEvent::RbacDecision {
subject,
action,
allowed,
..
} => AuditAction::Other {
detail: format!("rbac:{subject}:{action}:allowed={allowed}"),
},
AuditEvent::SandboxViolation {
agent,
path,
workspace,
..
} => AuditAction::Other {
detail: format!("sandbox_violation:{agent}:{path}:ws={workspace}"),
},
AuditEvent::Approval {
approval_id,
status,
..
} => AuditAction::Other {
detail: format!("approval:{approval_id}:{status}"),
},
}
}
#[cfg(test)]
fn now() -> DateTime<Utc> {
Utc::now()
}
}
pub trait AuditSink: Send + Sync {
fn record(&self, event: AuditEvent);
}
pub struct TrailAuditSink {
trail: Arc<AuditTrail>,
file_tx: tokio::sync::mpsc::Sender<String>,
}
impl TrailAuditSink {
pub fn new(trail: Arc<AuditTrail>, audit_path: PathBuf) -> Self {
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(1000);
let path = audit_path.clone();
tokio::spawn(async move {
if let Ok(mut file) = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
use tokio::io::AsyncWriteExt;
while let Some(line) = rx.recv().await {
let _ = file.write_all(line.as_bytes()).await;
let _ = file.write_all(b"\n").await;
}
}
});
Self { trail, file_tx: tx }
}
}
impl AuditSink for TrailAuditSink {
fn record(&self, event: AuditEvent) {
let actor = event.actor().to_string();
let action = event.to_audit_action();
self.trail.append(actor, action, "access_gate".into());
if let Ok(line) = serde_json::to_string(&event) {
match self.file_tx.try_send(line) {
Ok(()) => {}
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("Audit sink channel full — event still in Merkle chain");
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("Audit sink channel closed");
}
}
}
}
}
#[cfg(test)]
pub struct NoOpAuditSink;
#[cfg(test)]
impl AuditSink for NoOpAuditSink {
fn record(&self, _event: AuditEvent) {}
}
pub struct TracingAuditSink;
impl AuditSink for TracingAuditSink {
fn record(&self, event: AuditEvent) {
if let AuditEvent::ToolAccess {
agent,
tool,
allowed: false,
layer,
..
} = &event
{
tracing::warn!(
agent = %agent,
tool = %tool,
layer = ?layer,
"Access denied (no persistent audit sink configured)"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_access_event() {
let event = AuditEvent::ToolAccess {
timestamp: AuditEvent::now(),
agent: "test-agent".into(),
tool: "exec".into(),
allowed: true,
layer: None,
reason: None,
};
assert_eq!(event.actor(), "test-agent");
let action = event.to_audit_action();
assert!(matches!(action, AuditAction::Other { .. }));
}
#[test]
fn test_rbac_decision_event() {
let event = AuditEvent::RbacDecision {
timestamp: AuditEvent::now(),
subject: "user:alice".into(),
action: "UseTool(exec)".into(),
resource: "exec".into(),
allowed: false,
reason: Some("role User does not allow".into()),
};
assert_eq!(event.actor(), "user:alice");
}
#[test]
fn test_sandbox_violation_event() {
let event = AuditEvent::SandboxViolation {
timestamp: AuditEvent::now(),
agent: "rogue-agent".into(),
path: "/etc/passwd".into(),
workspace: "project-alpha".into(),
};
assert_eq!(event.actor(), "rogue-agent");
}
#[test]
fn test_event_serialization_roundtrip() {
let event = AuditEvent::ExecAccess {
timestamp: AuditEvent::now(),
agent: "test".into(),
binary: "git".into(),
allowed: true,
layer: None,
reason: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("ExecAccess"));
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(deserialized, AuditEvent::ExecAccess { .. }));
}
#[test]
fn test_noop_sink() {
let sink = NoOpAuditSink;
sink.record(AuditEvent::ToolAccess {
timestamp: AuditEvent::now(),
agent: "test".into(),
tool: "exec".into(),
allowed: true,
layer: None,
reason: None,
});
}
}