Skip to main content

ferrous_forge/config/locking/
audit_log.rs

1//! Audit logging for lock/unlock operations
2//!
3//! @task T015
4//! @epic T014
5
6use crate::config::hierarchy::ConfigLevel;
7use crate::config::locking::LockEntry;
8use crate::{Error, Result};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12use tokio::fs;
13use tracing::warn;
14
15/// Audit log entry
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AuditEntry {
18    /// Operation type (lock, unlock, `attempt_blocked`)
19    pub operation: String,
20    /// Configuration key affected
21    pub key: String,
22    /// The value (for lock operations)
23    pub value: Option<String>,
24    /// Timestamp of the operation
25    pub timestamp: DateTime<Utc>,
26    /// User who performed the operation
27    pub user: String,
28    /// Configuration level
29    pub level: String,
30    /// Reason provided for the operation
31    pub reason: String,
32    /// Whether the operation succeeded
33    pub success: bool,
34    /// Error message if failed
35    pub error: Option<String>,
36}
37
38impl AuditEntry {
39    /// Create a new audit entry
40    pub fn new(
41        operation: impl Into<String>,
42        key: impl Into<String>,
43        level: impl Into<String>,
44        reason: impl Into<String>,
45    ) -> Self {
46        Self {
47            operation: operation.into(),
48            key: key.into(),
49            value: None,
50            timestamp: Utc::now(),
51            user: whoami::username(),
52            level: level.into(),
53            reason: reason.into(),
54            success: true,
55            error: None,
56        }
57    }
58
59    /// Set the value field
60    pub fn with_value(mut self, value: impl Into<String>) -> Self {
61        self.value = Some(value.into());
62        self
63    }
64
65    /// Mark as failed with error
66    pub fn failed(mut self, error: impl Into<String>) -> Self {
67        self.success = false;
68        self.error = Some(error.into());
69        self
70    }
71}
72
73/// Get the audit log file path
74fn audit_log_path() -> Result<PathBuf> {
75    let config_dir =
76        dirs::config_dir().ok_or_else(|| Error::config("Could not find config directory"))?;
77    Ok(config_dir.join("ferrous-forge").join("audit.log"))
78}
79
80/// Log an unlock operation
81///
82/// # Errors
83///
84/// Returns an error if writing to the audit log fails.
85pub async fn log_unlock(
86    key: &str,
87    entry: &LockEntry,
88    level: ConfigLevel,
89    reason: &str,
90) -> Result<()> {
91    let audit_entry =
92        AuditEntry::new("unlock", key, level.display_name(), reason).with_value(&entry.value);
93
94    append_to_audit_log(audit_entry).await
95}
96
97/// Log a blocked change attempt
98///
99/// # Errors
100///
101/// Returns an error if writing to the audit log fails.
102pub async fn log_blocked_attempt(
103    key: &str,
104    attempted_value: &str,
105    level: ConfigLevel,
106    reason: &str,
107) -> Result<()> {
108    let audit_entry = AuditEntry::new("attempt_blocked", key, level.display_name(), reason)
109        .with_value(attempted_value)
110        .failed("Configuration key is locked");
111
112    append_to_audit_log(audit_entry).await
113}
114
115/// Append entry to audit log
116///
117/// # Errors
118///
119/// Returns an error if writing to the audit log fails.
120async fn append_to_audit_log(entry: AuditEntry) -> Result<()> {
121    let path = audit_log_path()?;
122
123    // Ensure parent directory exists
124    if let Some(parent) = path.parent() {
125        fs::create_dir_all(parent).await?;
126    }
127
128    let line = serde_json::to_string(&entry)
129        .map_err(|e| Error::config(format!("Failed to serialize audit entry: {}", e)))?;
130
131    // Append newline
132    let line = format!("{}\n", line);
133
134    // Open file for appending (create if doesn't exist)
135    use tokio::io::AsyncWriteExt;
136    let mut file = fs::OpenOptions::new()
137        .create(true)
138        .append(true)
139        .open(&path)
140        .await
141        .map_err(|e| Error::config(format!("Failed to open audit log: {}", e)))?;
142
143    file.write_all(line.as_bytes())
144        .await
145        .map_err(|e| Error::config(format!("Failed to write to audit log: {}", e)))?;
146
147    Ok(())
148}
149
150/// Read the audit log
151///
152/// # Errors
153///
154/// Returns an error if reading the audit log fails.
155pub async fn read_audit_log() -> Result<Vec<AuditEntry>> {
156    let path = audit_log_path()?;
157
158    if !path.exists() {
159        return Ok(vec![]);
160    }
161
162    let contents = fs::read_to_string(&path)
163        .await
164        .map_err(|e| Error::config(format!("Failed to read audit log: {}", e)))?;
165
166    let mut entries = Vec::new();
167    for line in contents.lines() {
168        if line.trim().is_empty() {
169            continue;
170        }
171        match serde_json::from_str::<AuditEntry>(line) {
172            Ok(entry) => entries.push(entry),
173            Err(e) => warn!("Failed to parse audit log entry: {}", e),
174        }
175    }
176
177    Ok(entries)
178}