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