use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use thiserror::Error;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Error, Clone, Serialize, Deserialize)]
pub enum AuditError {
#[error("Audit IO error: {message}")]
IoError { message: String },
#[error("Audit serialization error: {message}")]
SerializationError { message: String },
#[error("Audit configuration error: {message}")]
ConfigurationError { message: String },
#[error("Audit permission error: {message}")]
PermissionError { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretAuditEvent {
pub timestamp: DateTime<Utc>,
pub agent_id: String,
pub operation: String,
pub secret_key: Option<String>,
pub outcome: AuditOutcome,
pub error_message: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditOutcome {
Success,
Failure,
}
impl SecretAuditEvent {
pub fn success(agent_id: String, operation: String, secret_key: Option<String>) -> Self {
Self {
timestamp: Utc::now(),
agent_id,
operation,
secret_key,
outcome: AuditOutcome::Success,
error_message: None,
metadata: None,
}
}
pub fn failure(
agent_id: String,
operation: String,
secret_key: Option<String>,
error_message: String,
) -> Self {
Self {
timestamp: Utc::now(),
agent_id,
operation,
secret_key,
outcome: AuditOutcome::Failure,
error_message: Some(error_message),
metadata: None,
}
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
}
#[async_trait]
pub trait SecretAuditSink: Send + Sync {
async fn log_event(&self, event: SecretAuditEvent) -> Result<(), AuditError>;
}
pub struct JsonFileAuditSink {
file_path: PathBuf,
}
impl JsonFileAuditSink {
pub fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
async fn ensure_directory_exists(&self) -> Result<(), AuditError> {
if let Some(parent) = self.file_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| AuditError::IoError {
message: format!("Failed to create audit log directory: {}", e),
})?;
}
Ok(())
}
}
#[async_trait]
impl SecretAuditSink for JsonFileAuditSink {
async fn log_event(&self, event: SecretAuditEvent) -> Result<(), AuditError> {
self.ensure_directory_exists().await?;
let json_line =
serde_json::to_string(&event).map_err(|e| AuditError::SerializationError {
message: format!("Failed to serialize audit event: {}", e),
})?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.file_path)
.await
.map_err(|e| AuditError::IoError {
message: format!("Failed to open audit log file: {}", e),
})?;
file.write_all(json_line.as_bytes())
.await
.map_err(|e| AuditError::IoError {
message: format!("Failed to write to audit log: {}", e),
})?;
file.write_all(b"\n")
.await
.map_err(|e| AuditError::IoError {
message: format!("Failed to write newline to audit log: {}", e),
})?;
file.flush().await.map_err(|e| AuditError::IoError {
message: format!("Failed to flush audit log: {}", e),
})?;
Ok(())
}
}
pub type BoxedAuditSink = Arc<dyn SecretAuditSink + Send + Sync>;
pub fn create_audit_sink(audit_config: &Option<AuditConfig>) -> Option<BoxedAuditSink> {
audit_config.as_ref().map(|config| match config {
AuditConfig::JsonFile { file_path } => {
Arc::new(JsonFileAuditSink::new(file_path.clone())) as BoxedAuditSink
}
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuditConfig {
JsonFile {
file_path: PathBuf,
},
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
use tokio::fs;
#[tokio::test]
async fn test_secret_audit_event_creation() {
let event = SecretAuditEvent::success(
"agent-123".to_string(),
"get_secret".to_string(),
Some("api-key".to_string()),
);
assert_eq!(event.agent_id, "agent-123");
assert_eq!(event.operation, "get_secret");
assert_eq!(event.secret_key, Some("api-key".to_string()));
assert!(matches!(event.outcome, AuditOutcome::Success));
assert!(event.error_message.is_none());
}
#[tokio::test]
async fn test_failure_audit_event() {
let event = SecretAuditEvent::failure(
"agent-456".to_string(),
"get_secret".to_string(),
Some("missing-key".to_string()),
"Secret not found".to_string(),
);
assert_eq!(event.agent_id, "agent-456");
assert!(matches!(event.outcome, AuditOutcome::Failure));
assert_eq!(event.error_message, Some("Secret not found".to_string()));
}
#[tokio::test]
async fn test_json_file_audit_sink() {
let temp_file = NamedTempFile::new().unwrap();
let sink = JsonFileAuditSink::new(temp_file.path().to_path_buf());
let event = SecretAuditEvent::success(
"test-agent".to_string(),
"get_secret".to_string(),
Some("test-key".to_string()),
);
let result = sink.log_event(event.clone()).await;
assert!(result.is_ok());
let content = fs::read_to_string(temp_file.path()).await.unwrap();
let lines: Vec<&str> = content.trim().split('\n').collect();
assert_eq!(lines.len(), 1);
let parsed_event: SecretAuditEvent = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed_event.agent_id, "test-agent");
assert_eq!(parsed_event.operation, "get_secret");
}
#[tokio::test]
async fn test_multiple_audit_events() {
let temp_file = NamedTempFile::new().unwrap();
let sink = JsonFileAuditSink::new(temp_file.path().to_path_buf());
for i in 0..3 {
let event =
SecretAuditEvent::success(format!("agent-{}", i), "list_secrets".to_string(), None);
sink.log_event(event).await.unwrap();
}
let content = fs::read_to_string(temp_file.path()).await.unwrap();
let lines: Vec<&str> = content.trim().split('\n').collect();
assert_eq!(lines.len(), 3);
for (i, line) in lines.iter().enumerate() {
let parsed_event: SecretAuditEvent = serde_json::from_str(line).unwrap();
assert_eq!(parsed_event.agent_id, format!("agent-{}", i));
assert_eq!(parsed_event.operation, "list_secrets");
}
}
}