Skip to main content

adk_auth/
audit.rs

1//! Enterprise audit logging for access control and platform operations.
2//!
3//! Provides structured audit events with multi-tenant context, extensible event
4//! types, multiple sink backends, batch logging, cryptographic chaining, and
5//! query/retention APIs.
6//!
7//! # Enterprise Features
8//!
9//! - **Multi-tenant context**: `workspace_id`, `tenant_id`, `request_id` fields
10//! - **Extensible event types**: 15 built-in types + `Custom(String)` for platform extensions
11//! - **Multiple outcomes**: 9 outcome variants covering full lifecycle
12//! - **Batch logging**: `log_batch()` for high-throughput scenarios
13//! - **Cryptographic chaining**: Optional SHA-256 hash chain for append-only integrity
14//! - **Query interface**: `AuditFilter` for searching audit logs from the UI
15//! - **Retention API**: `purge_before()` for compliance-driven data lifecycle
16//! - **Multiple sinks**: File (JSONL), PostgreSQL, OpenTelemetry export
17
18use 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/// Type of audit event.
27///
28/// Covers access control, resource lifecycle, configuration changes, and
29/// platform-specific operations. Use `Custom(String)` for platform extensions
30/// without forking the crate.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum AuditEventType {
35    /// Tool access attempt.
36    ToolAccess,
37    /// Agent access attempt.
38    AgentAccess,
39    /// Permission check.
40    PermissionCheck,
41    /// Authentication event (login, token refresh, logout).
42    Authentication,
43    /// Authorization decision (role/policy evaluation).
44    Authorization,
45    /// Resource created (agent, workspace, project).
46    ResourceCreated,
47    /// Resource updated.
48    ResourceUpdated,
49    /// Resource deleted.
50    ResourceDeleted,
51    /// Configuration changed (settings, feature flags).
52    ConfigChanged,
53    /// Secret accessed (read from vault).
54    SecretAccessed,
55    /// Secret rotated (key rotation event).
56    SecretRotated,
57    /// Payment executed (billing event).
58    PaymentExecuted,
59    /// Policy evaluated (guardrail, rate limit).
60    PolicyEvaluated,
61    /// Session started.
62    SessionStarted,
63    /// Session ended.
64    SessionEnded,
65    /// Platform-specific custom event type.
66    Custom(String),
67}
68
69/// Outcome of an audit event.
70///
71/// Covers the full lifecycle of access decisions and resource operations.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74#[non_exhaustive]
75pub enum AuditOutcome {
76    /// Access was allowed.
77    Allowed,
78    /// Access was denied.
79    Denied,
80    /// An error occurred during the operation.
81    Error,
82    /// Resource was created successfully.
83    Created,
84    /// Resource was updated successfully.
85    Updated,
86    /// Resource was deleted successfully.
87    Deleted,
88    /// Operation was blocked by a policy or guardrail.
89    Blocked,
90    /// Operation was paused (awaiting approval).
91    Paused,
92    /// Operation was escalated to a higher authority.
93    Escalated,
94}
95
96/// An audit event with enterprise multi-tenant context.
97///
98/// All fields beyond `timestamp`, `user`, `event_type`, `resource`, and `outcome`
99/// are optional for backward compatibility. Enterprise platforms populate the
100/// additional context fields for multi-tenancy, tracing, and compliance.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct AuditEvent {
103    /// Timestamp of the event.
104    pub timestamp: DateTime<Utc>,
105    /// User ID (email, subject, or system identifier).
106    pub user: String,
107    /// Session ID (if available).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub session_id: Option<String>,
110    /// Type of event.
111    pub event_type: AuditEventType,
112    /// Resource being accessed (tool name, agent name, or descriptive path).
113    pub resource: String,
114    /// Outcome of the operation.
115    pub outcome: AuditOutcome,
116    /// Additional metadata (arbitrary JSON).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub metadata: Option<serde_json::Value>,
119
120    // ── Enterprise context fields ───────────────────────────────
121    /// Workspace ID for multi-tenant scoping.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub workspace_id: Option<String>,
124    /// Tenant ID for multi-tenant scoping (higher level than workspace).
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub tenant_id: Option<String>,
127    /// Request ID for distributed tracing correlation.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub request_id: Option<String>,
130    /// Client IP address.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub ip_address: Option<String>,
133    /// Resource UUID (distinct from the human-readable `resource` name).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub resource_id: Option<String>,
136    /// Action verb (e.g., "read", "write", "delete", "execute").
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub action: Option<String>,
139    /// SHA-256 hash of the previous event (for cryptographic chaining).
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub prev_hash: Option<String>,
142}
143
144impl AuditEvent {
145    /// Create a new audit event with the given type, user, resource, and outcome.
146    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    /// Create a new tool access event.
171    pub fn tool_access(user: &str, tool_name: &str, outcome: AuditOutcome) -> Self {
172        Self::new(AuditEventType::ToolAccess, user, tool_name, outcome)
173    }
174
175    /// Create a new agent access event.
176    pub fn agent_access(user: &str, agent_name: &str, outcome: AuditOutcome) -> Self {
177        Self::new(AuditEventType::AgentAccess, user, agent_name, outcome)
178    }
179
180    /// Create an authentication event.
181    pub fn authentication(user: &str, outcome: AuditOutcome) -> Self {
182        Self::new(AuditEventType::Authentication, user, "auth", outcome)
183    }
184
185    /// Create a resource lifecycle event.
186    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    /// Create a secret access event.
197    pub fn secret_accessed(user: &str, secret_name: &str, outcome: AuditOutcome) -> Self {
198        Self::new(AuditEventType::SecretAccessed, user, secret_name, outcome)
199    }
200
201    /// Create a configuration change event.
202    pub fn config_changed(user: &str, config_key: &str, outcome: AuditOutcome) -> Self {
203        Self::new(AuditEventType::ConfigChanged, user, config_key, outcome)
204    }
205
206    /// Create a custom event type for platform extensions.
207    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    /// Set the session ID.
212    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    /// Set metadata.
218    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
219        self.metadata = Some(metadata);
220        self
221    }
222
223    /// Set workspace ID for multi-tenant scoping.
224    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    /// Set tenant ID for multi-tenant scoping.
230    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    /// Set request ID for distributed tracing.
236    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    /// Set client IP address.
242    pub fn with_ip_address(mut self, ip: impl Into<String>) -> Self {
243        self.ip_address = Some(ip.into());
244        self
245    }
246
247    /// Set resource UUID.
248    pub fn with_resource_id(mut self, id: impl Into<String>) -> Self {
249        self.resource_id = Some(id.into());
250        self
251    }
252
253    /// Set action verb.
254    pub fn with_action(mut self, action: impl Into<String>) -> Self {
255        self.action = Some(action.into());
256        self
257    }
258
259    /// Compute and set the cryptographic hash chain link.
260    ///
261    /// The hash is SHA-256 of the JSON-serialized previous event.
262    /// Call this before logging to maintain an append-only chain.
263    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    /// Serialize this event to JSON (for hash chaining).
271    pub fn to_json(&self) -> Result<String, serde_json::Error> {
272        serde_json::to_string(self)
273    }
274}
275
276/// Filter for querying audit events.
277///
278/// All fields are optional — only non-None fields are used as filter criteria.
279/// Multiple fields are combined with AND logic.
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct AuditFilter {
282    /// Filter by user ID.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub user: Option<String>,
285    /// Filter by workspace ID.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub workspace_id: Option<String>,
288    /// Filter by tenant ID.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub tenant_id: Option<String>,
291    /// Filter by event type.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub event_type: Option<AuditEventType>,
294    /// Filter by outcome.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub outcome: Option<AuditOutcome>,
297    /// Filter by resource name (substring match).
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub resource: Option<String>,
300    /// Filter by resource UUID.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub resource_id: Option<String>,
303    /// Events after this timestamp.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub after: Option<DateTime<Utc>>,
306    /// Events before this timestamp.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub before: Option<DateTime<Utc>>,
309    /// Maximum number of results to return.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub limit: Option<usize>,
312    /// Offset for pagination.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub offset: Option<usize>,
315}
316
317impl AuditFilter {
318    /// Check if an event matches this filter.
319    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/// Trait for audit sinks.
370///
371/// Implementations persist audit events to a storage backend. The trait supports
372/// single-event logging, batch logging for high throughput, querying for UI
373/// display, and retention management for compliance.
374#[async_trait::async_trait]
375pub trait AuditSink: Send + Sync {
376    /// Log a single audit event.
377    async fn log(&self, event: AuditEvent) -> Result<(), crate::AuthError>;
378
379    /// Log a batch of audit events atomically.
380    ///
381    /// Default implementation logs events sequentially. Override for
382    /// high-throughput backends that support batch inserts.
383    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    /// Query audit events matching the given filter.
391    ///
392    /// Default implementation returns an empty vec (not all sinks support queries).
393    /// Override for queryable backends (PostgreSQL, in-memory).
394    async fn query(&self, _filter: &AuditFilter) -> Result<Vec<AuditEvent>, crate::AuthError> {
395        Ok(Vec::new())
396    }
397
398    /// Purge events older than the given cutoff timestamp.
399    ///
400    /// Returns the number of events purged. Default returns 0 (not all sinks
401    /// support retention management).
402    async fn purge_before(&self, _cutoff: DateTime<Utc>) -> Result<u64, crate::AuthError> {
403        Ok(0)
404    }
405
406    /// Flush any buffered events to the underlying storage.
407    ///
408    /// Default is a no-op. Override for buffered sinks.
409    async fn flush(&self) -> Result<(), crate::AuthError> {
410        Ok(())
411    }
412}
413
414/// File-based audit sink that writes JSONL (one JSON line per event).
415///
416/// Supports optional cryptographic chaining — when enabled, each event includes
417/// the SHA-256 hash of the previous event's JSON representation, creating an
418/// append-only tamper-evident log.
419pub struct FileAuditSink {
420    writer: Mutex<BufWriter<File>>,
421    path: PathBuf,
422    /// Last event JSON for hash chaining (None = chaining disabled).
423    last_event_json: Mutex<Option<String>>,
424    /// Whether to enable cryptographic hash chaining.
425    chain_enabled: bool,
426}
427
428impl FileAuditSink {
429    /// Create a new file audit sink.
430    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    /// Create a new file audit sink with cryptographic hash chaining enabled.
438    ///
439    /// Each event will include a `prev_hash` field containing the SHA-256 hash
440    /// of the previous event's JSON, creating a tamper-evident append-only log.
441    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    /// Get the path to the audit log file.
449    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        // Apply hash chaining if enabled
458        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
491/// In-memory audit sink for testing and development.
492///
493/// Stores all events in a `Vec` behind a `RwLock`. Supports querying and
494/// purging. Not suitable for production — use `FileAuditSink` or a database
495/// sink instead.
496pub struct InMemoryAuditSink {
497    events: tokio::sync::RwLock<Vec<AuditEvent>>,
498}
499
500impl InMemoryAuditSink {
501    /// Create a new in-memory audit sink.
502    pub fn new() -> Self {
503        Self { events: tokio::sync::RwLock::new(Vec::new()) }
504    }
505
506    /// Get the number of stored events.
507    pub async fn len(&self) -> usize {
508        self.events.read().await.len()
509    }
510
511    /// Check if the sink is empty.
512    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        // Apply offset
541        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        // Apply limit
550        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        // Verify JSON serialization includes enterprise fields
603        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        // SHA-256 hex is 64 chars
673        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        // Old-style event without enterprise fields should still deserialize
697        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        // Query by user
727        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        // Query by workspace
734        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        // Query by outcome
741        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        // Purge everything before now+1s (should purge all)
759        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}