use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::blockchain::BlockchainAudit;
use crate::error::Result;
const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024;
const MAX_ROTATED_LOGS: u32 = 9;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AuditEventType {
VaultCreated,
VaultOpened,
ModelStored,
ModelRetrieved,
ModelDeleted,
VersionDeleted,
AuthSuccess,
AuthFailure,
ConfigChanged,
SecurityViolation,
IntegrityFailure,
KeyDerived,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub event_type: AuditEventType,
pub description: String,
pub model_name: Option<String>,
pub version: Option<u32>,
pub success: bool,
pub metadata: Option<serde_json::Value>,
}
pub struct AuditLogger {
log_file: PathBuf,
chain: Option<Mutex<BlockchainAudit>>,
}
impl AuditLogger {
pub fn new(log_path: &Path) -> Result<Self> {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Self {
log_file: log_path.to_path_buf(),
chain: None,
})
}
pub fn with_chain(log_path: &Path, chain_dir: &Path, block_size: usize) -> Result<Self> {
let mut logger = Self::new(log_path)?;
let block_size = block_size.max(1);
logger.chain = Some(Mutex::new(BlockchainAudit::new(chain_dir, block_size)?));
Ok(logger)
}
pub fn chain(&self) -> Option<&Mutex<BlockchainAudit>> {
self.chain.as_ref()
}
pub fn log(&self, entry: AuditEntry) -> Result<()> {
self.rotate_if_needed()?;
let mut options = OpenOptions::new();
options.create(true).append(true);
crate::permissions::set_create_mode(&mut options);
let mut file = options.open(&self.log_file)?;
crate::permissions::restrict_file(&self.log_file)?;
let json = serde_json::to_string(&entry)?;
writeln!(file, "{}", json)?;
if let Some(chain) = &self.chain {
let mut chain = chain
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
chain.add_entry(entry)?;
}
Ok(())
}
fn rotate_if_needed(&self) -> Result<()> {
let size = match std::fs::metadata(&self.log_file) {
Ok(m) => m.len(),
Err(_) => return Ok(()), };
if size < MAX_LOG_SIZE {
return Ok(());
}
for i in (1..MAX_ROTATED_LOGS).rev() {
let src = self.log_file.with_extension(format!("log.{i}"));
let dst = self.log_file.with_extension(format!("log.{}", i + 1));
if src.exists() {
let _ = std::fs::rename(&src, &dst);
}
}
let rotated = self.log_file.with_extension("log.1");
let _ = std::fs::rename(&self.log_file, &rotated);
Ok(())
}
pub fn log_model_stored(&self, model_name: &str, version: u32, success: bool) -> Result<()> {
self.log(AuditEntry {
timestamp: Utc::now(),
event_type: AuditEventType::ModelStored,
description: format!("Model '{}' version {} stored", model_name, version),
model_name: Some(model_name.to_string()),
version: Some(version),
success,
metadata: None,
})
}
pub fn log_model_retrieved(&self, model_name: &str, version: u32, success: bool) -> Result<()> {
self.log(AuditEntry {
timestamp: Utc::now(),
event_type: AuditEventType::ModelRetrieved,
description: format!("Model '{}' version {} retrieved", model_name, version),
model_name: Some(model_name.to_string()),
version: Some(version),
success,
metadata: None,
})
}
pub fn log_auth(&self, success: bool, _reason: Option<&str>) -> Result<()> {
self.log(AuditEntry {
timestamp: Utc::now(),
event_type: if success {
AuditEventType::AuthSuccess
} else {
AuditEventType::AuthFailure
},
description: if success {
"Authentication successful".to_string()
} else {
"Authentication failed".to_string()
},
model_name: None,
version: None,
success,
metadata: None,
})
}
pub fn log_security_violation(&self, description: &str) -> Result<()> {
self.log(AuditEntry {
timestamp: Utc::now(),
event_type: AuditEventType::SecurityViolation,
description: description.to_string(),
model_name: None,
version: None,
success: false,
metadata: None,
})
}
pub fn read_entries(&self, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
if !self.log_file.exists() {
return Ok(Vec::new());
}
let contents = std::fs::read_to_string(&self.log_file)?;
let mut entries: Vec<AuditEntry> = contents
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
if let Some(n) = limit {
entries.truncate(n);
}
Ok(entries)
}
}
impl Drop for AuditLogger {
fn drop(&mut self) {
let Some(chain) = &self.chain else { return };
let mut chain = chain
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Err(err) = chain.finalize_block() {
eprintln!("warning: failed to finalize pending audit block: {err}");
}
}
}
impl crate::traits::AuditSink for AuditLogger {
fn emit(&self, entry: AuditEntry) -> Result<()> {
self.log(entry)
}
fn query(&self, limit: Option<usize>) -> Result<Vec<AuditEntry>> {
self.read_entries(limit)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::AuditSink;
#[test]
fn test_audit_logger_emit_and_query() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
let sink: &dyn AuditSink = &logger;
sink.emit(AuditEntry {
timestamp: Utc::now(),
event_type: AuditEventType::ModelStored,
description: "Test entry".into(),
model_name: Some("m1".into()),
version: Some(1),
success: true,
metadata: None,
})
.unwrap();
let entries = sink.query(Some(10)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].description, "Test entry");
}
#[test]
fn test_audit_logger_query_limit() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
for i in 0..5 {
logger
.log(AuditEntry {
timestamp: Utc::now(),
event_type: AuditEventType::ModelStored,
description: format!("entry {}", i),
model_name: None,
version: None,
success: true,
metadata: None,
})
.unwrap();
}
let entries = logger.read_entries(Some(3)).unwrap();
assert_eq!(entries.len(), 3);
let all = logger.read_entries(None).unwrap();
assert_eq!(all.len(), 5);
}
#[test]
fn test_log_auth_success() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
logger.log_auth(true, None).unwrap();
let entries = logger.read_entries(None).unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].success);
assert_eq!(entries[0].description, "Authentication successful");
assert!(matches!(entries[0].event_type, AuditEventType::AuthSuccess));
}
#[test]
fn test_log_auth_failure() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
logger.log_auth(false, Some("bad password")).unwrap();
let entries = logger.read_entries(None).unwrap();
assert_eq!(entries.len(), 1);
assert!(!entries[0].success);
assert!(entries[0].description.contains("Authentication failed"));
assert!(matches!(entries[0].event_type, AuditEventType::AuthFailure));
}
#[test]
fn test_log_auth_failure_no_reason() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
logger.log_auth(false, None).unwrap();
let entries = logger.read_entries(None).unwrap();
assert!(entries[0].description.contains("Authentication failed"));
}
#[test]
fn test_log_security_violation() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("audit.log");
let logger = AuditLogger::new(&log_path).unwrap();
logger
.log_security_violation("Unauthorized access attempt")
.unwrap();
let entries = logger.read_entries(None).unwrap();
assert_eq!(entries.len(), 1);
assert!(!entries[0].success);
assert_eq!(entries[0].description, "Unauthorized access attempt");
assert!(matches!(
entries[0].event_type,
AuditEventType::SecurityViolation
));
}
#[test]
fn test_read_entries_no_file() {
let temp_dir = tempfile::tempdir().unwrap();
let log_path = temp_dir.path().join("nonexistent.log");
let logger = AuditLogger {
log_file: log_path,
chain: None,
};
let entries = logger.read_entries(None).unwrap();
assert!(entries.is_empty());
}
}