|
| 1 | +//! Best-effort audit-trail emission for box lifecycle events. |
| 2 | +//! |
| 3 | +//! The audit log (read with `a3s-box audit`) records security-relevant actions — |
| 4 | +//! who created/started/stopped/removed a box, and what was exec'd or pulled. The |
| 5 | +//! reader + CLI command and the `AuditLog` writer were fully built, but no |
| 6 | +//! production code ever emitted an event, so the trail was always empty. These |
| 7 | +//! helpers wire the writer into the lifecycle commands. |
| 8 | +//! |
| 9 | +//! Emission is **best-effort**: a failure to record the audit trail must never |
| 10 | +//! fail the operation it describes. Auditing is on by default |
| 11 | +//! (`AuditConfig::default().enabled == true`); `AuditLog::log` no-ops when it is |
| 12 | +//! disabled. |
| 13 | +
|
| 14 | +use a3s_box_core::audit::{AuditAction, AuditEvent, AuditOutcome}; |
| 15 | +use a3s_box_runtime::AuditLog; |
| 16 | + |
| 17 | +/// Emit one audit event to `log`, best-effort. Separated from [`record`] so the |
| 18 | +/// emission can be unit-tested against a temporary log. |
| 19 | +pub(crate) fn record_to( |
| 20 | + log: &AuditLog, |
| 21 | + action: AuditAction, |
| 22 | + outcome: AuditOutcome, |
| 23 | + box_id: &str, |
| 24 | + message: &str, |
| 25 | +) { |
| 26 | + let event = AuditEvent::new(action, outcome) |
| 27 | + .with_box_id(box_id) |
| 28 | + .with_message(message); |
| 29 | + let _ = log.log(&event); |
| 30 | +} |
| 31 | + |
| 32 | +/// Emit one audit event to the default audit log (`~/.a3s/audit/audit.jsonl`), |
| 33 | +/// best-effort. A log that can't be opened is silently skipped. |
| 34 | +pub(crate) fn record(action: AuditAction, outcome: AuditOutcome, box_id: &str, message: &str) { |
| 35 | + if let Ok(log) = AuditLog::default_path() { |
| 36 | + record_to(&log, action, outcome, box_id, message); |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +#[cfg(test)] |
| 41 | +mod tests { |
| 42 | + use super::*; |
| 43 | + use a3s_box_core::audit::AuditConfig; |
| 44 | + use a3s_box_runtime::{read_audit_log, AuditQuery}; |
| 45 | + |
| 46 | + #[test] |
| 47 | + fn record_to_appends_a_readable_event() { |
| 48 | + let dir = tempfile::tempdir().unwrap(); |
| 49 | + let path = dir.path().join("audit.jsonl"); |
| 50 | + let log = AuditLog::new(&path, AuditConfig::default()).unwrap(); |
| 51 | + |
| 52 | + record_to( |
| 53 | + &log, |
| 54 | + AuditAction::BoxStop, |
| 55 | + AuditOutcome::Success, |
| 56 | + "box-123", |
| 57 | + "stopped via a3s-box stop", |
| 58 | + ); |
| 59 | + |
| 60 | + // The reader (a3s-box audit) must now surface the event — previously the |
| 61 | + // writer was never called so this list was always empty. |
| 62 | + let events = read_audit_log(&path, &AuditQuery::default()).unwrap(); |
| 63 | + assert_eq!(events.len(), 1); |
| 64 | + assert_eq!(events[0].box_id.as_deref(), Some("box-123")); |
| 65 | + assert!(matches!(events[0].action, AuditAction::BoxStop)); |
| 66 | + } |
| 67 | + |
| 68 | + #[test] |
| 69 | + fn record_to_is_silent_when_disabled() { |
| 70 | + let dir = tempfile::tempdir().unwrap(); |
| 71 | + let path = dir.path().join("audit.jsonl"); |
| 72 | + let disabled = AuditConfig { |
| 73 | + enabled: false, |
| 74 | + ..AuditConfig::default() |
| 75 | + }; |
| 76 | + let log = AuditLog::new(&path, disabled).unwrap(); |
| 77 | + |
| 78 | + record_to(&log, AuditAction::BoxStart, AuditOutcome::Success, "b", "x"); |
| 79 | + |
| 80 | + // Disabled: nothing is written (no file, or an empty read). |
| 81 | + let events = read_audit_log(&path, &AuditQuery::default()).unwrap_or_default(); |
| 82 | + assert!(events.is_empty()); |
| 83 | + } |
| 84 | +} |
0 commit comments