Skip to main content

subcog/security/
audit.rs

1//! Audit logging.
2//!
3//! Provides SOC2/GDPR compliant audit logging for memory operations.
4//!
5//! # HMAC Chain Integrity
6//!
7//! Audit entries are cryptographically chained using HMAC-SHA256.
8//! Each entry includes the HMAC of the previous entry, creating
9//! an append-only chain that detects tampering or deletion.
10//!
11//! To verify chain integrity, use [`AuditLogger::verify_chain`].
12
13use crate::models::{EventMeta, MemoryEvent};
14use crate::observability::{
15    RequestContext, current_request_id, global_event_bus, scope_request_context,
16};
17use crate::{Error, Result};
18use chrono::{DateTime, Utc};
19use hmac::{Hmac, Mac};
20use serde::{Deserialize, Serialize};
21use sha2::Sha256;
22use std::path::PathBuf;
23use std::sync::Mutex;
24use std::sync::OnceLock;
25use tracing::Instrument;
26
27/// HMAC-SHA256 type alias.
28type HmacSha256 = Hmac<Sha256>;
29
30/// Audit log entry.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AuditEntry {
33    /// Unique entry ID.
34    pub id: String,
35    /// Timestamp of the event.
36    pub timestamp: DateTime<Utc>,
37    /// Event type.
38    pub event_type: String,
39    /// Actor (user or system).
40    pub actor: String,
41    /// Resource affected.
42    pub resource: Option<String>,
43    /// Action taken.
44    pub action: String,
45    /// Outcome (success/failure).
46    pub outcome: AuditOutcome,
47    /// Additional metadata.
48    pub metadata: serde_json::Value,
49    /// HMAC signature of this entry (hex-encoded).
50    ///
51    /// Computed as: `HMAC-SHA256(key, id || timestamp || event_type || action || previous_hmac)`
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub hmac_signature: Option<String>,
54    /// HMAC of the previous entry in the chain (hex-encoded).
55    ///
56    /// First entry in chain has `previous_hmac` = "genesis".
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub previous_hmac: Option<String>,
59}
60
61/// Outcome of an audited action.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum AuditOutcome {
65    /// Action succeeded.
66    Success,
67    /// Action failed.
68    Failure,
69    /// Action was denied.
70    Denied,
71}
72
73impl AuditEntry {
74    /// Creates a new audit entry for the current time.
75    #[must_use]
76    pub fn new(event_type: impl Into<String>, action: impl Into<String>) -> Self {
77        Self {
78            id: uuid::Uuid::new_v4().to_string(),
79            timestamp: Utc::now(),
80            event_type: event_type.into(),
81            actor: "system".to_string(),
82            resource: None,
83            action: action.into(),
84            outcome: AuditOutcome::Success,
85            metadata: serde_json::Value::Null,
86            hmac_signature: None,
87            previous_hmac: None,
88        }
89    }
90
91    /// Computes the canonical string for HMAC signing.
92    ///
93    /// Format: `id|timestamp|event_type|action|previous_hmac`
94    #[must_use]
95    pub fn canonical_string(&self, previous_hmac: &str) -> String {
96        format!(
97            "{}|{}|{}|{}|{}",
98            self.id,
99            self.timestamp.to_rfc3339(),
100            self.event_type,
101            self.action,
102            previous_hmac
103        )
104    }
105
106    /// Computes the HMAC signature for this entry.
107    ///
108    /// Returns `None` if the HMAC key is invalid (should not happen with valid 32-byte keys).
109    #[must_use]
110    pub fn compute_hmac(&self, key: &[u8], previous_hmac: &str) -> Option<String> {
111        let canonical = self.canonical_string(previous_hmac);
112        let mut mac = HmacSha256::new_from_slice(key).ok()?;
113        mac.update(canonical.as_bytes());
114        let result = mac.finalize();
115        Some(hex::encode(result.into_bytes()))
116    }
117
118    /// Signs this entry with HMAC, setting both signature and previous hash.
119    ///
120    /// Returns `false` if the HMAC key is invalid.
121    pub fn sign(&mut self, key: &[u8], previous_hmac: &str) -> bool {
122        if let Some(sig) = self.compute_hmac(key, previous_hmac) {
123            self.previous_hmac = Some(previous_hmac.to_string());
124            self.hmac_signature = Some(sig);
125            true
126        } else {
127            false
128        }
129    }
130
131    /// Verifies this entry's HMAC signature.
132    ///
133    /// Returns `true` if the signature is valid, `false` otherwise.
134    #[must_use]
135    pub fn verify(&self, key: &[u8]) -> bool {
136        let Some(ref signature) = self.hmac_signature else {
137            return false;
138        };
139        let Some(ref previous) = self.previous_hmac else {
140            return false;
141        };
142
143        self.compute_hmac(key, previous)
144            .is_some_and(|computed| computed == *signature)
145    }
146
147    /// Sets the actor.
148    #[must_use]
149    pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
150        self.actor = actor.into();
151        self
152    }
153
154    /// Sets the resource.
155    #[must_use]
156    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
157        self.resource = Some(resource.into());
158        self
159    }
160
161    /// Sets the outcome.
162    #[must_use]
163    pub const fn with_outcome(mut self, outcome: AuditOutcome) -> Self {
164        self.outcome = outcome;
165        self
166    }
167
168    /// Sets metadata.
169    #[must_use]
170    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
171        self.metadata = metadata;
172        self
173    }
174}
175
176/// Genesis hash for the first entry in an HMAC chain.
177pub const GENESIS_HMAC: &str = "genesis";
178
179/// Audit logger configuration.
180#[derive(Debug, Clone)]
181pub struct AuditConfig {
182    /// Path to audit log file.
183    pub log_path: Option<PathBuf>,
184    /// Whether to also log to stderr.
185    pub log_stderr: bool,
186    /// Minimum retention period in days.
187    pub retention_days: u32,
188    /// Include memory content in logs (may contain sensitive data).
189    pub include_content: bool,
190    /// HMAC key for chain integrity (32 bytes recommended).
191    ///
192    /// If `None`, entries are not signed.
193    pub hmac_key: Option<Vec<u8>>,
194}
195
196impl Default for AuditConfig {
197    fn default() -> Self {
198        Self {
199            log_path: None,
200            log_stderr: false,
201            retention_days: 90,
202            include_content: false,
203            hmac_key: None,
204        }
205    }
206}
207
208impl AuditConfig {
209    /// Creates a new audit config.
210    #[must_use]
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    /// Sets the log path.
216    #[must_use]
217    pub fn with_log_path(mut self, path: impl Into<PathBuf>) -> Self {
218        self.log_path = Some(path.into());
219        self
220    }
221
222    /// Enables stderr logging.
223    #[must_use]
224    pub const fn with_stderr(mut self) -> Self {
225        self.log_stderr = true;
226        self
227    }
228
229    /// Sets retention period.
230    #[must_use]
231    pub const fn with_retention(mut self, days: u32) -> Self {
232        self.retention_days = days;
233        self
234    }
235
236    /// Sets the HMAC key for chain integrity.
237    ///
238    /// The key should be at least 32 bytes for security.
239    #[must_use]
240    pub fn with_hmac_key(mut self, key: Vec<u8>) -> Self {
241        self.hmac_key = Some(key);
242        self
243    }
244}
245
246/// Audit logger for SOC2/GDPR compliance.
247///
248/// # HMAC Chain Integrity
249///
250/// When configured with an HMAC key, the logger maintains a cryptographic
251/// chain where each entry's signature includes the previous entry's signature.
252/// This creates an append-only log that detects tampering or deletion.
253///
254/// The chain starts with `GENESIS_HMAC` as the "previous" value for the
255/// first entry. Each subsequent entry includes the HMAC of the previous entry.
256pub struct AuditLogger {
257    config: AuditConfig,
258    entries: Mutex<Vec<AuditEntry>>,
259    /// Last HMAC in the chain (for signing new entries).
260    last_hmac: Mutex<String>,
261}
262
263static GLOBAL_AUDIT_LOGGER: OnceLock<AuditLogger> = OnceLock::new();
264
265impl AuditLogger {
266    /// Creates a new audit logger with default config.
267    #[must_use]
268    pub fn new() -> Self {
269        Self {
270            config: AuditConfig::default(),
271            entries: Mutex::new(Vec::new()),
272            last_hmac: Mutex::new(GENESIS_HMAC.to_string()),
273        }
274    }
275
276    /// Creates a new audit logger with custom config.
277    #[must_use]
278    pub fn with_config(config: AuditConfig) -> Self {
279        Self {
280            config,
281            entries: Mutex::new(Vec::new()),
282            last_hmac: Mutex::new(GENESIS_HMAC.to_string()),
283        }
284    }
285
286    /// Logs an audit event.
287    pub fn log(&self, event: &MemoryEvent) {
288        let entry = self.event_to_entry(event);
289        self.log_entry(entry);
290    }
291
292    /// Logs a custom audit entry.
293    ///
294    /// If an HMAC key is configured, the entry is signed and chained
295    /// to the previous entry before storage.
296    pub fn log_entry(&self, entry: AuditEntry) {
297        let signed_entry = self.sign_entry(entry);
298
299        // Store in memory
300        if let Ok(mut entries) = self.entries.lock() {
301            entries.push(signed_entry.clone());
302        }
303
304        // Optionally write to file
305        if let Some(ref path) = self.config.log_path {
306            let _ = self.append_to_file(path, &signed_entry);
307        }
308    }
309
310    /// Signs an entry with HMAC if configured, updating chain state.
311    fn sign_entry(&self, mut entry: AuditEntry) -> AuditEntry {
312        let Some(ref key) = self.config.hmac_key else {
313            return entry;
314        };
315        let Ok(mut last) = self.last_hmac.lock() else {
316            return entry;
317        };
318
319        if entry.sign(key, &last)
320            && let Some(ref sig) = entry.hmac_signature
321        {
322            last.clone_from(sig);
323        }
324        entry
325    }
326
327    /// Logs a capture event.
328    pub fn log_capture(&self, memory_id: &str, namespace: &str) {
329        let entry = AuditEntry::new("memory.capture", "create")
330            .with_resource(memory_id)
331            .with_metadata(serde_json::json!({
332                "namespace": namespace
333            }));
334        self.log_entry(entry);
335    }
336
337    /// Logs a search/recall event.
338    pub fn log_recall(&self, query: &str, result_count: usize) {
339        let entry = AuditEntry::new("memory.recall", "search").with_metadata(serde_json::json!({
340            "query_length": query.len(),
341            "result_count": result_count
342        }));
343        self.log_entry(entry);
344    }
345
346    /// Logs a sync event.
347    pub fn log_sync(&self, pushed: usize, pulled: usize) {
348        let entry = AuditEntry::new("memory.sync", "sync").with_metadata(serde_json::json!({
349            "pushed": pushed,
350            "pulled": pulled
351        }));
352        self.log_entry(entry);
353    }
354
355    /// Logs a redaction event.
356    pub fn log_redaction(&self, memory_id: &str, redaction_types: &[String]) {
357        let entry = AuditEntry::new("security.redaction", "redact")
358            .with_resource(memory_id)
359            .with_metadata(serde_json::json!({
360                "redaction_types": redaction_types
361            }));
362        self.log_entry(entry);
363    }
364
365    /// Logs a PII detection event for GDPR/SOC2 compliance.
366    ///
367    /// Records when PII is detected in content, including the types of PII found
368    /// and the count. The actual PII values are NOT logged to avoid storing
369    /// sensitive data in audit logs.
370    pub fn log_pii_detection(&self, pii_types: &[String], context: Option<&str>) {
371        let entry =
372            AuditEntry::new("security.pii_detection", "detect").with_metadata(serde_json::json!({
373                "pii_types": pii_types,
374                "pii_count": pii_types.len(),
375                "context": context
376            }));
377        self.log_entry(entry);
378    }
379
380    /// Logs a PII disclosure event for GDPR/SOC2 compliance.
381    ///
382    /// Records when data containing PII is disclosed to external systems such as:
383    /// - LLM providers (Anthropic, `OpenAI`, Ollama, etc.)
384    /// - Remote sync destinations
385    /// - External APIs
386    ///
387    /// The actual PII values are NOT logged to avoid storing sensitive data.
388    /// Only metadata about the disclosure is recorded.
389    ///
390    /// # Arguments
391    ///
392    /// * `destination` - The external system receiving the data (e.g., "anthropic", "openai")
393    /// * `pii_types` - Types of PII being disclosed
394    /// * `data_subject_id` - Optional identifier of the data subject (anonymized)
395    /// * `purpose` - The purpose of the disclosure
396    /// * `legal_basis` - Legal basis for disclosure (e.g., "consent", "`legitimate_interest`")
397    pub fn log_pii_disclosure(
398        &self,
399        destination: &str,
400        pii_types: &[String],
401        data_subject_id: Option<&str>,
402        purpose: &str,
403        legal_basis: &str,
404    ) {
405        let entry = AuditEntry::new("security.pii_disclosure", "disclose").with_metadata(
406            serde_json::json!({
407                "destination": destination,
408                "pii_types": pii_types,
409                "pii_count": pii_types.len(),
410                "data_subject_id": data_subject_id,
411                "purpose": purpose,
412                "legal_basis": legal_basis,
413                "timestamp_utc": Utc::now().to_rfc3339()
414            }),
415        );
416        self.log_entry(entry);
417    }
418
419    /// Logs a bulk PII disclosure event for batch operations.
420    ///
421    /// Similar to `log_pii_disclosure` but for bulk operations involving multiple
422    /// data subjects or records.
423    ///
424    /// # Arguments
425    ///
426    /// * `destination` - The external system receiving the data
427    /// * `record_count` - Number of records being disclosed
428    /// * `pii_categories` - Categories of PII being disclosed
429    /// * `purpose` - The purpose of the disclosure
430    /// * `legal_basis` - Legal basis for disclosure
431    pub fn log_bulk_pii_disclosure(
432        &self,
433        destination: &str,
434        record_count: usize,
435        pii_categories: &[String],
436        purpose: &str,
437        legal_basis: &str,
438    ) {
439        let entry = AuditEntry::new("security.pii_bulk_disclosure", "bulk_disclose").with_metadata(
440            serde_json::json!({
441                "destination": destination,
442                "record_count": record_count,
443                "pii_categories": pii_categories,
444                "purpose": purpose,
445                "legal_basis": legal_basis,
446                "timestamp_utc": Utc::now().to_rfc3339()
447            }),
448        );
449        self.log_entry(entry);
450    }
451
452    /// Logs an access denied event.
453    pub fn log_denied(&self, action: &str, reason: &str) {
454        let entry = AuditEntry::new("security.denied", action)
455            .with_outcome(AuditOutcome::Denied)
456            .with_metadata(serde_json::json!({
457                "reason": reason
458            }));
459        self.log_entry(entry);
460    }
461
462    /// Returns recent audit entries.
463    #[must_use]
464    pub fn recent_entries(&self, limit: usize) -> Vec<AuditEntry> {
465        if let Ok(entries) = self.entries.lock() {
466            entries.iter().rev().take(limit).cloned().collect()
467        } else {
468            Vec::new()
469        }
470    }
471
472    /// Returns all entries since a given timestamp.
473    #[must_use]
474    pub fn entries_since(&self, since: DateTime<Utc>) -> Vec<AuditEntry> {
475        if let Ok(entries) = self.entries.lock() {
476            entries
477                .iter()
478                .filter(|e| e.timestamp >= since)
479                .cloned()
480                .collect()
481        } else {
482            Vec::new()
483        }
484    }
485
486    /// Clears old entries beyond retention period.
487    pub fn cleanup(&self) {
488        let cutoff = Utc::now() - chrono::Duration::days(i64::from(self.config.retention_days));
489        if let Ok(mut entries) = self.entries.lock() {
490            entries.retain(|e| e.timestamp >= cutoff);
491        }
492    }
493
494    /// Verifies the HMAC chain integrity of all entries.
495    ///
496    /// Returns `Ok(())` if all entries are valid and properly chained,
497    /// or an error describing the first invalid entry found.
498    ///
499    /// # Errors
500    ///
501    /// Returns an error if:
502    /// - No HMAC key is configured
503    /// - An entry has an invalid or missing signature
504    /// - The chain is broken (entry's `previous_hmac` doesn't match prior signature)
505    pub fn verify_chain(&self) -> Result<()> {
506        let key = self
507            .config
508            .hmac_key
509            .as_ref()
510            .ok_or_else(|| Error::OperationFailed {
511                operation: "verify_chain".to_string(),
512                cause: "no HMAC key configured".to_string(),
513            })?;
514
515        // Clone entries to release lock early and avoid significant_drop_tightening
516        let entries: Vec<AuditEntry> = self
517            .entries
518            .lock()
519            .map_err(|_| Error::OperationFailed {
520                operation: "verify_chain".to_string(),
521                cause: "failed to acquire lock".to_string(),
522            })?
523            .clone();
524
525        let mut expected_previous = GENESIS_HMAC.to_string();
526
527        for (i, entry) in entries.iter().enumerate() {
528            // Check that entry has HMAC fields
529            let Some(ref signature) = entry.hmac_signature else {
530                return Err(Error::OperationFailed {
531                    operation: "verify_chain".to_string(),
532                    cause: format!("entry {i} missing hmac_signature"),
533                });
534            };
535            let Some(ref previous) = entry.previous_hmac else {
536                return Err(Error::OperationFailed {
537                    operation: "verify_chain".to_string(),
538                    cause: format!("entry {i} missing previous_hmac"),
539                });
540            };
541
542            // Verify chain linkage
543            if *previous != expected_previous {
544                return Err(Error::OperationFailed {
545                    operation: "verify_chain".to_string(),
546                    cause: format!(
547                        "entry {i} chain broken: expected previous '{expected_previous}', got '{previous}'"
548                    ),
549                });
550            }
551
552            // Verify signature
553            if !entry.verify(key) {
554                return Err(Error::OperationFailed {
555                    operation: "verify_chain".to_string(),
556                    cause: format!("entry {i} has invalid signature"),
557                });
558            }
559
560            // Update expected previous for next iteration
561            expected_previous.clone_from(signature);
562        }
563
564        Ok(())
565    }
566
567    /// Returns whether HMAC signing is enabled.
568    #[must_use]
569    pub const fn is_signing_enabled(&self) -> bool {
570        self.config.hmac_key.is_some()
571    }
572
573    /// Converts a `MemoryEvent` to an `AuditEntry`.
574    #[allow(clippy::too_many_lines)]
575    fn event_to_entry(&self, event: &MemoryEvent) -> AuditEntry {
576        fn base_metadata(meta: &EventMeta) -> serde_json::Map<String, serde_json::Value> {
577            let mut metadata = serde_json::Map::new();
578            metadata.insert(
579                "event_id".to_string(),
580                serde_json::Value::String(meta.event_id.clone()),
581            );
582            metadata.insert(
583                "correlation_id".to_string(),
584                meta.correlation_id
585                    .clone()
586                    .map_or(serde_json::Value::Null, serde_json::Value::String),
587            );
588            metadata.insert(
589                "source".to_string(),
590                serde_json::Value::String(meta.source.to_string()),
591            );
592            metadata.insert(
593                "event_timestamp".to_string(),
594                serde_json::Value::Number(meta.timestamp.into()),
595            );
596            metadata
597        }
598
599        match event {
600            MemoryEvent::Captured {
601                meta,
602                memory_id,
603                namespace,
604                domain,
605                content_length,
606            } => {
607                let mut metadata = base_metadata(meta);
608                metadata.insert(
609                    "namespace".to_string(),
610                    serde_json::Value::String(namespace.as_str().to_string()),
611                );
612                metadata.insert(
613                    "domain".to_string(),
614                    serde_json::Value::String(domain.to_string()),
615                );
616                metadata.insert(
617                    "content_length".to_string(),
618                    serde_json::Value::Number(serde_json::Number::from(*content_length as u64)),
619                );
620
621                AuditEntry::new("memory.captured", "create")
622                    .with_resource(memory_id.to_string())
623                    .with_metadata(serde_json::Value::Object(metadata))
624            },
625
626            MemoryEvent::Retrieved {
627                meta,
628                memory_id,
629                query,
630                score,
631            } => {
632                let mut metadata = base_metadata(meta);
633                metadata.insert(
634                    "query_length".to_string(),
635                    serde_json::Value::Number(serde_json::Number::from(query.len() as u64)),
636                );
637                metadata.insert(
638                    "score".to_string(),
639                    serde_json::Value::Number(
640                        serde_json::Number::from_f64(f64::from(*score))
641                            .unwrap_or_else(|| serde_json::Number::from(0_u64)),
642                    ),
643                );
644
645                AuditEntry::new("memory.retrieved", "read")
646                    .with_resource(memory_id.to_string())
647                    .with_metadata(serde_json::Value::Object(metadata))
648            },
649
650            MemoryEvent::Updated {
651                meta,
652                memory_id,
653                modified_fields,
654            } => {
655                let mut metadata = base_metadata(meta);
656                metadata.insert(
657                    "modified_fields".to_string(),
658                    serde_json::Value::Array(
659                        modified_fields
660                            .iter()
661                            .cloned()
662                            .map(serde_json::Value::String)
663                            .collect(),
664                    ),
665                );
666
667                AuditEntry::new("memory.updated", "update")
668                    .with_resource(memory_id.to_string())
669                    .with_metadata(serde_json::Value::Object(metadata))
670            },
671
672            MemoryEvent::Archived {
673                meta,
674                memory_id,
675                reason,
676            } => {
677                let mut metadata = base_metadata(meta);
678                metadata.insert(
679                    "reason".to_string(),
680                    serde_json::Value::String(reason.clone()),
681                );
682
683                AuditEntry::new("memory.archived", "archive")
684                    .with_resource(memory_id.to_string())
685                    .with_metadata(serde_json::Value::Object(metadata))
686            },
687
688            MemoryEvent::Deleted {
689                meta,
690                memory_id,
691                reason,
692            } => {
693                let mut metadata = base_metadata(meta);
694                metadata.insert(
695                    "reason".to_string(),
696                    serde_json::Value::String(reason.clone()),
697                );
698
699                AuditEntry::new("memory.deleted", "delete")
700                    .with_resource(memory_id.to_string())
701                    .with_metadata(serde_json::Value::Object(metadata))
702            },
703
704            MemoryEvent::Redacted {
705                meta,
706                memory_id,
707                redaction_type,
708            } => {
709                let mut metadata = base_metadata(meta);
710                metadata.insert(
711                    "redaction_type".to_string(),
712                    serde_json::Value::String(redaction_type.clone()),
713                );
714
715                AuditEntry::new("security.redacted", "redact")
716                    .with_resource(memory_id.to_string())
717                    .with_metadata(serde_json::Value::Object(metadata))
718            },
719
720            MemoryEvent::Synced {
721                meta,
722                pushed,
723                pulled,
724                conflicts,
725            } => {
726                let mut metadata = base_metadata(meta);
727                metadata.insert(
728                    "pushed".to_string(),
729                    serde_json::Value::Number(serde_json::Number::from(*pushed as u64)),
730                );
731                metadata.insert(
732                    "pulled".to_string(),
733                    serde_json::Value::Number(serde_json::Number::from(*pulled as u64)),
734                );
735                metadata.insert(
736                    "conflicts".to_string(),
737                    serde_json::Value::Number(serde_json::Number::from(*conflicts as u64)),
738                );
739
740                AuditEntry::new("memory.synced", "sync")
741                    .with_metadata(serde_json::Value::Object(metadata))
742            },
743
744            MemoryEvent::Consolidated {
745                meta,
746                processed,
747                archived,
748                merged,
749            } => {
750                let mut metadata = base_metadata(meta);
751                metadata.insert(
752                    "processed".to_string(),
753                    serde_json::Value::Number(serde_json::Number::from(*processed as u64)),
754                );
755                metadata.insert(
756                    "archived".to_string(),
757                    serde_json::Value::Number(serde_json::Number::from(*archived as u64)),
758                );
759                metadata.insert(
760                    "merged".to_string(),
761                    serde_json::Value::Number(serde_json::Number::from(*merged as u64)),
762                );
763
764                AuditEntry::new("memory.consolidated", "consolidate")
765                    .with_metadata(serde_json::Value::Object(metadata))
766            },
767
768            MemoryEvent::McpStarted {
769                meta,
770                transport,
771                port,
772            } => {
773                let mut metadata = base_metadata(meta);
774                metadata.insert(
775                    "transport".to_string(),
776                    serde_json::Value::String(transport.clone()),
777                );
778                metadata.insert(
779                    "port".to_string(),
780                    port.map_or(serde_json::Value::Null, |p| {
781                        serde_json::Value::Number(p.into())
782                    }),
783                );
784
785                AuditEntry::new("mcp.started", "start")
786                    .with_metadata(serde_json::Value::Object(metadata))
787            },
788
789            MemoryEvent::McpAuthFailed {
790                meta,
791                client_id,
792                reason,
793            } => {
794                let mut metadata = base_metadata(meta);
795                metadata.insert(
796                    "client_id".to_string(),
797                    client_id
798                        .clone()
799                        .map_or(serde_json::Value::Null, serde_json::Value::String),
800                );
801                metadata.insert(
802                    "reason".to_string(),
803                    serde_json::Value::String(reason.clone()),
804                );
805
806                AuditEntry::new("mcp.auth_failed", "authenticate")
807                    .with_outcome(AuditOutcome::Denied)
808                    .with_metadata(serde_json::Value::Object(metadata))
809            },
810
811            MemoryEvent::McpToolExecuted {
812                meta,
813                tool_name,
814                status,
815                duration_ms,
816                error,
817            } => {
818                let mut metadata = base_metadata(meta);
819                metadata.insert(
820                    "tool_name".to_string(),
821                    serde_json::Value::String(tool_name.clone()),
822                );
823                metadata.insert(
824                    "status".to_string(),
825                    serde_json::Value::String(status.clone()),
826                );
827                metadata.insert(
828                    "duration_ms".to_string(),
829                    serde_json::Value::Number((*duration_ms).into()),
830                );
831                metadata.insert(
832                    "error".to_string(),
833                    error
834                        .clone()
835                        .map_or(serde_json::Value::Null, serde_json::Value::String),
836                );
837
838                let outcome = if status == "success" {
839                    AuditOutcome::Success
840                } else {
841                    AuditOutcome::Failure
842                };
843
844                AuditEntry::new("mcp.tool_executed", "execute")
845                    .with_outcome(outcome)
846                    .with_metadata(serde_json::Value::Object(metadata))
847            },
848
849            MemoryEvent::McpRequestError {
850                meta,
851                operation,
852                error,
853            } => {
854                let mut metadata = base_metadata(meta);
855                metadata.insert(
856                    "operation".to_string(),
857                    serde_json::Value::String(operation.clone()),
858                );
859                metadata.insert(
860                    "error".to_string(),
861                    serde_json::Value::String(error.clone()),
862                );
863
864                AuditEntry::new("mcp.request_error", "request")
865                    .with_outcome(AuditOutcome::Failure)
866                    .with_metadata(serde_json::Value::Object(metadata))
867            },
868
869            MemoryEvent::HookInvoked { meta, hook } => {
870                let mut metadata = base_metadata(meta);
871                metadata.insert("hook".to_string(), serde_json::Value::String(hook.clone()));
872
873                AuditEntry::new("hook.invoked", "invoke")
874                    .with_metadata(serde_json::Value::Object(metadata))
875            },
876
877            MemoryEvent::HookClassified {
878                meta,
879                hook,
880                classification,
881                classifier,
882                confidence,
883            } => {
884                let mut metadata = base_metadata(meta);
885                metadata.insert("hook".to_string(), serde_json::Value::String(hook.clone()));
886                metadata.insert(
887                    "classification".to_string(),
888                    serde_json::Value::String(classification.clone()),
889                );
890                metadata.insert(
891                    "classifier".to_string(),
892                    serde_json::Value::String(classifier.clone()),
893                );
894                metadata.insert(
895                    "confidence".to_string(),
896                    serde_json::Value::Number(
897                        serde_json::Number::from_f64(f64::from(*confidence))
898                            .unwrap_or_else(|| serde_json::Number::from(0_u64)),
899                    ),
900                );
901
902                AuditEntry::new("hook.classified", "classify")
903                    .with_metadata(serde_json::Value::Object(metadata))
904            },
905
906            MemoryEvent::HookCaptureDecision {
907                meta,
908                hook,
909                decision,
910                namespace,
911                memory_id,
912            } => {
913                let mut metadata = base_metadata(meta);
914                metadata.insert("hook".to_string(), serde_json::Value::String(hook.clone()));
915                metadata.insert(
916                    "decision".to_string(),
917                    serde_json::Value::String(decision.clone()),
918                );
919                metadata.insert(
920                    "namespace".to_string(),
921                    namespace
922                        .clone()
923                        .map_or(serde_json::Value::Null, serde_json::Value::String),
924                );
925                metadata.insert(
926                    "memory_id".to_string(),
927                    memory_id.as_ref().map_or(serde_json::Value::Null, |id| {
928                        serde_json::Value::String(id.to_string())
929                    }),
930                );
931
932                AuditEntry::new("hook.capture_decision", "decision")
933                    .with_metadata(serde_json::Value::Object(metadata))
934            },
935
936            MemoryEvent::HookFailed { meta, hook, error } => {
937                let mut metadata = base_metadata(meta);
938                metadata.insert("hook".to_string(), serde_json::Value::String(hook.clone()));
939                metadata.insert(
940                    "error".to_string(),
941                    serde_json::Value::String(error.clone()),
942                );
943
944                AuditEntry::new("hook.failed", "invoke")
945                    .with_outcome(AuditOutcome::Failure)
946                    .with_metadata(serde_json::Value::Object(metadata))
947            },
948        }
949    }
950
951    /// Appends an entry to the log file.
952    ///
953    /// # Security
954    ///
955    /// - Path canonicalization is performed to prevent TOCTOU race conditions
956    ///   where a symlink could be modified between path validation and file open.
957    /// - On Unix, file permissions are set atomically to 0o600 (owner read/write only)
958    ///   at file creation time using `OpenOptionsExt::mode()` to prevent race conditions.
959    fn append_to_file(&self, path: &std::path::Path, entry: &AuditEntry) -> std::io::Result<()> {
960        use std::fs::OpenOptions;
961        use std::io::Write;
962
963        // Canonicalize path to resolve symlinks and prevent TOCTOU attacks.
964        // If the file doesn't exist yet, canonicalize the parent directory instead.
965        let canonical_path = Self::canonicalize_path(path)?;
966
967        // Use OpenOptionsExt::mode() on Unix to set permissions atomically at creation time.
968        // This prevents the TOCTOU race where the file could be accessed with default
969        // permissions before set_permissions() is called.
970        #[cfg(unix)]
971        let mut file = {
972            use std::os::unix::fs::OpenOptionsExt;
973            OpenOptions::new()
974                .create(true)
975                .append(true)
976                .mode(0o600) // Atomic permission setting at creation
977                .open(&canonical_path)?
978        };
979
980        #[cfg(not(unix))]
981        let mut file = OpenOptions::new()
982            .create(true)
983            .append(true)
984            .open(&canonical_path)?;
985
986        let json = serde_json::to_string(entry)
987            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
988
989        writeln!(file, "{json}")?;
990        Ok(())
991    }
992
993    /// Canonicalizes a path, handling non-existent files by canonicalizing the parent.
994    fn canonicalize_path(path: &std::path::Path) -> std::io::Result<PathBuf> {
995        if path.exists() {
996            return path.canonicalize();
997        }
998
999        let Some(parent) = path.parent() else {
1000            return Ok(path.to_path_buf());
1001        };
1002
1003        if !parent.exists() {
1004            // Parent doesn't exist - return as-is, let OpenOptions handle the error
1005            return Ok(path.to_path_buf());
1006        }
1007
1008        let file_name = path.file_name().ok_or_else(|| {
1009            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid file name")
1010        })?;
1011
1012        Ok(parent.canonicalize()?.join(file_name))
1013    }
1014}
1015
1016/// Initializes the global audit logger.
1017///
1018/// # Errors
1019///
1020/// Returns an error if the log directory cannot be created or if the global
1021/// audit logger has already been initialized.
1022pub fn init_global(config: AuditConfig) -> Result<()> {
1023    if let Some(ref path) = config.log_path
1024        && let Some(parent) = path.parent()
1025    {
1026        std::fs::create_dir_all(parent).map_err(|e| Error::OperationFailed {
1027            operation: "init_audit_logger".to_string(),
1028            cause: e.to_string(),
1029        })?;
1030    }
1031
1032    GLOBAL_AUDIT_LOGGER
1033        .set(AuditLogger::with_config(config))
1034        .map_err(|_logger| Error::OperationFailed {
1035            operation: "init_audit_logger".to_string(),
1036            cause: "audit logger already initialized".to_string(),
1037        })?;
1038
1039    start_audit_subscription();
1040
1041    Ok(())
1042}
1043
1044/// Returns the global audit logger, if initialized.
1045#[must_use]
1046pub fn global_logger() -> Option<&'static AuditLogger> {
1047    GLOBAL_AUDIT_LOGGER.get()
1048}
1049
1050fn log_event_if_configured(event: &MemoryEvent) {
1051    if let Some(logger) = global_logger() {
1052        logger.log(event);
1053    }
1054}
1055
1056fn start_audit_subscription() {
1057    if tokio::runtime::Handle::try_current().is_err() {
1058        tracing::warn!("Audit event subscription requires a Tokio runtime");
1059        return;
1060    }
1061
1062    let mut receiver = global_event_bus().subscribe();
1063    let span = tracing::Span::current();
1064    let request_context = current_request_id().map(RequestContext::from_id);
1065    tokio::spawn(
1066        async move {
1067            let run = async move {
1068                while let Ok(event) = receiver.recv().await {
1069                    log_event_if_configured(&event);
1070                }
1071            };
1072
1073            if let Some(context) = request_context {
1074                scope_request_context(context, run).await;
1075            } else {
1076                run.await;
1077            }
1078        }
1079        .instrument(span),
1080    );
1081}
1082
1083/// Records a memory event through the global audit logger.
1084pub fn record_event(event: MemoryEvent) {
1085    global_event_bus().publish(event);
1086}
1087
1088impl Default for AuditLogger {
1089    fn default() -> Self {
1090        Self::new()
1091    }
1092}
1093
1094// ============================================================================
1095// Access Review Reports (COMP-HIGH-004)
1096// ============================================================================
1097
1098/// Access review report for SOC2 compliance.
1099///
1100/// Aggregates audit entries into a structured report showing:
1101/// - Who accessed what resources
1102/// - When accesses occurred
1103/// - What actions were taken
1104#[derive(Debug, Clone, Serialize, Deserialize)]
1105pub struct AccessReviewReport {
1106    /// Report generation timestamp.
1107    pub generated_at: DateTime<Utc>,
1108    /// Start of the review period.
1109    pub period_start: DateTime<Utc>,
1110    /// End of the review period.
1111    pub period_end: DateTime<Utc>,
1112    /// Total number of access events.
1113    pub total_events: usize,
1114    /// Summary by actor (user or system).
1115    pub by_actor: std::collections::HashMap<String, ActorAccessSummary>,
1116    /// Summary by resource type.
1117    pub by_resource_type: std::collections::HashMap<String, usize>,
1118    /// Summary by action type.
1119    pub by_action: std::collections::HashMap<String, usize>,
1120    /// Summary by outcome.
1121    pub by_outcome: OutcomeSummary,
1122}
1123
1124/// Summary of access events for a single actor.
1125#[derive(Debug, Clone, Serialize, Deserialize)]
1126pub struct ActorAccessSummary {
1127    /// Total events for this actor.
1128    pub event_count: usize,
1129    /// Distinct resources accessed.
1130    pub resources_accessed: std::collections::HashSet<String>,
1131    /// Actions performed.
1132    pub actions: std::collections::HashMap<String, usize>,
1133    /// First access timestamp.
1134    pub first_access: DateTime<Utc>,
1135    /// Last access timestamp.
1136    pub last_access: DateTime<Utc>,
1137}
1138
1139/// Summary of outcomes across all events.
1140#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1141pub struct OutcomeSummary {
1142    /// Count of successful operations.
1143    pub success: usize,
1144    /// Count of failed operations.
1145    pub failure: usize,
1146    /// Count of denied operations.
1147    pub denied: usize,
1148}
1149
1150impl AccessReviewReport {
1151    /// Generates an access review report from audit entries.
1152    ///
1153    /// # Arguments
1154    ///
1155    /// * `entries` - The audit entries to analyze
1156    /// * `period_start` - Start of the review period
1157    /// * `period_end` - End of the review period
1158    #[must_use]
1159    pub fn generate(
1160        entries: &[AuditEntry],
1161        period_start: DateTime<Utc>,
1162        period_end: DateTime<Utc>,
1163    ) -> Self {
1164        let mut by_actor: std::collections::HashMap<String, ActorAccessSummary> =
1165            std::collections::HashMap::new();
1166        let mut by_resource_type: std::collections::HashMap<String, usize> =
1167            std::collections::HashMap::new();
1168        let mut by_action: std::collections::HashMap<String, usize> =
1169            std::collections::HashMap::new();
1170        let mut by_outcome = OutcomeSummary::default();
1171
1172        // Filter entries within the period
1173        let filtered: Vec<_> = entries
1174            .iter()
1175            .filter(|e| e.timestamp >= period_start && e.timestamp <= period_end)
1176            .collect();
1177
1178        for entry in &filtered {
1179            // Update actor summary
1180            let actor_summary =
1181                by_actor
1182                    .entry(entry.actor.clone())
1183                    .or_insert_with(|| ActorAccessSummary {
1184                        event_count: 0,
1185                        resources_accessed: std::collections::HashSet::new(),
1186                        actions: std::collections::HashMap::new(),
1187                        first_access: entry.timestamp,
1188                        last_access: entry.timestamp,
1189                    });
1190
1191            actor_summary.event_count += 1;
1192            if let Some(ref resource) = entry.resource {
1193                actor_summary.resources_accessed.insert(resource.clone());
1194            }
1195            *actor_summary
1196                .actions
1197                .entry(entry.action.clone())
1198                .or_insert(0) += 1;
1199            if entry.timestamp < actor_summary.first_access {
1200                actor_summary.first_access = entry.timestamp;
1201            }
1202            if entry.timestamp > actor_summary.last_access {
1203                actor_summary.last_access = entry.timestamp;
1204            }
1205
1206            // Update resource type count
1207            *by_resource_type
1208                .entry(entry.event_type.clone())
1209                .or_insert(0) += 1;
1210
1211            // Update action count
1212            *by_action.entry(entry.action.clone()).or_insert(0) += 1;
1213
1214            // Update outcome count
1215            match entry.outcome {
1216                AuditOutcome::Success => by_outcome.success += 1,
1217                AuditOutcome::Failure => by_outcome.failure += 1,
1218                AuditOutcome::Denied => by_outcome.denied += 1,
1219            }
1220        }
1221
1222        Self {
1223            generated_at: Utc::now(),
1224            period_start,
1225            period_end,
1226            total_events: filtered.len(),
1227            by_actor,
1228            by_resource_type,
1229            by_action,
1230            by_outcome,
1231        }
1232    }
1233}
1234
1235impl AuditLogger {
1236    /// Generates an access review report for the specified period.
1237    ///
1238    /// # Arguments
1239    ///
1240    /// * `period_start` - Start of the review period
1241    /// * `period_end` - End of the review period
1242    ///
1243    /// # Example
1244    ///
1245    /// ```rust,ignore
1246    /// use chrono::{Duration, Utc};
1247    /// use subcog::security::AuditLogger;
1248    ///
1249    /// let logger = AuditLogger::new();
1250    /// let end = Utc::now();
1251    /// let start = end - Duration::days(30);
1252    ///
1253    /// let report = logger.generate_access_review(start, end);
1254    /// println!("Total events: {}", report.total_events);
1255    /// ```
1256    #[must_use]
1257    pub fn generate_access_review(
1258        &self,
1259        period_start: DateTime<Utc>,
1260        period_end: DateTime<Utc>,
1261    ) -> AccessReviewReport {
1262        let entries = self.entries_since(period_start);
1263        AccessReviewReport::generate(&entries, period_start, period_end)
1264    }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270    use crate::models::{Domain, EventMeta, MemoryId, Namespace};
1271
1272    #[test]
1273    fn test_audit_entry_creation() {
1274        let entry = AuditEntry::new("test.event", "test_action")
1275            .with_actor("test_user")
1276            .with_resource("resource_id")
1277            .with_outcome(AuditOutcome::Success);
1278
1279        assert_eq!(entry.event_type, "test.event");
1280        assert_eq!(entry.action, "test_action");
1281        assert_eq!(entry.actor, "test_user");
1282        assert_eq!(entry.resource, Some("resource_id".to_string()));
1283        assert_eq!(entry.outcome, AuditOutcome::Success);
1284    }
1285
1286    #[test]
1287    fn test_log_capture() {
1288        let logger = AuditLogger::new();
1289        logger.log_capture("mem_123", "decisions");
1290
1291        let entries = logger.recent_entries(10);
1292        assert_eq!(entries.len(), 1);
1293        assert_eq!(entries[0].event_type, "memory.capture");
1294    }
1295
1296    #[test]
1297    fn test_log_recall() {
1298        let logger = AuditLogger::new();
1299        logger.log_recall("test query", 5);
1300
1301        let entries = logger.recent_entries(10);
1302        assert_eq!(entries.len(), 1);
1303        assert_eq!(entries[0].event_type, "memory.recall");
1304    }
1305
1306    #[test]
1307    fn test_log_denied() {
1308        let logger = AuditLogger::new();
1309        logger.log_denied("capture", "secrets detected");
1310
1311        let entries = logger.recent_entries(10);
1312        assert_eq!(entries.len(), 1);
1313        assert_eq!(entries[0].outcome, AuditOutcome::Denied);
1314    }
1315
1316    #[test]
1317    fn test_log_memory_event() {
1318        let logger = AuditLogger::new();
1319        let event = MemoryEvent::Captured {
1320            meta: EventMeta::with_timestamp("test", None, 1_234_567_890),
1321            memory_id: MemoryId::new("test_id"),
1322            namespace: Namespace::Decisions,
1323            domain: Domain::new(),
1324            content_length: 100,
1325        };
1326
1327        logger.log(&event);
1328
1329        let entries = logger.recent_entries(10);
1330        assert_eq!(entries.len(), 1);
1331        assert_eq!(entries[0].event_type, "memory.captured");
1332    }
1333
1334    #[test]
1335    fn test_entries_since() {
1336        let logger = AuditLogger::new();
1337
1338        // Log some entries
1339        logger.log_capture("mem_1", "decisions");
1340        logger.log_capture("mem_2", "learnings");
1341
1342        let since = Utc::now() - chrono::Duration::hours(1);
1343        let entries = logger.entries_since(since);
1344
1345        assert_eq!(entries.len(), 2);
1346    }
1347
1348    #[test]
1349    fn test_cleanup() {
1350        let config = AuditConfig::new().with_retention(0); // 0 days = immediate cleanup
1351        let logger = AuditLogger::with_config(config);
1352
1353        logger.log_capture("mem_1", "decisions");
1354
1355        // Wait a tiny bit and cleanup
1356        std::thread::sleep(std::time::Duration::from_millis(10));
1357        logger.cleanup();
1358
1359        let entries = logger.recent_entries(10);
1360        assert!(entries.is_empty());
1361    }
1362
1363    #[test]
1364    fn test_audit_entry_serialization() {
1365        let entry = AuditEntry::new("test.event", "action")
1366            .with_metadata(serde_json::json!({"key": "value"}));
1367
1368        let json = serde_json::to_string(&entry).unwrap();
1369        assert!(json.contains("test.event"));
1370
1371        let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
1372        assert_eq!(deserialized.event_type, entry.event_type);
1373    }
1374
1375    // HMAC Chain Tests
1376
1377    #[test]
1378    fn test_hmac_sign_and_verify() {
1379        let key = b"test_key_32_bytes_long_xxxxxxxx";
1380        let mut entry = AuditEntry::new("test.event", "action");
1381
1382        assert!(entry.sign(key, GENESIS_HMAC));
1383        assert!(entry.hmac_signature.is_some());
1384        assert_eq!(entry.previous_hmac, Some(GENESIS_HMAC.to_string()));
1385        assert!(entry.verify(key));
1386    }
1387
1388    #[test]
1389    fn test_hmac_verify_fails_with_wrong_key() {
1390        let key = b"test_key_32_bytes_long_xxxxxxxx";
1391        let wrong_key = b"wrong_key_32_bytes_long_xxxxxxx";
1392        let mut entry = AuditEntry::new("test.event", "action");
1393
1394        assert!(entry.sign(key, GENESIS_HMAC));
1395        assert!(!entry.verify(wrong_key));
1396    }
1397
1398    #[test]
1399    fn test_hmac_verify_fails_with_tampered_content() {
1400        let key = b"test_key_32_bytes_long_xxxxxxxx";
1401        let mut entry = AuditEntry::new("test.event", "action");
1402
1403        assert!(entry.sign(key, GENESIS_HMAC));
1404        entry.action = "tampered_action".to_string();
1405        assert!(!entry.verify(key));
1406    }
1407
1408    #[test]
1409    fn test_hmac_chain_signing() {
1410        let key = vec![0u8; 32]; // 32-byte key
1411        let config = AuditConfig::new().with_hmac_key(key.clone());
1412        let logger = AuditLogger::with_config(config);
1413
1414        // Log multiple entries
1415        logger.log_capture("mem_1", "decisions");
1416        logger.log_capture("mem_2", "learnings");
1417        logger.log_capture("mem_3", "patterns");
1418
1419        let entries = logger.recent_entries(10);
1420        assert_eq!(entries.len(), 3);
1421
1422        // All entries should be signed
1423        for entry in &entries {
1424            assert!(entry.hmac_signature.is_some());
1425            assert!(entry.previous_hmac.is_some());
1426            assert!(entry.verify(&key));
1427        }
1428    }
1429
1430    #[test]
1431    fn test_hmac_chain_verification() {
1432        let key = vec![0u8; 32];
1433        let config = AuditConfig::new().with_hmac_key(key);
1434        let logger = AuditLogger::with_config(config);
1435
1436        logger.log_capture("mem_1", "decisions");
1437        logger.log_capture("mem_2", "learnings");
1438
1439        assert!(logger.verify_chain().is_ok());
1440    }
1441
1442    #[test]
1443    fn test_hmac_chain_verification_no_key() {
1444        let logger = AuditLogger::new();
1445        logger.log_capture("mem_1", "decisions");
1446
1447        // Should fail because no HMAC key is configured
1448        assert!(logger.verify_chain().is_err());
1449    }
1450
1451    #[test]
1452    fn test_is_signing_enabled() {
1453        let logger_without_key = AuditLogger::new();
1454        assert!(!logger_without_key.is_signing_enabled());
1455
1456        let config = AuditConfig::new().with_hmac_key(vec![0u8; 32]);
1457        let logger_with_key = AuditLogger::with_config(config);
1458        assert!(logger_with_key.is_signing_enabled());
1459    }
1460
1461    #[test]
1462    fn test_hmac_entry_serialization_with_signature() {
1463        let key = b"test_key_32_bytes_long_xxxxxxxx";
1464        let mut entry = AuditEntry::new("test.event", "action");
1465        assert!(entry.sign(key, GENESIS_HMAC));
1466
1467        let json = serde_json::to_string(&entry).unwrap();
1468        assert!(json.contains("hmac_signature"));
1469        assert!(json.contains("previous_hmac"));
1470        assert!(json.contains("genesis"));
1471
1472        let deserialized: AuditEntry = serde_json::from_str(&json).unwrap();
1473        assert_eq!(deserialized.hmac_signature, entry.hmac_signature);
1474        assert_eq!(deserialized.previous_hmac, entry.previous_hmac);
1475        assert!(deserialized.verify(key));
1476    }
1477
1478    #[test]
1479    fn test_unsigned_entry_omits_hmac_fields() {
1480        let entry = AuditEntry::new("test.event", "action");
1481
1482        let json = serde_json::to_string(&entry).unwrap();
1483        // Fields with skip_serializing_if = "Option::is_none" should be omitted
1484        assert!(!json.contains("hmac_signature"));
1485        assert!(!json.contains("previous_hmac"));
1486    }
1487
1488    #[test]
1489    fn test_log_pii_detection() {
1490        let logger = AuditLogger::new();
1491        let pii_types = vec!["Email Address".to_string(), "SSN".to_string()];
1492        logger.log_pii_detection(&pii_types, Some("content_redaction"));
1493
1494        let entries = logger.recent_entries(10);
1495        assert_eq!(entries.len(), 1);
1496        assert_eq!(entries[0].event_type, "security.pii_detection");
1497        assert_eq!(entries[0].action, "detect");
1498
1499        // Verify metadata contains expected fields
1500        let metadata = &entries[0].metadata;
1501        assert_eq!(metadata["pii_count"], 2);
1502        assert_eq!(metadata["context"], "content_redaction");
1503    }
1504
1505    #[test]
1506    fn test_log_pii_detection_without_context() {
1507        let logger = AuditLogger::new();
1508        let pii_types = vec!["Phone Number".to_string()];
1509        logger.log_pii_detection(&pii_types, None);
1510
1511        let entries = logger.recent_entries(10);
1512        assert_eq!(entries.len(), 1);
1513        assert_eq!(entries[0].metadata["pii_count"], 1);
1514        assert!(entries[0].metadata["context"].is_null());
1515    }
1516
1517    // Access Review Report Tests
1518
1519    #[test]
1520    fn test_access_review_report_empty() {
1521        let entries: Vec<AuditEntry> = vec![];
1522        let start = Utc::now() - chrono::Duration::hours(1);
1523        let end = Utc::now();
1524
1525        let report = AccessReviewReport::generate(&entries, start, end);
1526
1527        assert_eq!(report.total_events, 0);
1528        assert!(report.by_actor.is_empty());
1529        assert!(report.by_resource_type.is_empty());
1530        assert!(report.by_action.is_empty());
1531        assert_eq!(report.by_outcome.success, 0);
1532        assert_eq!(report.by_outcome.failure, 0);
1533        assert_eq!(report.by_outcome.denied, 0);
1534    }
1535
1536    #[test]
1537    fn test_access_review_report_single_entry() {
1538        let entry = AuditEntry::new("memory.capture", "capture")
1539            .with_actor("user1")
1540            .with_resource("mem_123")
1541            .with_outcome(AuditOutcome::Success);
1542
1543        let start = Utc::now() - chrono::Duration::hours(1);
1544        let end = Utc::now() + chrono::Duration::hours(1);
1545
1546        let report = AccessReviewReport::generate(&[entry], start, end);
1547
1548        assert_eq!(report.total_events, 1);
1549        assert_eq!(report.by_actor.len(), 1);
1550        assert!(report.by_actor.contains_key("user1"));
1551
1552        let actor_summary = report.by_actor.get("user1").unwrap();
1553        assert_eq!(actor_summary.event_count, 1);
1554        assert!(actor_summary.resources_accessed.contains("mem_123"));
1555        assert_eq!(actor_summary.actions.get("capture"), Some(&1));
1556
1557        assert_eq!(report.by_outcome.success, 1);
1558        assert_eq!(report.by_outcome.failure, 0);
1559        assert_eq!(report.by_outcome.denied, 0);
1560    }
1561
1562    #[test]
1563    fn test_access_review_report_multiple_actors() {
1564        let entry1 = AuditEntry::new("memory.capture", "capture")
1565            .with_actor("user1")
1566            .with_outcome(AuditOutcome::Success);
1567        let entry2 = AuditEntry::new("memory.recall", "recall")
1568            .with_actor("user2")
1569            .with_outcome(AuditOutcome::Success);
1570        let entry3 = AuditEntry::new("memory.capture", "capture")
1571            .with_actor("user1")
1572            .with_outcome(AuditOutcome::Denied);
1573
1574        let start = Utc::now() - chrono::Duration::hours(1);
1575        let end = Utc::now() + chrono::Duration::hours(1);
1576
1577        let report = AccessReviewReport::generate(&[entry1, entry2, entry3], start, end);
1578
1579        assert_eq!(report.total_events, 3);
1580        assert_eq!(report.by_actor.len(), 2);
1581
1582        let user1_summary = report.by_actor.get("user1").unwrap();
1583        assert_eq!(user1_summary.event_count, 2);
1584        assert_eq!(user1_summary.actions.get("capture"), Some(&2));
1585
1586        let user2_summary = report.by_actor.get("user2").unwrap();
1587        assert_eq!(user2_summary.event_count, 1);
1588        assert_eq!(user2_summary.actions.get("recall"), Some(&1));
1589    }
1590
1591    #[test]
1592    fn test_access_review_report_outcome_counting() {
1593        let entry1 = AuditEntry::new("test.event", "action").with_outcome(AuditOutcome::Success);
1594        let entry2 = AuditEntry::new("test.event", "action").with_outcome(AuditOutcome::Success);
1595        let entry3 = AuditEntry::new("test.event", "action").with_outcome(AuditOutcome::Failure);
1596        let entry4 = AuditEntry::new("test.event", "action").with_outcome(AuditOutcome::Denied);
1597        let entry5 = AuditEntry::new("test.event", "action").with_outcome(AuditOutcome::Denied);
1598
1599        let start = Utc::now() - chrono::Duration::hours(1);
1600        let end = Utc::now() + chrono::Duration::hours(1);
1601
1602        let report =
1603            AccessReviewReport::generate(&[entry1, entry2, entry3, entry4, entry5], start, end);
1604
1605        assert_eq!(report.by_outcome.success, 2);
1606        assert_eq!(report.by_outcome.failure, 1);
1607        assert_eq!(report.by_outcome.denied, 2);
1608    }
1609
1610    #[test]
1611    fn test_access_review_report_filters_by_period() {
1612        let now = Utc::now();
1613
1614        // Create entries at different times
1615        let mut entry_old = AuditEntry::new("memory.capture", "capture");
1616        entry_old.timestamp = now - chrono::Duration::days(10);
1617
1618        let mut entry_in_range = AuditEntry::new("memory.recall", "recall");
1619        entry_in_range.timestamp = now - chrono::Duration::hours(12);
1620
1621        let mut entry_future = AuditEntry::new("memory.delete", "delete");
1622        entry_future.timestamp = now + chrono::Duration::days(10);
1623
1624        let start = now - chrono::Duration::days(1);
1625        let end = now + chrono::Duration::days(1);
1626
1627        let report =
1628            AccessReviewReport::generate(&[entry_old, entry_in_range, entry_future], start, end);
1629
1630        // Only entry_in_range should be included
1631        assert_eq!(report.total_events, 1);
1632        assert_eq!(report.by_action.get("recall"), Some(&1));
1633        assert!(!report.by_action.contains_key("capture"));
1634        assert!(!report.by_action.contains_key("delete"));
1635    }
1636
1637    #[test]
1638    fn test_access_review_report_resource_aggregation() {
1639        let entry1 = AuditEntry::new("memory.capture", "capture")
1640            .with_actor("user1")
1641            .with_resource("mem_1");
1642        let entry2 = AuditEntry::new("memory.capture", "capture")
1643            .with_actor("user1")
1644            .with_resource("mem_2");
1645        let entry3 = AuditEntry::new("memory.recall", "recall")
1646            .with_actor("user1")
1647            .with_resource("mem_1"); // Same resource as entry1
1648
1649        let start = Utc::now() - chrono::Duration::hours(1);
1650        let end = Utc::now() + chrono::Duration::hours(1);
1651
1652        let report = AccessReviewReport::generate(&[entry1, entry2, entry3], start, end);
1653
1654        let user1_summary = report.by_actor.get("user1").unwrap();
1655        assert_eq!(user1_summary.event_count, 3);
1656        // resources_accessed should dedupe: mem_1, mem_2
1657        assert_eq!(user1_summary.resources_accessed.len(), 2);
1658        assert!(user1_summary.resources_accessed.contains("mem_1"));
1659        assert!(user1_summary.resources_accessed.contains("mem_2"));
1660    }
1661
1662    #[test]
1663    fn test_access_review_report_action_counting() {
1664        let entry1 = AuditEntry::new("memory.capture", "capture");
1665        let entry2 = AuditEntry::new("memory.capture", "capture");
1666        let entry3 = AuditEntry::new("memory.recall", "recall");
1667        let entry4 = AuditEntry::new("memory.delete", "delete");
1668
1669        let start = Utc::now() - chrono::Duration::hours(1);
1670        let end = Utc::now() + chrono::Duration::hours(1);
1671
1672        let report = AccessReviewReport::generate(&[entry1, entry2, entry3, entry4], start, end);
1673
1674        assert_eq!(report.by_action.get("capture"), Some(&2));
1675        assert_eq!(report.by_action.get("recall"), Some(&1));
1676        assert_eq!(report.by_action.get("delete"), Some(&1));
1677    }
1678
1679    #[test]
1680    fn test_access_review_report_resource_type_counting() {
1681        let entry1 = AuditEntry::new("memory.capture", "action");
1682        let entry2 = AuditEntry::new("memory.capture", "action");
1683        let entry3 = AuditEntry::new("memory.recall", "action");
1684        let entry4 = AuditEntry::new("security.pii_detection", "action");
1685
1686        let start = Utc::now() - chrono::Duration::hours(1);
1687        let end = Utc::now() + chrono::Duration::hours(1);
1688
1689        let report = AccessReviewReport::generate(&[entry1, entry2, entry3, entry4], start, end);
1690
1691        assert_eq!(report.by_resource_type.get("memory.capture"), Some(&2));
1692        assert_eq!(report.by_resource_type.get("memory.recall"), Some(&1));
1693        assert_eq!(
1694            report.by_resource_type.get("security.pii_detection"),
1695            Some(&1)
1696        );
1697    }
1698
1699    #[test]
1700    fn test_audit_logger_generate_access_review() {
1701        let logger = AuditLogger::new();
1702
1703        // Log some events
1704        logger.log_capture("mem_1", "decisions");
1705        logger.log_recall("test query", 5);
1706        logger.log_denied("capture", "secrets detected");
1707
1708        let start = Utc::now() - chrono::Duration::hours(1);
1709        let end = Utc::now() + chrono::Duration::hours(1);
1710
1711        let report = logger.generate_access_review(start, end);
1712
1713        assert_eq!(report.total_events, 3);
1714        assert_eq!(report.by_outcome.success, 2);
1715        assert_eq!(report.by_outcome.denied, 1);
1716    }
1717
1718    #[test]
1719    fn test_access_review_report_serialization() {
1720        let entry = AuditEntry::new("memory.capture", "capture")
1721            .with_actor("user1")
1722            .with_outcome(AuditOutcome::Success);
1723
1724        let start = Utc::now() - chrono::Duration::hours(1);
1725        let end = Utc::now() + chrono::Duration::hours(1);
1726
1727        let report = AccessReviewReport::generate(&[entry], start, end);
1728
1729        let json = serde_json::to_string(&report).unwrap();
1730        assert!(json.contains("generated_at"));
1731        assert!(json.contains("period_start"));
1732        assert!(json.contains("period_end"));
1733        assert!(json.contains("total_events"));
1734        assert!(json.contains("by_actor"));
1735        assert!(json.contains("by_outcome"));
1736
1737        // Verify it can be deserialized
1738        let deserialized: AccessReviewReport = serde_json::from_str(&json).unwrap();
1739        assert_eq!(deserialized.total_events, report.total_events);
1740    }
1741
1742    // PII Disclosure Logging Tests
1743
1744    #[test]
1745    fn test_log_pii_disclosure() {
1746        let logger = AuditLogger::new();
1747        let pii_types = vec!["Email Address".to_string(), "Name".to_string()];
1748
1749        logger.log_pii_disclosure(
1750            "anthropic",
1751            &pii_types,
1752            Some("user_hash_123"),
1753            "llm_processing",
1754            "consent",
1755        );
1756
1757        let entries = logger.recent_entries(10);
1758        assert_eq!(entries.len(), 1);
1759        assert_eq!(entries[0].event_type, "security.pii_disclosure");
1760        assert_eq!(entries[0].action, "disclose");
1761
1762        let metadata = &entries[0].metadata;
1763        assert_eq!(metadata["destination"], "anthropic");
1764        assert_eq!(metadata["pii_count"], 2);
1765        assert_eq!(metadata["purpose"], "llm_processing");
1766        assert_eq!(metadata["legal_basis"], "consent");
1767        assert_eq!(metadata["data_subject_id"], "user_hash_123");
1768    }
1769
1770    #[test]
1771    fn test_log_pii_disclosure_without_data_subject() {
1772        let logger = AuditLogger::new();
1773        let pii_types = vec!["IP Address".to_string()];
1774
1775        logger.log_pii_disclosure(
1776            "openai",
1777            &pii_types,
1778            None,
1779            "embedding",
1780            "legitimate_interest",
1781        );
1782
1783        let entries = logger.recent_entries(10);
1784        assert_eq!(entries.len(), 1);
1785        assert_eq!(entries[0].metadata["destination"], "openai");
1786        assert!(entries[0].metadata["data_subject_id"].is_null());
1787        assert_eq!(entries[0].metadata["purpose"], "embedding");
1788        assert_eq!(entries[0].metadata["legal_basis"], "legitimate_interest");
1789    }
1790
1791    #[test]
1792    fn test_log_pii_disclosure_multiple_destinations() {
1793        let logger = AuditLogger::new();
1794        let pii_types = vec!["Name".to_string()];
1795
1796        logger.log_pii_disclosure("anthropic", &pii_types, None, "enrichment", "consent");
1797        logger.log_pii_disclosure("openai", &pii_types, None, "enrichment", "consent");
1798        logger.log_pii_disclosure("ollama", &pii_types, None, "enrichment", "consent");
1799
1800        let entries = logger.recent_entries(10);
1801        assert_eq!(entries.len(), 3);
1802
1803        // Verify different destinations
1804        let destinations: Vec<_> = entries
1805            .iter()
1806            .map(|e| e.metadata["destination"].as_str().unwrap())
1807            .collect();
1808        assert!(destinations.contains(&"anthropic"));
1809        assert!(destinations.contains(&"openai"));
1810        assert!(destinations.contains(&"ollama"));
1811    }
1812
1813    #[test]
1814    fn test_log_bulk_pii_disclosure() {
1815        let logger = AuditLogger::new();
1816        let pii_categories = vec![
1817            "Personal Identifiers".to_string(),
1818            "Contact Information".to_string(),
1819        ];
1820
1821        logger.log_bulk_pii_disclosure(
1822            "remote_sync",
1823            100,
1824            &pii_categories,
1825            "backup",
1826            "legitimate_interest",
1827        );
1828
1829        let entries = logger.recent_entries(10);
1830        assert_eq!(entries.len(), 1);
1831        assert_eq!(entries[0].event_type, "security.pii_bulk_disclosure");
1832        assert_eq!(entries[0].action, "bulk_disclose");
1833
1834        let metadata = &entries[0].metadata;
1835        assert_eq!(metadata["destination"], "remote_sync");
1836        assert_eq!(metadata["record_count"], 100);
1837        assert_eq!(metadata["purpose"], "backup");
1838        assert_eq!(metadata["legal_basis"], "legitimate_interest");
1839    }
1840
1841    #[test]
1842    fn test_log_bulk_pii_disclosure_zero_records() {
1843        let logger = AuditLogger::new();
1844        let pii_categories = vec!["Names".to_string()];
1845
1846        logger.log_bulk_pii_disclosure("api_export", 0, &pii_categories, "export", "consent");
1847
1848        let entries = logger.recent_entries(10);
1849        assert_eq!(entries.len(), 1);
1850        assert_eq!(entries[0].metadata["record_count"], 0);
1851    }
1852
1853    #[test]
1854    fn test_pii_disclosure_timestamp_included() {
1855        let logger = AuditLogger::new();
1856        let pii_types = vec!["Email".to_string()];
1857
1858        let before = Utc::now();
1859        logger.log_pii_disclosure("provider", &pii_types, None, "purpose", "basis");
1860        let after = Utc::now();
1861
1862        let entries = logger.recent_entries(10);
1863        assert_eq!(entries.len(), 1);
1864
1865        // Verify timestamp_utc is in metadata
1866        let timestamp_str = entries[0].metadata["timestamp_utc"].as_str().unwrap();
1867        let timestamp: DateTime<Utc> = timestamp_str.parse().unwrap();
1868        assert!(timestamp >= before && timestamp <= after);
1869    }
1870
1871    #[test]
1872    fn test_pii_disclosure_in_access_review() {
1873        let logger = AuditLogger::new();
1874        let pii_types = vec!["SSN".to_string()];
1875
1876        logger.log_pii_disclosure("external_api", &pii_types, None, "verification", "consent");
1877        logger.log_bulk_pii_disclosure("backup", 50, &pii_types, "archival", "legitimate_interest");
1878
1879        let start = Utc::now() - chrono::Duration::hours(1);
1880        let end = Utc::now() + chrono::Duration::hours(1);
1881
1882        let report = logger.generate_access_review(start, end);
1883
1884        assert_eq!(report.total_events, 2);
1885        assert_eq!(
1886            report.by_resource_type.get("security.pii_disclosure"),
1887            Some(&1)
1888        );
1889        assert_eq!(
1890            report.by_resource_type.get("security.pii_bulk_disclosure"),
1891            Some(&1)
1892        );
1893    }
1894}