1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::fs::{self, OpenOptions};
9use std::io::Write as _;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AuditEntry {
15 pub timestamp: chrono::DateTime<chrono::Utc>,
17 pub user: String,
19 pub command: String,
21 pub args: Vec<String>,
23 pub exit_code: Option<i32>,
25 pub duration_ms: Option<u64>,
27 pub event_type: AuditEventType,
29 pub severity: AuditSeverity,
31 pub metadata: serde_json::Value,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum AuditEventType {
39 CommandExecution,
41 ConfigChange,
43 Authentication,
45 Authorization,
47 Security,
49 System,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum AuditSeverity {
57 Info,
59 Warning,
61 Error,
63 Critical,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AuditConfig {
70 pub enabled: bool,
72 pub log_path: PathBuf,
74 pub max_entries: usize,
76 pub keep_rotations: usize,
78 pub min_severity: AuditSeverity,
80}
81
82impl Default for AuditConfig {
83 fn default() -> Self {
84 Self {
85 enabled: true,
86 log_path: get_default_audit_log_path(),
87 max_entries: 10000,
88 keep_rotations: 5,
89 min_severity: AuditSeverity::Info,
90 }
91 }
92}
93
94pub struct AuditLogger {
96 config: AuditConfig,
97}
98
99impl AuditLogger {
100 pub fn new(config: AuditConfig) -> Result<Self> {
102 if let Some(parent) = config.log_path.parent() {
104 fs::create_dir_all(parent).with_context(|| {
105 format!("Failed to create audit log directory: {}", parent.display())
106 })?;
107 }
108
109 Ok(Self { config })
110 }
111
112 pub fn with_default_config() -> Result<Self> {
114 Self::new(AuditConfig::default())
115 }
116
117 pub fn log(&self, entry: AuditEntry) -> Result<()> {
119 if !self.config.enabled {
120 return Ok(());
121 }
122
123 if entry.severity < self.config.min_severity {
125 return Ok(());
126 }
127
128 self.rotate_if_needed()?;
130
131 let mut file = OpenOptions::new()
133 .create(true)
134 .append(true)
135 .open(&self.config.log_path)
136 .with_context(|| {
137 format!(
138 "Failed to open audit log: {}",
139 self.config.log_path.display()
140 )
141 })?;
142
143 let json = serde_json::to_string(&entry).context("Failed to serialize audit entry")?;
144
145 writeln!(file, "{}", json).context("Failed to write audit entry")?;
146
147 Ok(())
148 }
149
150 pub fn log_command(
152 &self,
153 command: &str,
154 args: &[String],
155 exit_code: Option<i32>,
156 duration_ms: Option<u64>,
157 ) -> Result<()> {
158 let entry = AuditEntry {
159 timestamp: chrono::Utc::now(),
160 user: get_current_user(),
161 command: command.to_string(),
162 args: args.to_vec(),
163 exit_code,
164 duration_ms,
165 event_type: AuditEventType::CommandExecution,
166 severity: if exit_code == Some(0) || exit_code.is_none() {
167 AuditSeverity::Info
168 } else {
169 AuditSeverity::Warning
170 },
171 metadata: serde_json::json!({
172 "pid": std::process::id(),
173 }),
174 };
175
176 self.log(entry)
177 }
178
179 pub fn log_config_change(
181 &self,
182 key: &str,
183 old_value: Option<&str>,
184 new_value: &str,
185 ) -> Result<()> {
186 let entry = AuditEntry {
187 timestamp: chrono::Utc::now(),
188 user: get_current_user(),
189 command: "config".to_string(),
190 args: vec![key.to_string(), new_value.to_string()],
191 exit_code: Some(0),
192 duration_ms: None,
193 event_type: AuditEventType::ConfigChange,
194 severity: AuditSeverity::Info,
195 metadata: serde_json::json!({
196 "key": key,
197 "old_value": old_value,
198 "new_value": new_value,
199 }),
200 };
201
202 self.log(entry)
203 }
204
205 pub fn log_security_event(&self, message: &str, severity: AuditSeverity) -> Result<()> {
207 let entry = AuditEntry {
208 timestamp: chrono::Utc::now(),
209 user: get_current_user(),
210 command: "security".to_string(),
211 args: vec![],
212 exit_code: None,
213 duration_ms: None,
214 event_type: AuditEventType::Security,
215 severity,
216 metadata: serde_json::json!({
217 "message": message,
218 }),
219 };
220
221 self.log(entry)
222 }
223
224 pub fn read_entries(&self) -> Result<Vec<AuditEntry>> {
226 if !self.config.log_path.exists() {
227 return Ok(vec![]);
228 }
229
230 let content = fs::read_to_string(&self.config.log_path).with_context(|| {
231 format!(
232 "Failed to read audit log: {}",
233 self.config.log_path.display()
234 )
235 })?;
236
237 let entries: Vec<AuditEntry> = content
238 .lines()
239 .filter(|line| !line.trim().is_empty())
240 .filter_map(|line| serde_json::from_str(line).ok())
241 .collect();
242
243 Ok(entries)
244 }
245
246 pub fn query_entries(
248 &self,
249 event_type: Option<AuditEventType>,
250 severity: Option<AuditSeverity>,
251 user: Option<&str>,
252 since: Option<chrono::DateTime<chrono::Utc>>,
253 until: Option<chrono::DateTime<chrono::Utc>>,
254 limit: Option<usize>,
255 ) -> Result<Vec<AuditEntry>> {
256 let mut entries = self.read_entries()?;
257
258 entries.retain(|entry| {
260 if let Some(et) = event_type {
261 if entry.event_type != et {
262 return false;
263 }
264 }
265
266 if let Some(sev) = severity {
267 if entry.severity < sev {
268 return false;
269 }
270 }
271
272 if let Some(u) = user {
273 if entry.user != u {
274 return false;
275 }
276 }
277
278 if let Some(since_time) = since {
279 if entry.timestamp < since_time {
280 return false;
281 }
282 }
283
284 if let Some(until_time) = until {
285 if entry.timestamp > until_time {
286 return false;
287 }
288 }
289
290 true
291 });
292
293 entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
295
296 if let Some(limit_count) = limit {
298 entries.truncate(limit_count);
299 }
300
301 Ok(entries)
302 }
303
304 fn rotate_if_needed(&self) -> Result<()> {
306 if !self.config.log_path.exists() {
307 return Ok(());
308 }
309
310 let entries = self.read_entries()?;
311
312 if entries.len() < self.config.max_entries {
313 return Ok(());
314 }
315
316 for i in (1..self.config.keep_rotations).rev() {
318 let old_path = self.get_rotated_path(i);
319 let new_path = self.get_rotated_path(i + 1);
320
321 if old_path.exists() {
322 fs::rename(&old_path, &new_path).with_context(|| {
323 format!(
324 "Failed to rotate log {} to {}",
325 old_path.display(),
326 new_path.display()
327 )
328 })?;
329 }
330 }
331
332 let rotated_path = self.get_rotated_path(1);
334 fs::rename(&self.config.log_path, &rotated_path).with_context(|| {
335 format!("Failed to rotate current log to {}", rotated_path.display())
336 })?;
337
338 let old_path = self.get_rotated_path(self.config.keep_rotations + 1);
340 if old_path.exists() {
341 fs::remove_file(&old_path).with_context(|| {
342 format!("Failed to remove old rotated log: {}", old_path.display())
343 })?;
344 }
345
346 Ok(())
347 }
348
349 fn get_rotated_path(&self, rotation: usize) -> PathBuf {
351 let mut path = self.config.log_path.clone();
352 let file_name = path.file_name().unwrap().to_string_lossy();
353 path.set_file_name(format!("{}.{}", file_name, rotation));
354 path
355 }
356
357 pub fn clear(&self) -> Result<()> {
359 if self.config.log_path.exists() {
361 fs::remove_file(&self.config.log_path).with_context(|| {
362 format!(
363 "Failed to remove audit log: {}",
364 self.config.log_path.display()
365 )
366 })?;
367 }
368
369 for i in 1..=self.config.keep_rotations {
371 let rotated_path = self.get_rotated_path(i);
372 if rotated_path.exists() {
373 fs::remove_file(&rotated_path).with_context(|| {
374 format!("Failed to remove rotated log: {}", rotated_path.display())
375 })?;
376 }
377 }
378
379 Ok(())
380 }
381
382 pub fn get_stats(&self) -> Result<AuditStats> {
384 let entries = self.read_entries()?;
385
386 let total_entries = entries.len();
387 let by_event_type = count_by_event_type(&entries);
388 let by_severity = count_by_severity(&entries);
389 let by_user = count_by_user(&entries);
390
391 let oldest_entry = entries.iter().map(|e| e.timestamp).min();
392 let newest_entry = entries.iter().map(|e| e.timestamp).max();
393
394 Ok(AuditStats {
395 total_entries,
396 by_event_type,
397 by_severity,
398 by_user,
399 oldest_entry,
400 newest_entry,
401 })
402 }
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct AuditStats {
408 pub total_entries: usize,
409 pub by_event_type: std::collections::HashMap<String, usize>,
410 pub by_severity: std::collections::HashMap<String, usize>,
411 pub by_user: std::collections::HashMap<String, usize>,
412 pub oldest_entry: Option<chrono::DateTime<chrono::Utc>>,
413 pub newest_entry: Option<chrono::DateTime<chrono::Utc>>,
414}
415
416fn count_by_event_type(entries: &[AuditEntry]) -> std::collections::HashMap<String, usize> {
417 let mut counts = std::collections::HashMap::new();
418 for entry in entries {
419 let key = format!("{:?}", entry.event_type);
420 *counts.entry(key).or_insert(0) += 1;
421 }
422 counts
423}
424
425fn count_by_severity(entries: &[AuditEntry]) -> std::collections::HashMap<String, usize> {
426 let mut counts = std::collections::HashMap::new();
427 for entry in entries {
428 let key = format!("{:?}", entry.severity);
429 *counts.entry(key).or_insert(0) += 1;
430 }
431 counts
432}
433
434fn count_by_user(entries: &[AuditEntry]) -> std::collections::HashMap<String, usize> {
435 let mut counts = std::collections::HashMap::new();
436 for entry in entries {
437 *counts.entry(entry.user.clone()).or_insert(0) += 1;
438 }
439 counts
440}
441
442fn get_default_audit_log_path() -> PathBuf {
444 if let Some(config_dir) = dirs::config_dir() {
445 config_dir.join("mielin").join("audit.log")
446 } else {
447 PathBuf::from(".mielin_audit.log")
448 }
449}
450
451fn get_current_user() -> String {
453 std::env::var("USER")
454 .or_else(|_| std::env::var("USERNAME"))
455 .unwrap_or_else(|_| "unknown".to_string())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_audit_entry_creation() {
464 let entry = AuditEntry {
465 timestamp: chrono::Utc::now(),
466 user: "test_user".to_string(),
467 command: "node".to_string(),
468 args: vec!["list".to_string()],
469 exit_code: Some(0),
470 duration_ms: Some(100),
471 event_type: AuditEventType::CommandExecution,
472 severity: AuditSeverity::Info,
473 metadata: serde_json::json!({}),
474 };
475
476 assert_eq!(entry.command, "node");
477 assert_eq!(entry.severity, AuditSeverity::Info);
478 }
479
480 #[test]
481 fn test_audit_config_default() {
482 let config = AuditConfig::default();
483 assert!(config.enabled);
484 assert_eq!(config.max_entries, 10000);
485 assert_eq!(config.keep_rotations, 5);
486 assert_eq!(config.min_severity, AuditSeverity::Info);
487 }
488
489 #[test]
490 fn test_get_current_user() {
491 let user = get_current_user();
492 assert!(!user.is_empty());
493 }
494
495 #[test]
496 fn test_severity_ordering() {
497 assert!(AuditSeverity::Info < AuditSeverity::Warning);
498 assert!(AuditSeverity::Warning < AuditSeverity::Error);
499 assert!(AuditSeverity::Error < AuditSeverity::Critical);
500 }
501}