Skip to main content

symbi_runtime/secrets/
auditing.rs

1//! Secrets auditing infrastructure
2//!
3//! This module provides structured auditing for all secret operations,
4//! allowing tracking of who accessed what secrets and when.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::sync::Arc;
11use thiserror::Error;
12use tokio::fs::OpenOptions;
13use tokio::io::AsyncWriteExt;
14
15/// Controls whether audit failures block secret operations
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum AuditFailureMode {
19    /// Block the secret operation if audit logging fails (production default)
20    #[default]
21    Strict,
22    /// Log a warning and allow the operation to proceed
23    Permissive,
24}
25
26/// Errors that can occur during audit operations
27#[derive(Debug, Error, Clone, Serialize, Deserialize)]
28pub enum AuditError {
29    /// IO error during audit logging
30    #[error("Audit IO error: {message}")]
31    IoError { message: String },
32
33    /// Serialization error when converting audit events to JSON
34    #[error("Audit serialization error: {message}")]
35    SerializationError { message: String },
36
37    /// Configuration error for audit sink
38    #[error("Audit configuration error: {message}")]
39    ConfigurationError { message: String },
40
41    /// Permission error when writing audit logs
42    #[error("Audit permission error: {message}")]
43    PermissionError { message: String },
44}
45
46/// A structured audit event for secret operations
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SecretAuditEvent {
49    /// Timestamp when the event occurred
50    pub timestamp: DateTime<Utc>,
51    /// ID of the agent performing the action
52    pub agent_id: String,
53    /// The type of operation performed
54    pub operation: String,
55    /// The key of the secret being accessed (if applicable)
56    pub secret_key: Option<String>,
57    /// The result of the operation
58    pub outcome: AuditOutcome,
59    /// Error details if the operation failed
60    pub error_message: Option<String>,
61    /// Additional context or metadata
62    pub metadata: Option<serde_json::Value>,
63}
64
65/// Outcome of a secret operation for auditing
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum AuditOutcome {
69    /// Intent to perform an operation (logged *before* the call)
70    Attempt,
71    /// Operation completed successfully
72    Success,
73    /// Operation failed
74    Failure,
75}
76
77impl SecretAuditEvent {
78    /// Create an intent-to-access audit event, logged **before** the backend call.
79    ///
80    /// This ensures that even if the process crashes during the Vault/file read,
81    /// there is a paper trail showing the access was attempted.
82    pub fn attempt(agent_id: String, operation: String, secret_key: Option<String>) -> Self {
83        Self {
84            timestamp: Utc::now(),
85            agent_id,
86            operation,
87            secret_key,
88            outcome: AuditOutcome::Attempt,
89            error_message: None,
90            metadata: None,
91        }
92    }
93
94    /// Create a new audit event for a successful operation
95    pub fn success(agent_id: String, operation: String, secret_key: Option<String>) -> Self {
96        Self {
97            timestamp: Utc::now(),
98            agent_id,
99            operation,
100            secret_key,
101            outcome: AuditOutcome::Success,
102            error_message: None,
103            metadata: None,
104        }
105    }
106
107    /// Create a new audit event for a failed operation
108    pub fn failure(
109        agent_id: String,
110        operation: String,
111        secret_key: Option<String>,
112        error_message: String,
113    ) -> Self {
114        Self {
115            timestamp: Utc::now(),
116            agent_id,
117            operation,
118            secret_key,
119            outcome: AuditOutcome::Failure,
120            error_message: Some(error_message),
121            metadata: None,
122        }
123    }
124
125    /// Add metadata to the audit event
126    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
127        self.metadata = Some(metadata);
128        self
129    }
130}
131
132/// Trait for audit sink implementations that can log secret operations
133#[async_trait]
134pub trait SecretAuditSink: Send + Sync {
135    /// Log an audit event
136    ///
137    /// # Arguments
138    /// * `event` - The audit event to log
139    ///
140    /// # Returns
141    /// * `Ok(())` - If the event was successfully logged
142    /// * `Err(AuditError)` - If there was an error logging the event
143    async fn log_event(&self, event: SecretAuditEvent) -> Result<(), AuditError>;
144
145    /// Return the failure mode for this audit sink
146    fn failure_mode(&self) -> AuditFailureMode;
147}
148
149/// JSON file-based audit sink that appends audit events as JSON lines
150pub struct JsonFileAuditSink {
151    /// Path to the audit log file
152    file_path: PathBuf,
153    /// Failure mode for this sink
154    failure_mode: AuditFailureMode,
155}
156
157impl JsonFileAuditSink {
158    /// Create a new JSON file audit sink
159    ///
160    /// # Arguments
161    /// * `file_path` - Path to the audit log file
162    ///
163    /// # Returns
164    /// * New JsonFileAuditSink instance (defaults to Strict failure mode)
165    pub fn new(file_path: PathBuf) -> Self {
166        Self {
167            file_path,
168            failure_mode: AuditFailureMode::default(),
169        }
170    }
171
172    /// Create a new JSON file audit sink with a specific failure mode
173    pub fn with_failure_mode(file_path: PathBuf, failure_mode: AuditFailureMode) -> Self {
174        Self {
175            file_path,
176            failure_mode,
177        }
178    }
179
180    /// Ensure the audit log directory exists
181    async fn ensure_directory_exists(&self) -> Result<(), AuditError> {
182        if let Some(parent) = self.file_path.parent() {
183            tokio::fs::create_dir_all(parent)
184                .await
185                .map_err(|e| AuditError::IoError {
186                    message: format!("Failed to create audit log directory: {}", e),
187                })?;
188        }
189        Ok(())
190    }
191}
192
193#[async_trait]
194impl SecretAuditSink for JsonFileAuditSink {
195    async fn log_event(&self, event: SecretAuditEvent) -> Result<(), AuditError> {
196        // Ensure the directory exists
197        self.ensure_directory_exists().await?;
198
199        // Serialize the event to JSON
200        let json_line =
201            serde_json::to_string(&event).map_err(|e| AuditError::SerializationError {
202                message: format!("Failed to serialize audit event: {}", e),
203            })?;
204
205        // Open the file in append mode (create if it doesn't exist)
206        let mut opts = OpenOptions::new();
207        opts.create(true).append(true);
208        #[cfg(unix)]
209        opts.mode(0o600); // Owner-only read/write
210        let mut file = opts
211            .open(&self.file_path)
212            .await
213            .map_err(|e| AuditError::IoError {
214                message: format!("Failed to open audit log file: {}", e),
215            })?;
216
217        // Write the JSON line followed by a newline
218        file.write_all(json_line.as_bytes())
219            .await
220            .map_err(|e| AuditError::IoError {
221                message: format!("Failed to write to audit log: {}", e),
222            })?;
223
224        file.write_all(b"\n")
225            .await
226            .map_err(|e| AuditError::IoError {
227                message: format!("Failed to write newline to audit log: {}", e),
228            })?;
229
230        // Ensure data is written to disk
231        file.flush().await.map_err(|e| AuditError::IoError {
232            message: format!("Failed to flush audit log: {}", e),
233        })?;
234
235        Ok(())
236    }
237
238    fn failure_mode(&self) -> AuditFailureMode {
239        self.failure_mode
240    }
241}
242
243/// Convenience type for boxed audit sink
244pub type BoxedAuditSink = Arc<dyn SecretAuditSink + Send + Sync>;
245
246/// Helper function to create an optional audit sink from configuration
247pub fn create_audit_sink(audit_config: &Option<AuditConfig>) -> Option<BoxedAuditSink> {
248    audit_config.as_ref().map(|config| match config {
249        AuditConfig::JsonFile {
250            file_path,
251            failure_mode,
252        } => Arc::new(JsonFileAuditSink::with_failure_mode(
253            file_path.clone(),
254            failure_mode.unwrap_or_default(),
255        )) as BoxedAuditSink,
256    })
257}
258
259/// Configuration for audit logging
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "type", rename_all = "lowercase")]
262pub enum AuditConfig {
263    /// JSON file-based audit logging
264    JsonFile {
265        /// Path to the audit log file
266        file_path: PathBuf,
267        /// Failure mode: strict (block operation) or permissive (log warning)
268        #[serde(default)]
269        failure_mode: Option<AuditFailureMode>,
270    },
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use tempfile::NamedTempFile;
277    use tokio::fs;
278
279    #[tokio::test]
280    async fn test_secret_audit_event_creation() {
281        let event = SecretAuditEvent::success(
282            "agent-123".to_string(),
283            "get_secret".to_string(),
284            Some("api-key".to_string()),
285        );
286
287        assert_eq!(event.agent_id, "agent-123");
288        assert_eq!(event.operation, "get_secret");
289        assert_eq!(event.secret_key, Some("api-key".to_string()));
290        assert!(matches!(event.outcome, AuditOutcome::Success));
291        assert!(event.error_message.is_none());
292    }
293
294    #[tokio::test]
295    async fn test_failure_audit_event() {
296        let event = SecretAuditEvent::failure(
297            "agent-456".to_string(),
298            "get_secret".to_string(),
299            Some("missing-key".to_string()),
300            "Secret not found".to_string(),
301        );
302
303        assert_eq!(event.agent_id, "agent-456");
304        assert!(matches!(event.outcome, AuditOutcome::Failure));
305        assert_eq!(event.error_message, Some("Secret not found".to_string()));
306    }
307
308    #[tokio::test]
309    async fn test_json_file_audit_sink() {
310        let temp_file = NamedTempFile::new().unwrap();
311        let sink = JsonFileAuditSink::new(temp_file.path().to_path_buf());
312
313        let event = SecretAuditEvent::success(
314            "test-agent".to_string(),
315            "get_secret".to_string(),
316            Some("test-key".to_string()),
317        );
318
319        let result = sink.log_event(event.clone()).await;
320        assert!(result.is_ok());
321
322        // Verify the file was written correctly
323        let content = fs::read_to_string(temp_file.path()).await.unwrap();
324        let lines: Vec<&str> = content.trim().split('\n').collect();
325        assert_eq!(lines.len(), 1);
326
327        // Parse and verify the JSON
328        let parsed_event: SecretAuditEvent = serde_json::from_str(lines[0]).unwrap();
329        assert_eq!(parsed_event.agent_id, "test-agent");
330        assert_eq!(parsed_event.operation, "get_secret");
331    }
332
333    #[tokio::test]
334    async fn test_multiple_audit_events() {
335        let temp_file = NamedTempFile::new().unwrap();
336        let sink = JsonFileAuditSink::new(temp_file.path().to_path_buf());
337
338        // Log multiple events
339        for i in 0..3 {
340            let event =
341                SecretAuditEvent::success(format!("agent-{}", i), "list_secrets".to_string(), None);
342            sink.log_event(event).await.unwrap();
343        }
344
345        // Verify all events were written
346        let content = fs::read_to_string(temp_file.path()).await.unwrap();
347        let lines: Vec<&str> = content.trim().split('\n').collect();
348        assert_eq!(lines.len(), 3);
349
350        // Verify each line is valid JSON
351        for (i, line) in lines.iter().enumerate() {
352            let parsed_event: SecretAuditEvent = serde_json::from_str(line).unwrap();
353            assert_eq!(parsed_event.agent_id, format!("agent-{}", i));
354            assert_eq!(parsed_event.operation, "list_secrets");
355        }
356    }
357}