1use 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum AuditFailureMode {
19 #[default]
21 Strict,
22 Permissive,
24}
25
26#[derive(Debug, Error, Clone, Serialize, Deserialize)]
28pub enum AuditError {
29 #[error("Audit IO error: {message}")]
31 IoError { message: String },
32
33 #[error("Audit serialization error: {message}")]
35 SerializationError { message: String },
36
37 #[error("Audit configuration error: {message}")]
39 ConfigurationError { message: String },
40
41 #[error("Audit permission error: {message}")]
43 PermissionError { message: String },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SecretAuditEvent {
49 pub timestamp: DateTime<Utc>,
51 pub agent_id: String,
53 pub operation: String,
55 pub secret_key: Option<String>,
57 pub outcome: AuditOutcome,
59 pub error_message: Option<String>,
61 pub metadata: Option<serde_json::Value>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum AuditOutcome {
69 Attempt,
71 Success,
73 Failure,
75}
76
77impl SecretAuditEvent {
78 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 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 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 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
127 self.metadata = Some(metadata);
128 self
129 }
130}
131
132#[async_trait]
134pub trait SecretAuditSink: Send + Sync {
135 async fn log_event(&self, event: SecretAuditEvent) -> Result<(), AuditError>;
144
145 fn failure_mode(&self) -> AuditFailureMode;
147}
148
149pub struct JsonFileAuditSink {
151 file_path: PathBuf,
153 failure_mode: AuditFailureMode,
155}
156
157impl JsonFileAuditSink {
158 pub fn new(file_path: PathBuf) -> Self {
166 Self {
167 file_path,
168 failure_mode: AuditFailureMode::default(),
169 }
170 }
171
172 pub fn with_failure_mode(file_path: PathBuf, failure_mode: AuditFailureMode) -> Self {
174 Self {
175 file_path,
176 failure_mode,
177 }
178 }
179
180 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 self.ensure_directory_exists().await?;
198
199 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 let mut opts = OpenOptions::new();
207 opts.create(true).append(true);
208 #[cfg(unix)]
209 opts.mode(0o600); 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 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 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
243pub type BoxedAuditSink = Arc<dyn SecretAuditSink + Send + Sync>;
245
246pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "type", rename_all = "lowercase")]
262pub enum AuditConfig {
263 JsonFile {
265 file_path: PathBuf,
267 #[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 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 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 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 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 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}