use crate::config::hierarchy::ConfigLevel;
use crate::config::locking::LockEntry;
use crate::{Error, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub operation: String,
pub key: String,
pub value: Option<String>,
pub timestamp: DateTime<Utc>,
pub user: String,
pub level: String,
pub reason: String,
pub success: bool,
pub error: Option<String>,
}
impl AuditEntry {
pub fn new(
operation: impl Into<String>,
key: impl Into<String>,
level: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
operation: operation.into(),
key: key.into(),
value: None,
timestamp: Utc::now(),
user: whoami::username(),
level: level.into(),
reason: reason.into(),
success: true,
error: None,
}
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
pub fn failed(mut self, error: impl Into<String>) -> Self {
self.success = false;
self.error = Some(error.into());
self
}
}
fn audit_log_path() -> Result<PathBuf> {
let config_dir =
dirs::config_dir().ok_or_else(|| Error::config("Could not find config directory"))?;
Ok(config_dir.join("ferrous-forge").join("audit.log"))
}
pub async fn log_unlock(
key: &str,
entry: &LockEntry,
level: ConfigLevel,
reason: &str,
) -> Result<()> {
let audit_entry =
AuditEntry::new("unlock", key, level.display_name(), reason).with_value(&entry.value);
append_to_audit_log(audit_entry).await
}
pub async fn log_blocked_attempt(
key: &str,
attempted_value: &str,
level: ConfigLevel,
reason: &str,
) -> Result<()> {
let audit_entry = AuditEntry::new("attempt_blocked", key, level.display_name(), reason)
.with_value(attempted_value)
.failed("Configuration key is locked");
append_to_audit_log(audit_entry).await
}
async fn append_to_audit_log(entry: AuditEntry) -> Result<()> {
let path = audit_log_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let line = serde_json::to_string(&entry)
.map_err(|e| Error::config(format!("Failed to serialize audit entry: {}", e)))?;
let line = format!("{}\n", line);
use tokio::io::AsyncWriteExt;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
.map_err(|e| Error::config(format!("Failed to open audit log: {}", e)))?;
file.write_all(line.as_bytes())
.await
.map_err(|e| Error::config(format!("Failed to write to audit log: {}", e)))?;
Ok(())
}
pub async fn read_audit_log() -> Result<Vec<AuditEntry>> {
let path = audit_log_path()?;
if !path.exists() {
return Ok(vec![]);
}
let contents = fs::read_to_string(&path)
.await
.map_err(|e| Error::config(format!("Failed to read audit log: {}", e)))?;
let mut entries = Vec::new();
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<AuditEntry>(line) {
Ok(entry) => entries.push(entry),
Err(e) => warn!("Failed to parse audit log entry: {}", e),
}
}
Ok(entries)
}