Skip to main content

a3s_box_runtime/
audit.rs

1//! Persistent audit log writer.
2//!
3//! Appends structured `AuditEvent` records to a JSON-lines file
4//! with size-based rotation. Provides query support for reading
5//! back events with time-range and action filters.
6
7use std::fs::{self, File, OpenOptions};
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use a3s_box_core::audit::{AuditAction, AuditConfig, AuditEvent, AuditOutcome};
13use a3s_box_core::error::{BoxError, Result};
14
15/// Persistent audit log that appends events to a JSON-lines file.
16///
17/// Thread-safe via internal `Mutex`. Supports size-based rotation.
18pub struct AuditLog {
19    inner: Mutex<AuditLogInner>,
20}
21
22struct AuditLogInner {
23    /// Path to the active audit log file.
24    path: PathBuf,
25    /// Current file handle (lazy-opened on first write).
26    file: Option<File>,
27    /// Current file size in bytes.
28    current_size: u64,
29    /// Configuration.
30    config: AuditConfig,
31}
32
33impl AuditLog {
34    /// Create a new audit log at the given path.
35    pub fn new(path: impl Into<PathBuf>, config: AuditConfig) -> Result<Self> {
36        let path = path.into();
37
38        // Create parent directory if needed
39        if let Some(parent) = path.parent() {
40            fs::create_dir_all(parent).map_err(|e| {
41                BoxError::AuditError(format!(
42                    "Failed to create audit log directory {}: {}",
43                    parent.display(),
44                    e
45                ))
46            })?;
47        }
48
49        // Get current file size if it exists
50        let current_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
51
52        Ok(Self {
53            inner: Mutex::new(AuditLogInner {
54                path,
55                file: None,
56                current_size,
57                config,
58            }),
59        })
60    }
61
62    /// Open the audit log at the default path (~/.a3s/audit/audit.jsonl).
63    pub fn default_path() -> Result<Self> {
64        let path = a3s_box_core::dirs_home().join("audit").join("audit.jsonl");
65
66        Self::new(path, AuditConfig::default())
67    }
68
69    /// Append an audit event to the log.
70    pub fn log(&self, event: &AuditEvent) -> Result<()> {
71        let mut inner = self
72            .inner
73            .lock()
74            .map_err(|_| BoxError::AuditError("Audit log lock poisoned".to_string()))?;
75
76        if !inner.config.enabled {
77            return Ok(());
78        }
79
80        // Rotate if needed
81        if inner.current_size >= inner.config.max_size {
82            Self::rotate(&mut inner)?;
83        }
84
85        // Open file if not yet open
86        if inner.file.is_none() {
87            let file = OpenOptions::new()
88                .create(true)
89                .append(true)
90                .open(&inner.path)
91                .map_err(|e| {
92                    BoxError::AuditError(format!(
93                        "Failed to open audit log {}: {}",
94                        inner.path.display(),
95                        e
96                    ))
97                })?;
98            inner.file = Some(file);
99        }
100
101        // Serialize and write
102        let mut line = serde_json::to_string(event).map_err(|e| {
103            BoxError::SerializationError(format!("Failed to serialize audit event: {}", e))
104        })?;
105        line.push('\n');
106
107        let bytes = line.as_bytes();
108        if let Some(ref mut file) = inner.file {
109            file.write_all(bytes)
110                .map_err(|e| BoxError::AuditError(format!("Failed to write audit event: {}", e)))?;
111            file.flush()
112                .map_err(|e| BoxError::AuditError(format!("Failed to flush audit log: {}", e)))?;
113        }
114
115        inner.current_size += bytes.len() as u64;
116        Ok(())
117    }
118
119    /// Rotate the audit log file.
120    fn rotate(inner: &mut AuditLogInner) -> Result<()> {
121        // Close current file
122        inner.file = None;
123
124        let max = inner.config.max_files;
125        let base = &inner.path;
126
127        // Remove oldest if at limit
128        let oldest = rotated_path(base, max);
129        if oldest.exists() {
130            if let Err(e) = fs::remove_file(&oldest) {
131                tracing::warn!(path = %oldest.display(), error = %e, "Failed to remove oldest audit log during rotation");
132            }
133        }
134
135        // Shift existing rotated files: .9 → .10, .8 → .9, etc.
136        for i in (1..max).rev() {
137            let from = rotated_path(base, i);
138            let to = rotated_path(base, i + 1);
139            if from.exists() {
140                if let Err(e) = fs::rename(&from, &to) {
141                    tracing::warn!(from = %from.display(), to = %to.display(), error = %e, "Failed to shift audit log during rotation");
142                }
143            }
144        }
145
146        // Rename current to .1
147        if base.exists() {
148            if let Err(e) = fs::rename(base, rotated_path(base, 1)) {
149                tracing::warn!(path = %base.display(), error = %e, "Failed to rotate current audit log");
150            }
151        }
152
153        inner.current_size = 0;
154        Ok(())
155    }
156
157    /// Get the path to the audit log file.
158    pub fn path(&self) -> PathBuf {
159        self.inner
160            .lock()
161            .map(|inner| inner.path.clone())
162            .unwrap_or_default()
163    }
164}
165
166/// Generate a rotated file path (e.g., audit.jsonl.1, audit.jsonl.2).
167fn rotated_path(base: &Path, index: u32) -> PathBuf {
168    let name = format!(
169        "{}.{}",
170        base.file_name().unwrap_or_default().to_string_lossy(),
171        index
172    );
173    base.with_file_name(name)
174}
175
176/// Query parameters for reading audit events.
177#[derive(Debug, Clone, Default)]
178pub struct AuditQuery {
179    /// Filter by action type.
180    pub action: Option<AuditAction>,
181    /// Filter by box ID.
182    pub box_id: Option<String>,
183    /// Filter by outcome.
184    pub outcome: Option<AuditOutcome>,
185    /// Only events after this time.
186    pub since: Option<chrono::DateTime<chrono::Utc>>,
187    /// Only events before this time.
188    pub until: Option<chrono::DateTime<chrono::Utc>>,
189    /// Maximum number of events to return.
190    pub limit: Option<usize>,
191}
192
193/// Read audit events from a log file, applying optional filters.
194pub fn read_audit_log(path: &Path, query: &AuditQuery) -> Result<Vec<AuditEvent>> {
195    if !path.exists() {
196        return Ok(vec![]);
197    }
198
199    let file = File::open(path).map_err(|e| {
200        BoxError::AuditError(format!(
201            "Failed to open audit log {}: {}",
202            path.display(),
203            e
204        ))
205    })?;
206
207    let reader = BufReader::new(file);
208    let mut events = Vec::new();
209
210    for line in reader.lines() {
211        let line = line
212            .map_err(|e| BoxError::AuditError(format!("Failed to read audit log line: {}", e)))?;
213
214        if line.trim().is_empty() {
215            continue;
216        }
217
218        let event: AuditEvent = match serde_json::from_str(&line) {
219            Ok(e) => e,
220            Err(_) => continue, // skip malformed lines
221        };
222
223        // Apply filters
224        if let Some(ref action) = query.action {
225            if event.action != *action {
226                continue;
227            }
228        }
229        if let Some(ref box_id) = query.box_id {
230            if event.box_id.as_deref() != Some(box_id.as_str()) {
231                continue;
232            }
233        }
234        if let Some(ref outcome) = query.outcome {
235            if event.outcome != *outcome {
236                continue;
237            }
238        }
239        if let Some(since) = query.since {
240            if event.timestamp < since {
241                continue;
242            }
243        }
244        if let Some(until) = query.until {
245            if event.timestamp > until {
246                continue;
247            }
248        }
249
250        events.push(event);
251
252        if let Some(limit) = query.limit {
253            if events.len() >= limit {
254                break;
255            }
256        }
257    }
258
259    Ok(events)
260}
261
262impl a3s_box_core::traits::AuditSink for AuditLog {
263    fn record(&self, event: &AuditEvent) -> Result<()> {
264        self.log(event)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use tempfile::TempDir;
272
273    fn test_log(dir: &Path) -> AuditLog {
274        let path = dir.join("audit.jsonl");
275        AuditLog::new(path, AuditConfig::default()).unwrap()
276    }
277
278    #[test]
279    fn test_audit_log_write_and_read() {
280        let dir = TempDir::new().unwrap();
281        let log = test_log(dir.path());
282
283        let event = AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success)
284            .with_box_id("box-1")
285            .with_message("Created box");
286
287        log.log(&event).unwrap();
288
289        let events = read_audit_log(&log.path(), &AuditQuery::default()).unwrap();
290        assert_eq!(events.len(), 1);
291        assert_eq!(events[0].action, AuditAction::BoxCreate);
292        assert_eq!(events[0].box_id, Some("box-1".to_string()));
293    }
294
295    #[test]
296    fn test_audit_log_multiple_events() {
297        let dir = TempDir::new().unwrap();
298        let log = test_log(dir.path());
299
300        for i in 0..5 {
301            let event = AuditEvent::new(AuditAction::ExecCommand, AuditOutcome::Success)
302                .with_box_id(format!("box-{}", i));
303            log.log(&event).unwrap();
304        }
305
306        let events = read_audit_log(&log.path(), &AuditQuery::default()).unwrap();
307        assert_eq!(events.len(), 5);
308    }
309
310    #[test]
311    fn test_audit_log_filter_by_action() {
312        let dir = TempDir::new().unwrap();
313        let log = test_log(dir.path());
314
315        log.log(&AuditEvent::new(
316            AuditAction::BoxCreate,
317            AuditOutcome::Success,
318        ))
319        .unwrap();
320        log.log(&AuditEvent::new(
321            AuditAction::BoxStop,
322            AuditOutcome::Success,
323        ))
324        .unwrap();
325        log.log(&AuditEvent::new(
326            AuditAction::BoxCreate,
327            AuditOutcome::Failure,
328        ))
329        .unwrap();
330
331        let query = AuditQuery {
332            action: Some(AuditAction::BoxCreate),
333            ..Default::default()
334        };
335        let events = read_audit_log(&log.path(), &query).unwrap();
336        assert_eq!(events.len(), 2);
337    }
338
339    #[test]
340    fn test_audit_log_filter_by_box_id() {
341        let dir = TempDir::new().unwrap();
342        let log = test_log(dir.path());
343
344        log.log(&AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success).with_box_id("a"))
345            .unwrap();
346        log.log(&AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success).with_box_id("b"))
347            .unwrap();
348
349        let query = AuditQuery {
350            box_id: Some("a".to_string()),
351            ..Default::default()
352        };
353        let events = read_audit_log(&log.path(), &query).unwrap();
354        assert_eq!(events.len(), 1);
355        assert_eq!(events[0].box_id, Some("a".to_string()));
356    }
357
358    #[test]
359    fn test_audit_log_filter_by_outcome() {
360        let dir = TempDir::new().unwrap();
361        let log = test_log(dir.path());
362
363        log.log(&AuditEvent::new(
364            AuditAction::ImagePull,
365            AuditOutcome::Success,
366        ))
367        .unwrap();
368        log.log(&AuditEvent::new(
369            AuditAction::ImagePull,
370            AuditOutcome::Failure,
371        ))
372        .unwrap();
373        log.log(&AuditEvent::new(
374            AuditAction::ImagePull,
375            AuditOutcome::Denied,
376        ))
377        .unwrap();
378
379        let query = AuditQuery {
380            outcome: Some(AuditOutcome::Failure),
381            ..Default::default()
382        };
383        let events = read_audit_log(&log.path(), &query).unwrap();
384        assert_eq!(events.len(), 1);
385    }
386
387    #[test]
388    fn test_audit_log_limit() {
389        let dir = TempDir::new().unwrap();
390        let log = test_log(dir.path());
391
392        for _ in 0..10 {
393            log.log(&AuditEvent::new(
394                AuditAction::ExecCommand,
395                AuditOutcome::Success,
396            ))
397            .unwrap();
398        }
399
400        let query = AuditQuery {
401            limit: Some(3),
402            ..Default::default()
403        };
404        let events = read_audit_log(&log.path(), &query).unwrap();
405        assert_eq!(events.len(), 3);
406    }
407
408    #[test]
409    fn test_audit_log_empty_file() {
410        let dir = TempDir::new().unwrap();
411        let path = dir.path().join("nonexistent.jsonl");
412        let events = read_audit_log(&path, &AuditQuery::default()).unwrap();
413        assert!(events.is_empty());
414    }
415
416    #[test]
417    fn test_audit_log_disabled() {
418        let dir = TempDir::new().unwrap();
419        let path = dir.path().join("audit.jsonl");
420        let config = AuditConfig {
421            enabled: false,
422            ..Default::default()
423        };
424        let log = AuditLog::new(&path, config).unwrap();
425
426        log.log(&AuditEvent::new(
427            AuditAction::BoxCreate,
428            AuditOutcome::Success,
429        ))
430        .unwrap();
431
432        // File should not exist or be empty
433        let events = read_audit_log(&path, &AuditQuery::default()).unwrap();
434        assert!(events.is_empty());
435    }
436
437    #[test]
438    fn test_audit_log_rotation() {
439        let dir = TempDir::new().unwrap();
440        let path = dir.path().join("audit.jsonl");
441        let config = AuditConfig {
442            enabled: true,
443            max_size: 100, // very small to trigger rotation
444            max_files: 3,
445        };
446        let log = AuditLog::new(&path, config).unwrap();
447
448        // Write enough events to trigger rotation
449        for i in 0..20 {
450            let event = AuditEvent::new(AuditAction::ExecCommand, AuditOutcome::Success)
451                .with_message(format!("Event {}", i));
452            log.log(&event).unwrap();
453        }
454
455        // Should have rotated files
456        assert!(path.exists());
457        let rotated_1 = dir.path().join("audit.jsonl.1");
458        assert!(rotated_1.exists());
459    }
460
461    #[test]
462    fn test_rotated_path() {
463        let base = PathBuf::from("/var/log/audit.jsonl");
464        assert_eq!(
465            rotated_path(&base, 1),
466            PathBuf::from("/var/log/audit.jsonl.1")
467        );
468        assert_eq!(
469            rotated_path(&base, 10),
470            PathBuf::from("/var/log/audit.jsonl.10")
471        );
472    }
473
474    #[test]
475    fn test_audit_log_path() {
476        let dir = TempDir::new().unwrap();
477        let path = dir.path().join("audit.jsonl");
478        let log = AuditLog::new(&path, AuditConfig::default()).unwrap();
479        assert_eq!(log.path(), path);
480    }
481}