Skip to main content

mvm_core/
audit.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::security::{GateDecision, ThreatFinding};
8
9// ============================================================================
10// Local mvmctl audit log (single-host operations)
11// ============================================================================
12
13/// Default path for the local audit log.
14///
15/// Prefers XDG state directory (`~/.local/state/mvm/log/`). Falls back to
16/// legacy `~/.mvm/log/` if an audit log already exists there.
17pub fn default_audit_log() -> String {
18    // Check legacy location for backward compat
19    let legacy = format!("{}/log/audit.jsonl", crate::config::mvm_data_dir());
20    if std::path::Path::new(&legacy).exists() {
21        return legacy;
22    }
23    format!("{}/log/audit.jsonl", crate::config::mvm_state_dir())
24}
25
26/// Rotate when the audit log exceeds this size.
27const ROTATE_THRESHOLD_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB
28
29/// Categories of local mvmctl operations that are audit-logged.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum LocalAuditKind {
33    VmStart,
34    VmStop,
35    KeyLookup,
36    VolumeCreate,
37    VolumeOpen,
38    UpdateInstall,
39    Uninstall,
40    // --- DX features (Phase 2) ---
41    NetworkCreate,
42    NetworkRemove,
43    ImageFetch,
44    TemplateBuild,
45    TemplatePush,
46    TemplatePull,
47    ConfigChange,
48    ConsoleSessionStart,
49    ConsoleSessionEnd,
50}
51
52/// A single local audit log entry.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct LocalAuditEvent {
55    pub timestamp: String,
56    pub kind: LocalAuditKind,
57    pub vm_name: Option<String>,
58    pub detail: Option<String>,
59}
60
61impl LocalAuditEvent {
62    /// Create an event stamped with the current UTC time.
63    pub fn now(kind: LocalAuditKind, vm_name: Option<String>, detail: Option<String>) -> Self {
64        let timestamp = chrono::Utc::now().to_rfc3339();
65        Self {
66            timestamp,
67            kind,
68            vm_name,
69            detail,
70        }
71    }
72}
73
74/// Append-only local audit log writer.
75pub struct LocalAuditLog {
76    path: PathBuf,
77}
78
79impl LocalAuditLog {
80    /// Open (or create) a local audit log at `path`.
81    ///
82    /// Creates parent directories if they don't exist.
83    pub fn open(path: &Path) -> Result<Self> {
84        if let Some(parent) = path.parent() {
85            std::fs::create_dir_all(parent)
86                .with_context(|| format!("Failed to create audit log dir: {}", parent.display()))?;
87        }
88        Ok(Self {
89            path: path.to_path_buf(),
90        })
91    }
92
93    /// Append one JSONL line.  Rotates to `audit.jsonl.1` when the file
94    /// exceeds [`ROTATE_THRESHOLD_BYTES`].
95    pub fn append(&self, event: &LocalAuditEvent) -> Result<()> {
96        self.maybe_rotate()?;
97
98        let mut file = std::fs::OpenOptions::new()
99            .create(true)
100            .append(true)
101            .open(&self.path)
102            .with_context(|| format!("Failed to open audit log: {}", self.path.display()))?;
103
104        let line = serde_json::to_string(event).context("Failed to serialize audit event")?;
105        writeln!(file, "{line}").context("Failed to write audit event")?;
106        Ok(())
107    }
108
109    fn maybe_rotate(&self) -> Result<()> {
110        if !self.path.exists() {
111            return Ok(());
112        }
113        let meta = std::fs::metadata(&self.path)
114            .with_context(|| format!("Failed to stat {}", self.path.display()))?;
115        if meta.len() >= ROTATE_THRESHOLD_BYTES {
116            let rotated = self.path.with_extension("jsonl.1");
117            std::fs::rename(&self.path, &rotated)
118                .with_context(|| format!("Failed to rotate audit log to {}", rotated.display()))?;
119        }
120        Ok(())
121    }
122}
123
124/// Emit a local audit event to the default log path (best-effort).
125///
126/// Errors are logged via `tracing::warn!` and never propagated — audit
127/// failures must not block the operation being logged.
128pub fn emit(kind: LocalAuditKind, vm_name: Option<&str>, detail: Option<&str>) {
129    let event = LocalAuditEvent::now(kind, vm_name.map(str::to_owned), detail.map(str::to_owned));
130    let path = PathBuf::from(default_audit_log());
131    match LocalAuditLog::open(&path).and_then(|log| log.append(&event)) {
132        Ok(()) => {}
133        Err(e) => tracing::warn!("audit log write failed: {e}"),
134    }
135}
136
137/// Audit event types for per-tenant audit logging.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum AuditAction {
140    // -- Instance lifecycle --
141    InstanceCreated,
142    InstanceStarted,
143    InstanceStopped,
144    InstanceWarmed,
145    InstanceSlept,
146    InstanceWoken,
147    InstanceDestroyed,
148    // -- Pool/Tenant --
149    PoolCreated,
150    PoolBuilt,
151    PoolDestroyed,
152    TenantCreated,
153    TenantDestroyed,
154    // -- Operational --
155    QuotaExceeded,
156    SecretsRotated,
157    SnapshotCreated,
158    SnapshotRestored,
159    SnapshotDeleted,
160    TransitionDeferred,
161    MinRuntimeOverridden,
162    // -- Vsock security (Phase 8) --
163    VsockSessionStarted,
164    VsockSessionEnded,
165    VsockFrameReceived,
166    CommandBlocked,
167    CommandApproved,
168    CommandDenied,
169    ThreatDetected,
170    RateLimitExceeded,
171    SessionRecycled,
172}
173
174/// A single audit log entry.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct AuditEntry {
177    pub timestamp: String,
178    pub tenant_id: String,
179    pub pool_id: Option<String>,
180    pub instance_id: Option<String>,
181    pub action: AuditAction,
182    pub detail: Option<String>,
183    /// Threat findings from the classifier (empty for non-security events).
184    #[serde(default)]
185    pub threats: Vec<ThreatFinding>,
186    /// Gate decision for command-gated events.
187    #[serde(default)]
188    pub gate_decision: Option<GateDecision>,
189    /// Vsock frame sequence number.
190    #[serde(default)]
191    pub frame_sequence: Option<u64>,
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_audit_entry_serialization() {
200        let entry = AuditEntry {
201            timestamp: "2025-01-01T00:00:00Z".to_string(),
202            tenant_id: "acme".to_string(),
203            pool_id: Some("workers".to_string()),
204            instance_id: Some("i-abc123".to_string()),
205            action: AuditAction::InstanceStarted,
206            detail: Some("pid=12345".to_string()),
207            threats: vec![],
208            gate_decision: None,
209            frame_sequence: None,
210        };
211
212        let json = serde_json::to_string(&entry).unwrap();
213        assert!(json.contains("\"tenant_id\":\"acme\""));
214        assert!(json.contains("\"InstanceStarted\""));
215    }
216
217    #[test]
218    fn test_audit_entry_no_optionals() {
219        let entry = AuditEntry {
220            timestamp: "2025-01-01T00:00:00Z".to_string(),
221            tenant_id: "acme".to_string(),
222            pool_id: None,
223            instance_id: None,
224            action: AuditAction::TenantCreated,
225            detail: None,
226            threats: vec![],
227            gate_decision: None,
228            frame_sequence: None,
229        };
230
231        let json = serde_json::to_string(&entry).unwrap();
232        assert!(json.contains("\"pool_id\":null"));
233    }
234
235    #[test]
236    fn test_all_audit_actions_serialize() {
237        let actions = vec![
238            AuditAction::InstanceCreated,
239            AuditAction::InstanceStarted,
240            AuditAction::InstanceStopped,
241            AuditAction::InstanceWarmed,
242            AuditAction::InstanceSlept,
243            AuditAction::InstanceWoken,
244            AuditAction::InstanceDestroyed,
245            AuditAction::PoolCreated,
246            AuditAction::PoolBuilt,
247            AuditAction::PoolDestroyed,
248            AuditAction::TenantCreated,
249            AuditAction::TenantDestroyed,
250            AuditAction::QuotaExceeded,
251            AuditAction::SecretsRotated,
252            AuditAction::SnapshotCreated,
253            AuditAction::SnapshotRestored,
254            AuditAction::SnapshotDeleted,
255            AuditAction::TransitionDeferred,
256            AuditAction::MinRuntimeOverridden,
257            AuditAction::VsockSessionStarted,
258            AuditAction::VsockSessionEnded,
259            AuditAction::VsockFrameReceived,
260            AuditAction::CommandBlocked,
261            AuditAction::CommandApproved,
262            AuditAction::CommandDenied,
263            AuditAction::ThreatDetected,
264            AuditAction::RateLimitExceeded,
265            AuditAction::SessionRecycled,
266        ];
267
268        for action in actions {
269            let json = serde_json::to_string(&action).unwrap();
270            assert!(!json.is_empty());
271        }
272    }
273
274    #[test]
275    fn test_audit_entry_backward_compat() {
276        // Old-format JSON without new fields should still deserialize
277        let json = r#"{
278            "timestamp": "2025-01-01T00:00:00Z",
279            "tenant_id": "acme",
280            "pool_id": null,
281            "instance_id": null,
282            "action": "TenantCreated",
283            "detail": null
284        }"#;
285        let entry: AuditEntry = serde_json::from_str(json).unwrap();
286        assert_eq!(entry.tenant_id, "acme");
287        assert!(entry.threats.is_empty());
288        assert!(entry.gate_decision.is_none());
289        assert!(entry.frame_sequence.is_none());
290    }
291
292    #[test]
293    fn test_audit_entry_with_security_fields() {
294        use crate::security::{GateDecision, Severity, ThreatCategory, ThreatFinding};
295
296        let entry = AuditEntry {
297            timestamp: "2025-01-01T00:00:00Z".to_string(),
298            tenant_id: "acme".to_string(),
299            pool_id: None,
300            instance_id: Some("i-001".to_string()),
301            action: AuditAction::ThreatDetected,
302            detail: Some("classified vsock frame".to_string()),
303            threats: vec![ThreatFinding {
304                category: ThreatCategory::Destructive,
305                pattern_id: "rm_rf_root".to_string(),
306                severity: Severity::Critical,
307                matched_text: "rm -rf /".to_string(),
308                context: "literal match".to_string(),
309            }],
310            gate_decision: Some(GateDecision::Blocked {
311                pattern: "rm -rf /".to_string(),
312                reason: "destructive".to_string(),
313            }),
314            frame_sequence: Some(42),
315        };
316
317        let json = serde_json::to_string(&entry).unwrap();
318        let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
319        assert_eq!(parsed.threats.len(), 1);
320        assert_eq!(parsed.threats[0].category, ThreatCategory::Destructive);
321        assert!(parsed.gate_decision.is_some());
322        assert_eq!(parsed.frame_sequence, Some(42));
323    }
324
325    // -------------------------------------------------------------------------
326    // LocalAuditEvent / LocalAuditLog tests
327    // -------------------------------------------------------------------------
328
329    #[test]
330    fn test_local_audit_event_serializes() {
331        let event = LocalAuditEvent::now(
332            LocalAuditKind::VmStart,
333            Some("my-vm".to_string()),
334            Some("flake=.".to_string()),
335        );
336        let json = serde_json::to_string(&event).unwrap();
337        let parsed: LocalAuditEvent = serde_json::from_str(&json).unwrap();
338        assert_eq!(parsed.kind, LocalAuditKind::VmStart);
339        assert_eq!(parsed.vm_name.as_deref(), Some("my-vm"));
340        assert_eq!(parsed.detail.as_deref(), Some("flake=."));
341        assert!(!parsed.timestamp.is_empty());
342    }
343
344    #[test]
345    fn test_local_audit_kind_all_variants_serialize() {
346        let kinds = [
347            LocalAuditKind::VmStart,
348            LocalAuditKind::VmStop,
349            LocalAuditKind::KeyLookup,
350            LocalAuditKind::VolumeCreate,
351            LocalAuditKind::VolumeOpen,
352            LocalAuditKind::UpdateInstall,
353            LocalAuditKind::Uninstall,
354            LocalAuditKind::NetworkCreate,
355            LocalAuditKind::NetworkRemove,
356            LocalAuditKind::ImageFetch,
357            LocalAuditKind::TemplateBuild,
358            LocalAuditKind::TemplatePush,
359            LocalAuditKind::TemplatePull,
360            LocalAuditKind::ConfigChange,
361            LocalAuditKind::ConsoleSessionStart,
362            LocalAuditKind::ConsoleSessionEnd,
363        ];
364        for kind in kinds {
365            let json = serde_json::to_string(&kind).unwrap();
366            assert!(!json.is_empty());
367        }
368    }
369
370    #[test]
371    fn test_local_audit_log_append() {
372        let tmp = tempfile::tempdir().unwrap();
373        let path = tmp.path().join("audit.jsonl");
374
375        let log = LocalAuditLog::open(&path).unwrap();
376        let event = LocalAuditEvent::now(LocalAuditKind::VmStop, Some("vm1".to_string()), None);
377        log.append(&event).unwrap();
378
379        let contents = std::fs::read_to_string(&path).unwrap();
380        assert!(contents.contains("vm_stop"));
381        assert!(contents.contains("vm1"));
382        // One line per event.
383        assert_eq!(contents.lines().count(), 1);
384
385        // Append a second event.
386        let event2 = LocalAuditEvent::now(
387            LocalAuditKind::UpdateInstall,
388            None,
389            Some("v1.2.3".to_string()),
390        );
391        log.append(&event2).unwrap();
392        let contents2 = std::fs::read_to_string(&path).unwrap();
393        assert_eq!(contents2.lines().count(), 2);
394    }
395
396    #[test]
397    fn test_local_audit_log_rotation() {
398        let tmp = tempfile::tempdir().unwrap();
399        let path = tmp.path().join("audit.jsonl");
400
401        // Write a file that exceeds the rotation threshold.
402        let big_content = "x".repeat(ROTATE_THRESHOLD_BYTES as usize + 1);
403        std::fs::write(&path, big_content).unwrap();
404
405        let log = LocalAuditLog::open(&path).unwrap();
406        let event = LocalAuditEvent::now(LocalAuditKind::Uninstall, None, None);
407        log.append(&event).unwrap();
408
409        // The rotated file should exist.
410        let rotated = path.with_extension("jsonl.1");
411        assert!(rotated.exists(), "rotation file should be created");
412
413        // The new log file should contain only the new event.
414        let contents = std::fs::read_to_string(&path).unwrap();
415        assert_eq!(contents.lines().count(), 1);
416        assert!(contents.contains("uninstall"));
417    }
418}