ferrous_forge/config/locking/
audit_log.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AuditEntry {
18 pub operation: String,
20 pub key: String,
22 pub value: Option<String>,
24 pub timestamp: DateTime<Utc>,
26 pub user: String,
28 pub level: String,
30 pub reason: String,
32 pub success: bool,
34 pub error: Option<String>,
36}
37
38impl AuditEntry {
39 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 pub fn with_value(mut self, value: impl Into<String>) -> Self {
61 self.value = Some(value.into());
62 self
63 }
64
65 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
73fn 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
80pub 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
97pub 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
115async fn append_to_audit_log(entry: AuditEntry) -> Result<()> {
121 let path = audit_log_path()?;
122
123 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 let line = format!("{}\n", line);
133
134 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
150pub 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}