envelope_cli/audit/
logger.rs

1//! Audit logger for append-only audit log
2//!
3//! Provides the AuditLogger struct that writes audit entries to a log file.
4//! Each entry is written as a single JSON line and flushed immediately.
5
6use std::fs::{File, OpenOptions};
7use std::io::{BufRead, BufReader, Write};
8use std::path::PathBuf;
9
10use crate::error::{EnvelopeError, EnvelopeResult};
11
12use super::entry::AuditEntry;
13
14/// Handles writing audit entries to the audit log file
15///
16/// The log file uses a line-delimited JSON format (JSONL) where each line
17/// is a complete JSON object representing one audit entry.
18pub struct AuditLogger {
19    /// Path to the audit log file
20    log_path: PathBuf,
21}
22
23impl AuditLogger {
24    /// Create a new AuditLogger that writes to the specified path
25    pub fn new(log_path: PathBuf) -> Self {
26        Self { log_path }
27    }
28
29    /// Log an audit entry
30    ///
31    /// Appends the entry as a JSON line to the audit log file.
32    /// Each write is flushed immediately to ensure durability.
33    pub fn log(&self, entry: &AuditEntry) -> EnvelopeResult<()> {
34        let mut file = OpenOptions::new()
35            .create(true)
36            .append(true)
37            .open(&self.log_path)
38            .map_err(|e| EnvelopeError::Io(format!("Failed to open audit log: {}", e)))?;
39
40        let json = serde_json::to_string(entry)
41            .map_err(|e| EnvelopeError::Json(format!("Failed to serialize audit entry: {}", e)))?;
42
43        writeln!(file, "{}", json)
44            .map_err(|e| EnvelopeError::Io(format!("Failed to write audit entry: {}", e)))?;
45
46        file.flush()
47            .map_err(|e| EnvelopeError::Io(format!("Failed to flush audit log: {}", e)))?;
48
49        Ok(())
50    }
51
52    /// Log multiple audit entries atomically
53    ///
54    /// Writes all entries and flushes once at the end.
55    pub fn log_batch(&self, entries: &[AuditEntry]) -> EnvelopeResult<()> {
56        if entries.is_empty() {
57            return Ok(());
58        }
59
60        let mut file = OpenOptions::new()
61            .create(true)
62            .append(true)
63            .open(&self.log_path)
64            .map_err(|e| EnvelopeError::Io(format!("Failed to open audit log: {}", e)))?;
65
66        for entry in entries {
67            let json = serde_json::to_string(entry).map_err(|e| {
68                EnvelopeError::Json(format!("Failed to serialize audit entry: {}", e))
69            })?;
70
71            writeln!(file, "{}", json)
72                .map_err(|e| EnvelopeError::Io(format!("Failed to write audit entry: {}", e)))?;
73        }
74
75        file.flush()
76            .map_err(|e| EnvelopeError::Io(format!("Failed to flush audit log: {}", e)))?;
77
78        Ok(())
79    }
80
81    /// Read all audit entries from the log file
82    ///
83    /// Returns entries in chronological order (oldest first).
84    pub fn read_all(&self) -> EnvelopeResult<Vec<AuditEntry>> {
85        if !self.log_path.exists() {
86            return Ok(Vec::new());
87        }
88
89        let file = File::open(&self.log_path)
90            .map_err(|e| EnvelopeError::Io(format!("Failed to open audit log: {}", e)))?;
91
92        let reader = BufReader::new(file);
93        let mut entries = Vec::new();
94
95        for (line_num, line) in reader.lines().enumerate() {
96            let line = line.map_err(|e| {
97                EnvelopeError::Io(format!(
98                    "Failed to read audit log line {}: {}",
99                    line_num + 1,
100                    e
101                ))
102            })?;
103
104            // Skip empty lines
105            if line.trim().is_empty() {
106                continue;
107            }
108
109            let entry: AuditEntry = serde_json::from_str(&line).map_err(|e| {
110                EnvelopeError::Json(format!(
111                    "Failed to parse audit entry at line {}: {}",
112                    line_num + 1,
113                    e
114                ))
115            })?;
116
117            entries.push(entry);
118        }
119
120        Ok(entries)
121    }
122
123    /// Read the most recent N entries from the log
124    pub fn read_recent(&self, count: usize) -> EnvelopeResult<Vec<AuditEntry>> {
125        let all_entries = self.read_all()?;
126        let start = all_entries.len().saturating_sub(count);
127        Ok(all_entries[start..].to_vec())
128    }
129
130    /// Get the number of entries in the audit log
131    pub fn entry_count(&self) -> EnvelopeResult<usize> {
132        if !self.log_path.exists() {
133            return Ok(0);
134        }
135
136        let file = File::open(&self.log_path)
137            .map_err(|e| EnvelopeError::Io(format!("Failed to open audit log: {}", e)))?;
138
139        let reader = BufReader::new(file);
140        let count = reader.lines().filter(|l| l.is_ok()).count();
141
142        Ok(count)
143    }
144
145    /// Check if the audit log file exists
146    pub fn exists(&self) -> bool {
147        self.log_path.exists()
148    }
149
150    /// Get the path to the audit log file
151    pub fn path(&self) -> &PathBuf {
152        &self.log_path
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::audit::entry::{EntityType, Operation};
160    use serde_json::json;
161    use tempfile::TempDir;
162
163    fn create_test_logger() -> (AuditLogger, TempDir) {
164        let temp_dir = TempDir::new().unwrap();
165        let log_path = temp_dir.path().join("audit.log");
166        let logger = AuditLogger::new(log_path);
167        (logger, temp_dir)
168    }
169
170    fn create_test_entry() -> AuditEntry {
171        AuditEntry::create(
172            EntityType::Account,
173            "acc-12345678",
174            Some("Test Account".to_string()),
175            &json!({"name": "Test Account", "balance": 1000}),
176        )
177    }
178
179    #[test]
180    fn test_log_and_read() {
181        let (logger, _temp) = create_test_logger();
182        let entry = create_test_entry();
183
184        // Log the entry
185        logger.log(&entry).unwrap();
186
187        // Read it back
188        let entries = logger.read_all().unwrap();
189        assert_eq!(entries.len(), 1);
190        assert_eq!(entries[0].operation, Operation::Create);
191        assert_eq!(entries[0].entity_type, EntityType::Account);
192    }
193
194    #[test]
195    fn test_multiple_entries() {
196        let (logger, _temp) = create_test_logger();
197
198        // Log multiple entries
199        for i in 0..5 {
200            let entry = AuditEntry::create(
201                EntityType::Account,
202                format!("acc-{}", i),
203                Some(format!("Account {}", i)),
204                &json!({"name": format!("Account {}", i)}),
205            );
206            logger.log(&entry).unwrap();
207        }
208
209        // Verify count
210        assert_eq!(logger.entry_count().unwrap(), 5);
211
212        // Verify all entries readable
213        let entries = logger.read_all().unwrap();
214        assert_eq!(entries.len(), 5);
215    }
216
217    #[test]
218    fn test_log_batch() {
219        let (logger, _temp) = create_test_logger();
220
221        let entries: Vec<AuditEntry> = (0..3)
222            .map(|i| {
223                AuditEntry::create(
224                    EntityType::Account,
225                    format!("acc-{}", i),
226                    None,
227                    &json!({"id": i}),
228                )
229            })
230            .collect();
231
232        logger.log_batch(&entries).unwrap();
233
234        let read_entries = logger.read_all().unwrap();
235        assert_eq!(read_entries.len(), 3);
236    }
237
238    #[test]
239    fn test_read_recent() {
240        let (logger, _temp) = create_test_logger();
241
242        // Log 10 entries
243        for i in 0..10 {
244            let entry = AuditEntry::create(
245                EntityType::Account,
246                format!("acc-{}", i),
247                None,
248                &json!({"index": i}),
249            );
250            logger.log(&entry).unwrap();
251        }
252
253        // Read last 3
254        let recent = logger.read_recent(3).unwrap();
255        assert_eq!(recent.len(), 3);
256        assert_eq!(recent[0].entity_id, "acc-7");
257        assert_eq!(recent[1].entity_id, "acc-8");
258        assert_eq!(recent[2].entity_id, "acc-9");
259    }
260
261    #[test]
262    fn test_empty_log() {
263        let (logger, _temp) = create_test_logger();
264
265        assert!(!logger.exists());
266        assert_eq!(logger.entry_count().unwrap(), 0);
267        assert!(logger.read_all().unwrap().is_empty());
268    }
269
270    #[test]
271    fn test_update_entry_logged() {
272        let (logger, _temp) = create_test_logger();
273
274        let before = json!({"name": "Old Name", "balance": 100});
275        let after = json!({"name": "New Name", "balance": 100});
276
277        let entry = AuditEntry::update(
278            EntityType::Account,
279            "acc-12345678",
280            Some("Account".to_string()),
281            &before,
282            &after,
283            Some("name: \"Old Name\" -> \"New Name\"".to_string()),
284        );
285
286        logger.log(&entry).unwrap();
287
288        let entries = logger.read_all().unwrap();
289        assert_eq!(entries.len(), 1);
290        assert_eq!(entries[0].operation, Operation::Update);
291        assert!(entries[0].before.is_some());
292        assert!(entries[0].after.is_some());
293    }
294
295    #[test]
296    fn test_delete_entry_logged() {
297        let (logger, _temp) = create_test_logger();
298
299        let entity = json!({"name": "Deleted Account"});
300        let entry = AuditEntry::delete(
301            EntityType::Account,
302            "acc-12345678",
303            Some("Deleted Account".to_string()),
304            &entity,
305        );
306
307        logger.log(&entry).unwrap();
308
309        let entries = logger.read_all().unwrap();
310        assert_eq!(entries.len(), 1);
311        assert_eq!(entries[0].operation, Operation::Delete);
312        assert!(entries[0].before.is_some());
313        assert!(entries[0].after.is_none());
314    }
315
316    #[test]
317    fn test_survives_crash_simulation() {
318        let (logger, temp) = create_test_logger();
319
320        // Log entry
321        let entry = create_test_entry();
322        logger.log(&entry).unwrap();
323
324        // Create a new logger pointing to the same file (simulating restart)
325        let logger2 = AuditLogger::new(temp.path().join("audit.log"));
326
327        // Should still be readable
328        let entries = logger2.read_all().unwrap();
329        assert_eq!(entries.len(), 1);
330    }
331}