1use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::fs::{File, OpenOptions};
22use std::io::{BufWriter, Write};
23use std::path::PathBuf;
24use std::sync::Mutex;
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum AuditEventType {
35 ToolAccess,
37 AgentAccess,
39 PermissionCheck,
41 Authentication,
43 Authorization,
45 ResourceCreated,
47 ResourceUpdated,
49 ResourceDeleted,
51 ConfigChanged,
53 SecretAccessed,
55 SecretRotated,
57 PaymentExecuted,
59 PolicyEvaluated,
61 SessionStarted,
63 SessionEnded,
65 Custom(String),
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74#[non_exhaustive]
75pub enum AuditOutcome {
76 Allowed,
78 Denied,
80 Error,
82 Created,
84 Updated,
86 Deleted,
88 Blocked,
90 Paused,
92 Escalated,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct AuditEvent {
103 pub timestamp: DateTime<Utc>,
105 pub user: String,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub session_id: Option<String>,
110 pub event_type: AuditEventType,
112 pub resource: String,
114 pub outcome: AuditOutcome,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub metadata: Option<serde_json::Value>,
119
120 #[serde(skip_serializing_if = "Option::is_none")]
123 pub workspace_id: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub tenant_id: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub request_id: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub ip_address: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub resource_id: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub action: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub prev_hash: Option<String>,
142}
143
144impl AuditEvent {
145 pub fn new(
147 event_type: AuditEventType,
148 user: impl Into<String>,
149 resource: impl Into<String>,
150 outcome: AuditOutcome,
151 ) -> Self {
152 Self {
153 timestamp: Utc::now(),
154 user: user.into(),
155 session_id: None,
156 event_type,
157 resource: resource.into(),
158 outcome,
159 metadata: None,
160 workspace_id: None,
161 tenant_id: None,
162 request_id: None,
163 ip_address: None,
164 resource_id: None,
165 action: None,
166 prev_hash: None,
167 }
168 }
169
170 pub fn tool_access(user: &str, tool_name: &str, outcome: AuditOutcome) -> Self {
172 Self::new(AuditEventType::ToolAccess, user, tool_name, outcome)
173 }
174
175 pub fn agent_access(user: &str, agent_name: &str, outcome: AuditOutcome) -> Self {
177 Self::new(AuditEventType::AgentAccess, user, agent_name, outcome)
178 }
179
180 pub fn authentication(user: &str, outcome: AuditOutcome) -> Self {
182 Self::new(AuditEventType::Authentication, user, "auth", outcome)
183 }
184
185 pub fn resource_event(
187 event_type: AuditEventType,
188 user: &str,
189 resource: &str,
190 resource_id: &str,
191 outcome: AuditOutcome,
192 ) -> Self {
193 Self::new(event_type, user, resource, outcome).with_resource_id(resource_id)
194 }
195
196 pub fn secret_accessed(user: &str, secret_name: &str, outcome: AuditOutcome) -> Self {
198 Self::new(AuditEventType::SecretAccessed, user, secret_name, outcome)
199 }
200
201 pub fn config_changed(user: &str, config_key: &str, outcome: AuditOutcome) -> Self {
203 Self::new(AuditEventType::ConfigChanged, user, config_key, outcome)
204 }
205
206 pub fn custom(event_type: &str, user: &str, resource: &str, outcome: AuditOutcome) -> Self {
208 Self::new(AuditEventType::Custom(event_type.to_string()), user, resource, outcome)
209 }
210
211 pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
213 self.session_id = Some(session_id.into());
214 self
215 }
216
217 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
219 self.metadata = Some(metadata);
220 self
221 }
222
223 pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {
225 self.workspace_id = Some(workspace_id.into());
226 self
227 }
228
229 pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
231 self.tenant_id = Some(tenant_id.into());
232 self
233 }
234
235 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
237 self.request_id = Some(request_id.into());
238 self
239 }
240
241 pub fn with_ip_address(mut self, ip: impl Into<String>) -> Self {
243 self.ip_address = Some(ip.into());
244 self
245 }
246
247 pub fn with_resource_id(mut self, id: impl Into<String>) -> Self {
249 self.resource_id = Some(id.into());
250 self
251 }
252
253 pub fn with_action(mut self, action: impl Into<String>) -> Self {
255 self.action = Some(action.into());
256 self
257 }
258
259 pub fn with_prev_hash(mut self, prev_event_json: &str) -> Self {
264 let mut hasher = Sha256::new();
265 hasher.update(prev_event_json.as_bytes());
266 self.prev_hash = Some(hex::encode(hasher.finalize()));
267 self
268 }
269
270 pub fn to_json(&self) -> Result<String, serde_json::Error> {
272 serde_json::to_string(self)
273 }
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct AuditFilter {
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub user: Option<String>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub workspace_id: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub tenant_id: Option<String>,
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub event_type: Option<AuditEventType>,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub outcome: Option<AuditOutcome>,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub resource: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub resource_id: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub after: Option<DateTime<Utc>>,
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub before: Option<DateTime<Utc>>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub limit: Option<usize>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub offset: Option<usize>,
315}
316
317impl AuditFilter {
318 pub fn matches(&self, event: &AuditEvent) -> bool {
320 if let Some(ref user) = self.user {
321 if &event.user != user {
322 return false;
323 }
324 }
325 if let Some(ref ws) = self.workspace_id {
326 if event.workspace_id.as_ref() != Some(ws) {
327 return false;
328 }
329 }
330 if let Some(ref tid) = self.tenant_id {
331 if event.tenant_id.as_ref() != Some(tid) {
332 return false;
333 }
334 }
335 if let Some(ref et) = self.event_type {
336 if &event.event_type != et {
337 return false;
338 }
339 }
340 if let Some(ref oc) = self.outcome {
341 if &event.outcome != oc {
342 return false;
343 }
344 }
345 if let Some(ref res) = self.resource {
346 if !event.resource.contains(res.as_str()) {
347 return false;
348 }
349 }
350 if let Some(ref rid) = self.resource_id {
351 if event.resource_id.as_ref() != Some(rid) {
352 return false;
353 }
354 }
355 if let Some(after) = self.after {
356 if event.timestamp <= after {
357 return false;
358 }
359 }
360 if let Some(before) = self.before {
361 if event.timestamp >= before {
362 return false;
363 }
364 }
365 true
366 }
367}
368
369#[async_trait::async_trait]
375pub trait AuditSink: Send + Sync {
376 async fn log(&self, event: AuditEvent) -> Result<(), crate::AuthError>;
378
379 async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<(), crate::AuthError> {
384 for event in events {
385 self.log(event).await?;
386 }
387 Ok(())
388 }
389
390 async fn query(&self, _filter: &AuditFilter) -> Result<Vec<AuditEvent>, crate::AuthError> {
395 Ok(Vec::new())
396 }
397
398 async fn purge_before(&self, _cutoff: DateTime<Utc>) -> Result<u64, crate::AuthError> {
403 Ok(0)
404 }
405
406 async fn flush(&self) -> Result<(), crate::AuthError> {
410 Ok(())
411 }
412}
413
414pub struct FileAuditSink {
420 writer: Mutex<BufWriter<File>>,
421 path: PathBuf,
422 last_event_json: Mutex<Option<String>>,
424 chain_enabled: bool,
426}
427
428impl FileAuditSink {
429 pub fn new(path: impl Into<PathBuf>) -> Result<Self, std::io::Error> {
431 let path = path.into();
432 let file = OpenOptions::new().create(true).append(true).open(&path)?;
433 let writer = Mutex::new(BufWriter::new(file));
434 Ok(Self { writer, path, last_event_json: Mutex::new(None), chain_enabled: false })
435 }
436
437 pub fn with_chaining(path: impl Into<PathBuf>) -> Result<Self, std::io::Error> {
442 let path = path.into();
443 let file = OpenOptions::new().create(true).append(true).open(&path)?;
444 let writer = Mutex::new(BufWriter::new(file));
445 Ok(Self { writer, path, last_event_json: Mutex::new(None), chain_enabled: true })
446 }
447
448 pub fn path(&self) -> &PathBuf {
450 &self.path
451 }
452}
453
454#[async_trait::async_trait]
455impl AuditSink for FileAuditSink {
456 async fn log(&self, mut event: AuditEvent) -> Result<(), crate::AuthError> {
457 if self.chain_enabled {
459 let mut last = self.last_event_json.lock().unwrap_or_else(|p| p.into_inner());
460 if let Some(ref prev_json) = *last {
461 let mut hasher = Sha256::new();
462 hasher.update(prev_json.as_bytes());
463 event.prev_hash = Some(hex::encode(hasher.finalize()));
464 }
465 let line = serde_json::to_string(&event)
466 .map_err(|e| crate::AuthError::AuditError(e.to_string()))?;
467 *last = Some(line.clone());
468
469 let mut writer = self.writer.lock().unwrap_or_else(|poisoned| {
470 tracing::warn!(path = %self.path.display(), "audit writer lock poisoned, recovering");
471 poisoned.into_inner()
472 });
473 writeln!(writer, "{line}")?;
474 writer.flush()?;
475 } else {
476 let line = serde_json::to_string(&event)
477 .map_err(|e| crate::AuthError::AuditError(e.to_string()))?;
478
479 let mut writer = self.writer.lock().unwrap_or_else(|poisoned| {
480 tracing::warn!(path = %self.path.display(), "audit writer lock poisoned, recovering");
481 poisoned.into_inner()
482 });
483 writeln!(writer, "{line}")?;
484 writer.flush()?;
485 }
486
487 Ok(())
488 }
489}
490
491pub struct InMemoryAuditSink {
497 events: tokio::sync::RwLock<Vec<AuditEvent>>,
498}
499
500impl InMemoryAuditSink {
501 pub fn new() -> Self {
503 Self { events: tokio::sync::RwLock::new(Vec::new()) }
504 }
505
506 pub async fn len(&self) -> usize {
508 self.events.read().await.len()
509 }
510
511 pub async fn is_empty(&self) -> bool {
513 self.events.read().await.is_empty()
514 }
515}
516
517impl Default for InMemoryAuditSink {
518 fn default() -> Self {
519 Self::new()
520 }
521}
522
523#[async_trait::async_trait]
524impl AuditSink for InMemoryAuditSink {
525 async fn log(&self, event: AuditEvent) -> Result<(), crate::AuthError> {
526 self.events.write().await.push(event);
527 Ok(())
528 }
529
530 async fn log_batch(&self, events: Vec<AuditEvent>) -> Result<(), crate::AuthError> {
531 self.events.write().await.extend(events);
532 Ok(())
533 }
534
535 async fn query(&self, filter: &AuditFilter) -> Result<Vec<AuditEvent>, crate::AuthError> {
536 let events = self.events.read().await;
537 let mut results: Vec<AuditEvent> =
538 events.iter().filter(|e| filter.matches(e)).cloned().collect();
539
540 if let Some(offset) = filter.offset {
542 if offset < results.len() {
543 results = results[offset..].to_vec();
544 } else {
545 results.clear();
546 }
547 }
548
549 if let Some(limit) = filter.limit {
551 results.truncate(limit);
552 }
553
554 Ok(results)
555 }
556
557 async fn purge_before(&self, cutoff: DateTime<Utc>) -> Result<u64, crate::AuthError> {
558 let mut events = self.events.write().await;
559 let before_len = events.len();
560 events.retain(|e| e.timestamp >= cutoff);
561 Ok((before_len - events.len()) as u64)
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_audit_event_serialization() {
571 let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed);
572 let json = serde_json::to_string(&event).unwrap();
573 assert!(json.contains("\"user\":\"alice\""));
574 assert!(json.contains("\"resource\":\"search\""));
575 assert!(json.contains("\"outcome\":\"allowed\""));
576 }
577
578 #[test]
579 fn test_audit_event_with_session() {
580 let event = AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied)
581 .with_session("session-123");
582 assert_eq!(event.session_id, Some("session-123".to_string()));
583 }
584
585 #[test]
586 fn test_enterprise_context_fields() {
587 let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
588 .with_workspace("ws_123")
589 .with_tenant("tenant_abc")
590 .with_request_id("req_456")
591 .with_ip_address("192.168.1.1")
592 .with_resource_id("550e8400-e29b-41d4-a716-446655440000")
593 .with_action("execute");
594
595 assert_eq!(event.workspace_id, Some("ws_123".to_string()));
596 assert_eq!(event.tenant_id, Some("tenant_abc".to_string()));
597 assert_eq!(event.request_id, Some("req_456".to_string()));
598 assert_eq!(event.ip_address, Some("192.168.1.1".to_string()));
599 assert_eq!(event.resource_id, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
600 assert_eq!(event.action, Some("execute".to_string()));
601
602 let json = serde_json::to_string(&event).unwrap();
604 assert!(json.contains("\"workspace_id\":\"ws_123\""));
605 assert!(json.contains("\"tenant_id\":\"tenant_abc\""));
606 assert!(json.contains("\"request_id\":\"req_456\""));
607 }
608
609 #[test]
610 fn test_custom_event_type() {
611 let event =
612 AuditEvent::custom("deployment_triggered", "ci-bot", "agent-v2", AuditOutcome::Created);
613 let json = serde_json::to_string(&event).unwrap();
614 assert!(json.contains("deployment_triggered"));
615 assert!(json.contains("\"outcome\":\"created\""));
616 }
617
618 #[test]
619 fn test_new_outcomes() {
620 for outcome in [
621 AuditOutcome::Created,
622 AuditOutcome::Updated,
623 AuditOutcome::Deleted,
624 AuditOutcome::Blocked,
625 AuditOutcome::Paused,
626 AuditOutcome::Escalated,
627 ] {
628 let event =
629 AuditEvent::new(AuditEventType::ResourceCreated, "user", "res", outcome.clone());
630 let json = serde_json::to_string(&event).unwrap();
631 let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
632 assert_eq!(deserialized.outcome, outcome);
633 }
634 }
635
636 #[test]
637 fn test_new_event_types() {
638 let types = vec![
639 AuditEventType::Authentication,
640 AuditEventType::Authorization,
641 AuditEventType::ResourceCreated,
642 AuditEventType::ResourceUpdated,
643 AuditEventType::ResourceDeleted,
644 AuditEventType::ConfigChanged,
645 AuditEventType::SecretAccessed,
646 AuditEventType::SecretRotated,
647 AuditEventType::PaymentExecuted,
648 AuditEventType::PolicyEvaluated,
649 AuditEventType::SessionStarted,
650 AuditEventType::SessionEnded,
651 AuditEventType::Custom("my_event".to_string()),
652 ];
653
654 for event_type in types {
655 let event =
656 AuditEvent::new(event_type.clone(), "user", "resource", AuditOutcome::Allowed);
657 let json = serde_json::to_string(&event).unwrap();
658 let deserialized: AuditEvent = serde_json::from_str(&json).unwrap();
659 assert_eq!(deserialized.event_type, event_type);
660 }
661 }
662
663 #[test]
664 fn test_hash_chaining() {
665 let event1 = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed);
666 let json1 = event1.to_json().unwrap();
667
668 let event2 =
669 AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied).with_prev_hash(&json1);
670
671 assert!(event2.prev_hash.is_some());
672 assert_eq!(event2.prev_hash.as_ref().unwrap().len(), 64);
674 }
675
676 #[test]
677 fn test_audit_filter_matches() {
678 let event = AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
679 .with_workspace("ws_1");
680
681 let filter = AuditFilter { user: Some("alice".to_string()), ..Default::default() };
682 assert!(filter.matches(&event));
683
684 let filter = AuditFilter { user: Some("bob".to_string()), ..Default::default() };
685 assert!(!filter.matches(&event));
686
687 let filter = AuditFilter { workspace_id: Some("ws_1".to_string()), ..Default::default() };
688 assert!(filter.matches(&event));
689
690 let filter = AuditFilter { workspace_id: Some("ws_2".to_string()), ..Default::default() };
691 assert!(!filter.matches(&event));
692 }
693
694 #[test]
695 fn test_backward_compatible_serialization() {
696 let old_json = r#"{"timestamp":"2026-01-01T00:00:00Z","user":"alice","event_type":"tool_access","resource":"search","outcome":"allowed"}"#;
698 let event: AuditEvent = serde_json::from_str(old_json).unwrap();
699 assert_eq!(event.user, "alice");
700 assert_eq!(event.workspace_id, None);
701 assert_eq!(event.tenant_id, None);
702 }
703
704 #[tokio::test]
705 async fn test_in_memory_sink_query() {
706 let sink = InMemoryAuditSink::new();
707
708 sink.log(
709 AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)
710 .with_workspace("ws_1"),
711 )
712 .await
713 .unwrap();
714 sink.log(
715 AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied).with_workspace("ws_1"),
716 )
717 .await
718 .unwrap();
719 sink.log(
720 AuditEvent::tool_access("alice", "deploy", AuditOutcome::Allowed)
721 .with_workspace("ws_2"),
722 )
723 .await
724 .unwrap();
725
726 let results = sink
728 .query(&AuditFilter { user: Some("alice".to_string()), ..Default::default() })
729 .await
730 .unwrap();
731 assert_eq!(results.len(), 2);
732
733 let results = sink
735 .query(&AuditFilter { workspace_id: Some("ws_1".to_string()), ..Default::default() })
736 .await
737 .unwrap();
738 assert_eq!(results.len(), 2);
739
740 let results = sink
742 .query(&AuditFilter { outcome: Some(AuditOutcome::Denied), ..Default::default() })
743 .await
744 .unwrap();
745 assert_eq!(results.len(), 1);
746 assert_eq!(results[0].user, "bob");
747 }
748
749 #[tokio::test]
750 async fn test_in_memory_sink_purge() {
751 let sink = InMemoryAuditSink::new();
752
753 sink.log(AuditEvent::tool_access("alice", "search", AuditOutcome::Allowed)).await.unwrap();
754 sink.log(AuditEvent::tool_access("bob", "exec", AuditOutcome::Denied)).await.unwrap();
755
756 assert_eq!(sink.len().await, 2);
757
758 let cutoff = Utc::now() + chrono::Duration::seconds(1);
760 let purged = sink.purge_before(cutoff).await.unwrap();
761 assert_eq!(purged, 2);
762 assert!(sink.is_empty().await);
763 }
764
765 #[tokio::test]
766 async fn test_in_memory_sink_batch() {
767 let sink = InMemoryAuditSink::new();
768
769 let events = vec![
770 AuditEvent::tool_access("alice", "a", AuditOutcome::Allowed),
771 AuditEvent::tool_access("bob", "b", AuditOutcome::Denied),
772 AuditEvent::tool_access("carol", "c", AuditOutcome::Allowed),
773 ];
774
775 sink.log_batch(events).await.unwrap();
776 assert_eq!(sink.len().await, 3);
777 }
778}