mielin_cli/
audit.rs

1//! Audit logging system for tracking CLI operations
2//!
3//! This module provides comprehensive audit logging for all CLI operations,
4//! including command execution, configuration changes, and security events.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::fs::{self, OpenOptions};
9use std::io::Write as _;
10use std::path::PathBuf;
11
12/// Audit log entry
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AuditEntry {
15    /// Timestamp of the event
16    pub timestamp: chrono::DateTime<chrono::Utc>,
17    /// User who executed the command
18    pub user: String,
19    /// Command that was executed
20    pub command: String,
21    /// Command arguments
22    pub args: Vec<String>,
23    /// Exit code (None if still running)
24    pub exit_code: Option<i32>,
25    /// Duration of command execution
26    pub duration_ms: Option<u64>,
27    /// Event type
28    pub event_type: AuditEventType,
29    /// Severity level
30    pub severity: AuditSeverity,
31    /// Additional metadata
32    pub metadata: serde_json::Value,
33}
34
35/// Audit event types
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum AuditEventType {
39    /// Command execution
40    CommandExecution,
41    /// Configuration change
42    ConfigChange,
43    /// Authentication event
44    Authentication,
45    /// Authorization event
46    Authorization,
47    /// Security event
48    Security,
49    /// System event
50    System,
51}
52
53/// Audit severity levels
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum AuditSeverity {
57    /// Informational
58    Info,
59    /// Warning
60    Warning,
61    /// Error
62    Error,
63    /// Critical
64    Critical,
65}
66
67/// Audit logger configuration
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AuditConfig {
70    /// Enable audit logging
71    pub enabled: bool,
72    /// Log file path
73    pub log_path: PathBuf,
74    /// Maximum log entries before rotation
75    pub max_entries: usize,
76    /// Number of rotated logs to keep
77    pub keep_rotations: usize,
78    /// Minimum severity to log
79    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
94/// Audit logger
95pub struct AuditLogger {
96    config: AuditConfig,
97}
98
99impl AuditLogger {
100    /// Create a new audit logger with the given configuration
101    pub fn new(config: AuditConfig) -> Result<Self> {
102        // Ensure audit log directory exists
103        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    /// Create a new audit logger with default configuration
113    pub fn with_default_config() -> Result<Self> {
114        Self::new(AuditConfig::default())
115    }
116
117    /// Log an audit entry
118    pub fn log(&self, entry: AuditEntry) -> Result<()> {
119        if !self.config.enabled {
120            return Ok(());
121        }
122
123        // Check severity threshold
124        if entry.severity < self.config.min_severity {
125            return Ok(());
126        }
127
128        // Check if rotation is needed
129        self.rotate_if_needed()?;
130
131        // Append entry to log file
132        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    /// Log a command execution
151    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    /// Log a configuration change
180    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    /// Log a security event
206    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    /// Read all audit entries
225    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    /// Query audit entries with filters
247    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        // Apply filters
259        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        // Sort by timestamp (newest first)
294        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
295
296        // Apply limit
297        if let Some(limit_count) = limit {
298            entries.truncate(limit_count);
299        }
300
301        Ok(entries)
302    }
303
304    /// Rotate audit log if needed
305    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        // Rotate logs
317        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        // Move current log to .1
333        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        // Remove old rotated logs
339        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    /// Get rotated log path
350    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    /// Clear all audit logs
358    pub fn clear(&self) -> Result<()> {
359        // Remove current log
360        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        // Remove rotated logs
370        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    /// Get audit statistics
383    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/// Audit statistics
406#[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
442/// Get default audit log path
443fn 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
451/// Get current user
452fn 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}