use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tracing::{debug, error, warn};
#[derive(Debug, Error)]
pub enum AuditError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Configuration error: {0}")]
Configuration(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
AuthSuccess,
AuthFailure,
AuthRateLimited,
KeyCreated,
KeyUpdated,
KeyDisabled,
KeyEnabled,
KeyRevoked,
KeyExpired,
KeyUsed,
PermissionGranted,
PermissionDenied,
RoleChanged,
SecurityViolation,
SuspiciousActivity,
ConfigurationChanged,
StorageAccessed,
StorageModified,
BackupCreated,
BackupRestored,
SystemStartup,
SystemShutdown,
ErrorOccurred,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AuditSeverity {
Info,
Warning,
Error,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub id: String,
pub timestamp: DateTime<Utc>,
pub event_type: AuditEventType,
pub severity: AuditSeverity,
pub source: String,
pub actor: Option<String>,
pub resource: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub message: String,
pub metadata: serde_json::Value,
pub session_id: Option<String>,
pub request_id: Option<String>,
}
impl AuditEvent {
pub fn new(
event_type: AuditEventType,
severity: AuditSeverity,
source: String,
message: String,
) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
timestamp: Utc::now(),
event_type,
severity,
source,
actor: None,
resource: None,
client_ip: None,
user_agent: None,
message,
metadata: serde_json::Value::Object(serde_json::Map::new()),
session_id: None,
request_id: None,
}
}
pub fn with_actor(mut self, actor: String) -> Self {
self.actor = Some(actor);
self
}
pub fn with_resource(mut self, resource: String) -> Self {
self.resource = Some(resource);
self
}
pub fn with_client_ip(mut self, client_ip: String) -> Self {
self.client_ip = Some(client_ip);
self
}
pub fn with_user_agent(mut self, user_agent: String) -> Self {
self.user_agent = Some(user_agent);
self
}
pub fn with_metadata(mut self, key: String, value: serde_json::Value) -> Self {
if let serde_json::Value::Object(ref mut map) = self.metadata {
map.insert(key, value);
}
self
}
pub fn with_session_id(mut self, session_id: String) -> Self {
self.session_id = Some(session_id);
self
}
pub fn with_request_id(mut self, request_id: String) -> Self {
self.request_id = Some(request_id);
self
}
}
#[derive(Debug, Clone)]
pub struct AuditConfig {
pub enabled: bool,
pub log_file: PathBuf,
pub min_severity: AuditSeverity,
pub max_file_size: u64,
pub max_files: u32,
pub console_output: bool,
pub include_sensitive_data: bool,
pub file_permissions: u32,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
log_file: dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".pulseengine")
.join("mcp-auth")
.join("audit.jsonl"),
min_severity: AuditSeverity::Info,
max_file_size: 10 * 1024 * 1024, max_files: 10,
console_output: false,
include_sensitive_data: false,
file_permissions: 0o600,
}
}
}
pub struct AuditLogger {
config: AuditConfig,
}
impl AuditLogger {
pub async fn new(config: AuditConfig) -> Result<Self, AuditError> {
if config.enabled {
if let Some(parent) = config.log_file.parent() {
if !parent.exists() {
fs::create_dir_all(parent).await?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(parent).await {
let mut perms = metadata.permissions();
perms.set_mode(0o700); fs::set_permissions(parent, perms).await?;
}
}
}
}
Ok(Self { config })
}
pub fn new_disabled() -> Self {
let config = AuditConfig {
enabled: false,
..Default::default()
};
Self { config }
}
pub async fn log(&self, event: AuditEvent) -> Result<(), AuditError> {
if !self.config.enabled {
return Ok(());
}
if !self.should_log(&event.severity) {
return Ok(());
}
let sanitized_event = if self.config.include_sensitive_data {
event
} else {
self.sanitize_event(event)
};
let json_line = serde_json::to_string(&sanitized_event)?;
if self.config.console_output {
println!("{json_line}");
}
self.write_to_file(&json_line).await?;
debug!(
"Logged audit event: {} - {}",
sanitized_event.id, sanitized_event.message
);
Ok(())
}
fn should_log(&self, severity: &AuditSeverity) -> bool {
match (&self.config.min_severity, severity) {
(AuditSeverity::Info, _) => true,
(AuditSeverity::Warning, AuditSeverity::Info) => false,
(AuditSeverity::Warning, _) => true,
(AuditSeverity::Error, AuditSeverity::Info | AuditSeverity::Warning) => false,
(AuditSeverity::Error, _) => true,
(AuditSeverity::Critical, AuditSeverity::Critical) => true,
(AuditSeverity::Critical, _) => false,
}
}
fn sanitize_event(&self, mut event: AuditEvent) -> AuditEvent {
if let serde_json::Value::Object(ref mut map) = event.metadata {
if map.contains_key("api_key") {
map.insert(
"api_key".to_string(),
serde_json::Value::String("***redacted***".to_string()),
);
}
if map.contains_key("secret") {
map.insert(
"secret".to_string(),
serde_json::Value::String("***redacted***".to_string()),
);
}
if map.contains_key("password") {
map.insert(
"password".to_string(),
serde_json::Value::String("***redacted***".to_string()),
);
}
}
if event.message.contains("key:") {
event.message = event
.message
.replace(&event.message, "Sensitive data redacted");
}
event
}
async fn write_to_file(&self, line: &str) -> Result<(), AuditError> {
if self.config.log_file.exists() {
let metadata = fs::metadata(&self.config.log_file).await?;
if metadata.len() > self.config.max_file_size {
self.rotate_logs().await?;
}
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.config.log_file)
.await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = file.metadata().await?.permissions();
perms.set_mode(self.config.file_permissions);
file.set_permissions(perms).await?;
}
file.write_all(format!("{line}\n").as_bytes()).await?;
file.flush().await?;
Ok(())
}
async fn rotate_logs(&self) -> Result<(), AuditError> {
for i in (1..self.config.max_files).rev() {
let old_file = self.config.log_file.with_extension(format!("log.{i}"));
let new_file = self
.config
.log_file
.with_extension(format!("log.{}", i + 1));
if old_file.exists() {
if let Err(e) = fs::rename(&old_file, &new_file).await {
warn!(
"Failed to rotate log file {} to {}: {}",
old_file.display(),
new_file.display(),
e
);
}
}
}
let rotated_file = self.config.log_file.with_extension("log.1");
if let Err(e) = fs::rename(&self.config.log_file, &rotated_file).await {
error!("Failed to rotate current log file: {}", e);
return Err(AuditError::Io(e));
}
let oldest_file = self
.config
.log_file
.with_extension(format!("log.{}", self.config.max_files));
if oldest_file.exists() {
if let Err(e) = fs::remove_file(&oldest_file).await {
warn!(
"Failed to remove oldest log file {}: {}",
oldest_file.display(),
e
);
}
}
debug!(
"Rotated audit logs, moved current to {}",
rotated_file.display()
);
Ok(())
}
pub async fn get_stats(&self) -> Result<AuditStats, AuditError> {
let mut stats = AuditStats::default();
if !self.config.log_file.exists() {
return Ok(stats);
}
let content = fs::read_to_string(&self.config.log_file).await?;
let lines: Vec<&str> = content.lines().collect();
stats.total_events = lines.len() as u64;
for line in lines {
if let Ok(event) = serde_json::from_str::<AuditEvent>(line) {
match event.severity {
AuditSeverity::Info => stats.info_events += 1,
AuditSeverity::Warning => stats.warning_events += 1,
AuditSeverity::Error => stats.error_events += 1,
AuditSeverity::Critical => stats.critical_events += 1,
}
match event.event_type {
AuditEventType::AuthSuccess => stats.auth_success += 1,
AuditEventType::AuthFailure => stats.auth_failures += 1,
AuditEventType::SecurityViolation => stats.security_violations += 1,
_ => {}
}
}
}
Ok(stats)
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct AuditStats {
pub total_events: u64,
pub info_events: u64,
pub warning_events: u64,
pub error_events: u64,
pub critical_events: u64,
pub auth_success: u64,
pub auth_failures: u64,
pub security_violations: u64,
}
pub mod events {
use super::*;
pub fn auth_success(user_id: &str, client_ip: &str) -> AuditEvent {
AuditEvent::new(
AuditEventType::AuthSuccess,
AuditSeverity::Info,
"auth".to_string(),
format!("User {user_id} authenticated successfully"),
)
.with_actor(user_id.to_string())
.with_client_ip(client_ip.to_string())
}
pub fn auth_failure(client_ip: &str, reason: &str) -> AuditEvent {
AuditEvent::new(
AuditEventType::AuthFailure,
AuditSeverity::Warning,
"auth".to_string(),
format!("Authentication failed: {reason}"),
)
.with_client_ip(client_ip.to_string())
.with_metadata(
"failure_reason".to_string(),
serde_json::Value::String(reason.to_string()),
)
}
pub fn key_created(key_id: &str, creator: &str, role: &str) -> AuditEvent {
AuditEvent::new(
AuditEventType::KeyCreated,
AuditSeverity::Info,
"key_management".to_string(),
format!("API key {key_id} created with role {role}"),
)
.with_actor(creator.to_string())
.with_resource(key_id.to_string())
.with_metadata(
"role".to_string(),
serde_json::Value::String(role.to_string()),
)
}
pub fn key_used(key_id: &str, client_ip: &str) -> AuditEvent {
AuditEvent::new(
AuditEventType::KeyUsed,
AuditSeverity::Info,
"auth".to_string(),
format!("API key {key_id} used for authentication"),
)
.with_resource(key_id.to_string())
.with_client_ip(client_ip.to_string())
}
pub fn security_violation(description: &str, client_ip: Option<&str>) -> AuditEvent {
let mut event = AuditEvent::new(
AuditEventType::SecurityViolation,
AuditSeverity::Critical,
"security".to_string(),
format!("Security violation: {description}"),
);
if let Some(ip) = client_ip {
event = event.with_client_ip(ip.to_string());
}
event
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_audit_event_creation() {
let event = AuditEvent::new(
AuditEventType::AuthSuccess,
AuditSeverity::Info,
"test".to_string(),
"Test event".to_string(),
)
.with_actor("user123".to_string())
.with_client_ip("192.168.1.1".to_string());
assert_eq!(event.event_type, AuditEventType::AuthSuccess);
assert_eq!(event.severity, AuditSeverity::Info);
assert_eq!(event.actor, Some("user123".to_string()));
assert_eq!(event.client_ip, Some("192.168.1.1".to_string()));
}
#[tokio::test]
async fn test_audit_logger() {
let temp_dir = tempdir().unwrap();
let log_file = temp_dir.path().join("test_audit.log");
let config = AuditConfig {
enabled: true,
log_file: log_file.clone(),
min_severity: AuditSeverity::Info,
console_output: false,
include_sensitive_data: false,
..Default::default()
};
let logger = AuditLogger::new(config).await.unwrap();
let event = events::auth_success("user123", "192.168.1.1");
logger.log(event).await.unwrap();
assert!(log_file.exists());
let content = fs::read_to_string(&log_file).await.unwrap();
assert!(content.contains("auth_success"));
assert!(content.contains("user123"));
}
#[tokio::test]
async fn test_sensitive_data_sanitization() {
let temp_dir = tempdir().unwrap();
let log_file = temp_dir.path().join("test_audit.log");
let config = AuditConfig {
enabled: true,
log_file: log_file.clone(),
include_sensitive_data: false,
..Default::default()
};
let logger = AuditLogger::new(config).await.unwrap();
let event = AuditEvent::new(
AuditEventType::KeyCreated,
AuditSeverity::Info,
"test".to_string(),
"API key created".to_string(),
)
.with_metadata(
"api_key".to_string(),
serde_json::Value::String("secret123".to_string()),
);
logger.log(event).await.unwrap();
let content = fs::read_to_string(&log_file).await.unwrap();
assert!(content.contains("***redacted***"));
assert!(!content.contains("secret123"));
}
}