use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AuditEventType {
ToolAccess,
AgentAccess,
PermissionCheck,
Authentication,
Authorization,
ResourceCreated,
ResourceUpdated,
ResourceDeleted,
ConfigChanged,
SecretAccessed,
SecretRotated,
PaymentExecuted,
PolicyEvaluated,
SessionStarted,
SessionEnded,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AuditOutcome {
Allowed,
Denied,
Error,
Created,
Updated,
Deleted,
Blocked,
Paused,
Escalated,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub timestamp: DateTime<Utc>,
pub user: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub event_type: AuditEventType,
pub resource: String,
pub outcome: AuditOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev_hash: Option<String>,
}
impl AuditEvent {
pub fn new(
event_type: AuditEventType,
user: impl Into<String>,
resource: impl Into<String>,
outcome: AuditOutcome,
) -> Self {
Self {
timestamp: Utc::now(),
user: user.into(),
session_id: None,
event_type,
resource: resource.into(),
outcome,
metadata: None,
workspace_id: None,
tenant_id: None,
request_id: None,
ip_address: None,
resource_id: None,
action: None,
prev_hash: None,
}
}
pub fn tool_access(user: &str, tool_name: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::ToolAccess, user, tool_name, outcome)
}
pub fn agent_access(user: &str, agent_name: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::AgentAccess, user, agent_name, outcome)
}
pub fn authentication(user: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::Authentication, user, "auth", outcome)
}
pub fn resource_event(
event_type: AuditEventType,
user: &str,
resource: &str,
resource_id: &str,
outcome: AuditOutcome,
) -> Self {
Self::new(event_type, user, resource, outcome).with_resource_id(resource_id)
}
pub fn secret_accessed(user: &str, secret_name: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::SecretAccessed, user, secret_name, outcome)
}
pub fn config_changed(user: &str, config_key: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::ConfigChanged, user, config_key, outcome)
}
pub fn custom(event_type: &str, user: &str, resource: &str, outcome: AuditOutcome) -> Self {
Self::new(AuditEventType::Custom(event_type.to_string()), user, resource, outcome)
}
pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {
self.workspace_id = Some(workspace_id.into());
self
}
pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.request_id = Some(request_id.into());
self
}
pub fn with_ip_address(mut self, ip: impl Into<String>) -> Self {
self.ip_address = Some(ip.into());
self
}
pub fn with_resource_id(mut self, id: impl Into<String>) -> Self {
self.resource_id = Some(id.into());
self
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into());
self
}
pub fn with_prev_hash(mut self, prev_event_json: &str) -> Self {
let mut hasher = Sha256::new();
hasher.update(prev_event_json.as_bytes());
self.prev_hash = Some(hex::encode(hasher.finalize()));
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<AuditEventType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<AuditOutcome>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<usize>,
}
impl AuditFilter {
pub fn matches(&self, event: &AuditEvent) -> bool {
if let Some(ref user) = self.user {
if &event.user != user {
return false;
}
}
if let Some(ref ws) = self.workspace_id {
if event.workspace_id.as_ref() != Some(ws) {
return false;
}
}
if let Some(ref tid) = self.tenant_id {
if event.tenant_id.as_ref() != Some(tid) {
return false;
}
}
if let Some(ref et) = self.event_type {
if &event.event_type != et {
return false;
}
}
if let Some(ref oc) = self.outcome {
if &event.outcome != oc {
return false;
}
}
if let Some(ref res) = self.resource {
if !event.resource.contains(res.as_str()) {
return false;
}
}
if let Some(ref rid) = self.resource_id {
if event.resource_id.as_ref() != Some(rid) {
return false;
}
}
if let Some(after) = self.after {
if event.timestamp <= after {
return false;
}
}
if let Some(before) = self.before {
if event.timestamp >= before {
return false;
}
}
true
}
}
#[async_trait::async_trait]
pub trait AuditSink: Send + Sync {
async fn log(&self, event: AuditEvent) -> Result<(), crate::AuthError>;
async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<(), crate::AuthError> {
for event in events {
self.log(event).await?;
}
Ok(())
}
async fn query(&self, _filter: &AuditFilter) -> Result<Vec<AuditEvent>, crate::AuthError> {
Ok(Vec::new())
}
async fn purge_before(&self, _cutoff: DateTime<Utc>) -> Result<u64, crate::AuthError> {
Ok(0)
}
async fn flush(&self) -> Result<(), crate::AuthError> {
Ok(())
}
}
pub struct FileAuditSink {
writer: Mutex<BufWriter<File>>,
path: PathBuf,
last_event_json: Mutex<Option<String>>,
chain_enabled: bool,
}
impl FileAuditSink {
pub fn new(path: impl Into<PathBuf>) -> Result<Self, std::io::Error> {
let path = path.into();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let writer = Mutex::new(BufWriter::new(file));
Ok(Self { writer, path, last_event_json: Mutex::new(None), chain_enabled: false })
}
pub fn with_chaining(path: impl Into<PathBuf>) -> Result<Self, std::io::Error> {
let path = path.into();
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let writer = Mutex::new(BufWriter::new(file));
Ok(Self { writer, path, last_event_json: Mutex::new(None), chain_enabled: true })
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
#[async_trait::async_trait]
impl AuditSink for FileAuditSink {
async fn log(&self, mut event: AuditEvent) -> Result<(), crate::AuthError> {
if self.chain_enabled {
let mut last = self.last_event_json.lock().unwrap_or_else(|p| p.into_inner());
if let Some(ref prev_json) = *last {
let mut hasher = Sha256::new();
hasher.update(prev_json.as_bytes());
event.prev_hash = Some(hex::encode(hasher.finalize()));
}
let line = serde_json::to_string(&event)
.map_err(|e| crate::AuthError::AuditError(e.to_string()))?;
*last = Some(line.clone());
let mut writer = self.writer.lock().unwrap_or_else(|poisoned| {
tracing::warn!(path = %self.path.display(), "audit writer lock poisoned, recovering");
poisoned.into_inner()
});
writeln!(writer, "{line}")?;
writer.flush()?;
} else {
let line = serde_json::to_string(&event)
.map_err(|e| crate::AuthError::AuditError(e.to_string()))?;
let mut writer = self.writer.lock().unwrap_or_else(|poisoned| {
tracing::warn!(path = %self.path.display(), "audit writer lock poisoned, recovering");
poisoned.into_inner()
});
writeln!(writer, "{line}")?;
writer.flush()?;
}
Ok(())
}
}
pub struct InMemoryAuditSink {
events: tokio::sync::RwLock<Vec<AuditEvent>>,
}
impl InMemoryAuditSink {
pub fn new() -> Self {
Self { events: tokio::sync::RwLock::new(Vec::new()) }
}
pub async fn len(&self) -> usize {
self.events.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.events.read().await.is_empty()
}
}
impl Default for InMemoryAuditSink {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl AuditSink for InMemoryAuditSink {
async fn log(&self, event: AuditEvent) -> Result<(), crate::AuthError> {
self.events.write().await.push(event);
Ok(())
}
async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<(), crate::AuthError> {
self.events.write().await.extend(events);
Ok(())
}
async fn query(&self, filter: &AuditFilter) -> Result<Vec<AuditEvent>, crate::AuthError> {
let events = self.events.read().await;
let mut results: Vec<AuditEvent> =
events.iter().filter(|e| filter.matches(e)).cloned().collect();
if let Some(offset) = filter.offset {
if offset < results.len() {
results = results[offset..].to_vec();
} else {
results.clear();
}
}
if let Some(limit) = filter.limit {
results.truncate(limit);
}
Ok(results)
}
async fn purge_before(&self, cutoff: DateTime<Utc>) -> Result<u64, crate::AuthError> {
let mut events = self.events.write().await;
let before_len = events.len();
events.retain(|e| e.timestamp >= cutoff);
Ok((before_len - events.len()) as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_event_serialization() {
let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed);
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"user\":\"alice\""));
assert!(json.contains("\"resource\":\"search\""));
assert!(json.contains("\"outcome\":\"allowed\""));
}
#[test]
fn test_audit_event_with_session() {
let event = AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied)
.with_session("session-123");
assert_eq!(event.session_id, Some("session-123".to_string()));
}
#[test]
fn test_enterprise_context_fields() {
let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
.with_workspace("ws_123")
.with_tenant("tenant_abc")
.with_request_id("req_456")
.with_ip_address("192.168.1.1")
.with_resource_id("550e8400-e29b-41d4-a716-446655440000")
.with_action("execute");
assert_eq!(event.workspace_id, Some("ws_123".to_string()));
assert_eq!(event.tenant_id, Some("tenant_abc".to_string()));
assert_eq!(event.request_id, Some("req_456".to_string()));
assert_eq!(event.ip_address, Some("192.168.1.1".to_string()));
assert_eq!(event.resource_id, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
assert_eq!(event.action, Some("execute".to_string()));
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"workspace_id\":\"ws_123\""));
assert!(json.contains("\"tenant_id\":\"tenant_abc\""));
assert!(json.contains("\"request_id\":\"req_456\""));
}
#[test]
fn test_custom_event_type() {
let event =
AuditEvent::custom("deployment_triggered", "ci-bot", "agent-v2", AuditOutcome::Created);
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("deployment_triggered"));
assert!(json.contains("\"outcome\":\"created\""));
}
#[test]
fn test_new_outcomes() {
for outcome in [
AuditOutcome::Created,
AuditOutcome::Updated,
AuditOutcome::Deleted,
AuditOutcome::Blocked,
AuditOutcome::Paused,
AuditOutcome::Escalated,
] {
let event =
AuditEvent::new(AuditEventType::ResourceCreated, "user", "res", outcome.clone());
let json = serde_json::to_string(&event).unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.outcome, outcome);
}
}
#[test]
fn test_new_event_types() {
let types = vec![
AuditEventType::Authentication,
AuditEventType::Authorization,
AuditEventType::ResourceCreated,
AuditEventType::ResourceUpdated,
AuditEventType::ResourceDeleted,
AuditEventType::ConfigChanged,
AuditEventType::SecretAccessed,
AuditEventType::SecretRotated,
AuditEventType::PaymentExecuted,
AuditEventType::PolicyEvaluated,
AuditEventType::SessionStarted,
AuditEventType::SessionEnded,
AuditEventType::Custom("my_event".to_string()),
];
for event_type in types {
let event =
AuditEvent::new(event_type.clone(), "user", "resource", AuditOutcome::Allowed);
let json = serde_json::to_string(&event).unwrap();
let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.event_type, event_type);
}
}
#[test]
fn test_hash_chaining() {
let event1 = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed);
let json1 = event1.to_json().unwrap();
let event2 =
AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied).with_prev_hash(&json1);
assert!(event2.prev_hash.is_some());
assert_eq!(event2.prev_hash.as_ref().unwrap().len(), 64);
}
#[test]
fn test_audit_filter_matches() {
let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
.with_workspace("ws_1");
let filter = AuditFilter { user: Some("alice".to_string()), ..Default::default() };
assert!(filter.matches(&event));
let filter = AuditFilter { user: Some("bob".to_string()), ..Default::default() };
assert!(!filter.matches(&event));
let filter = AuditFilter { workspace_id: Some("ws_1".to_string()), ..Default::default() };
assert!(filter.matches(&event));
let filter = AuditFilter { workspace_id: Some("ws_2".to_string()), ..Default::default() };
assert!(!filter.matches(&event));
}
#[test]
fn test_backward_compatible_serialization() {
let old_json = r#"{"timestamp":"2026-01-01T00:00:00Z","user":"alice","event_type":"tool_access","resource":"search","outcome":"allowed"}"#;
let event: AuditEvent = serde_json::from_str(old_json).unwrap();
assert_eq!(event.user, "alice");
assert_eq!(event.workspace_id, None);
assert_eq!(event.tenant_id, None);
}
#[tokio::test]
async fn test_in_memory_sink_query() {
let sink = InMemoryAuditSink::new();
sink.log(
AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
.with_workspace("ws_1"),
)
.await
.unwrap();
sink.log(
AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied).with_workspace("ws_1"),
)
.await
.unwrap();
sink.log(
AuditEvent::tool_access("alice", "deploy", AuditOutcome::Allowed)
.with_workspace("ws_2"),
)
.await
.unwrap();
let results = sink
.query(&AuditFilter { user: Some("alice".to_string()), ..Default::default() })
.await
.unwrap();
assert_eq!(results.len(), 2);
let results = sink
.query(&AuditFilter { workspace_id: Some("ws_1".to_string()), ..Default::default() })
.await
.unwrap();
assert_eq!(results.len(), 2);
let results = sink
.query(&AuditFilter { outcome: Some(AuditOutcome::Denied), ..Default::default() })
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].user, "bob");
}
#[tokio::test]
async fn test_in_memory_sink_purge() {
let sink = InMemoryAuditSink::new();
sink.log(AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)).await.unwrap();
sink.log(AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied)).await.unwrap();
assert_eq!(sink.len().await, 2);
let cutoff = Utc::now() + chrono::Duration::seconds(1);
let purged = sink.purge_before(cutoff).await.unwrap();
assert_eq!(purged, 2);
assert!(sink.is_empty().await);
}
#[tokio::test]
async fn test_in_memory_sink_batch() {
let sink = InMemoryAuditSink::new();
let events = vec![
AuditEvent::tool_access("alice", "a", AuditOutcome::Allowed),
AuditEvent::tool_access("bob", "b", AuditOutcome::Denied),
AuditEvent::tool_access("carol", "c", AuditOutcome::Allowed),
];
sink.log_batch(events).await.unwrap();
assert_eq!(sink.len().await, 3);
}
}