Skip to main content

a3s_box_core/
audit.rs

1//! Audit logging types for security-relevant event tracking.
2//!
3//! Provides structured audit events that capture who did what, when,
4//! and with what outcome. Designed for compliance and forensic analysis.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A structured audit event capturing a security-relevant action.
10///
11/// Follows the W7 model: Who, What, When, Where, Why, hoW, outcome.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AuditEvent {
14    /// Unique event ID.
15    pub id: String,
16
17    /// ISO 8601 timestamp.
18    pub timestamp: chrono::DateTime<chrono::Utc>,
19
20    /// Action category.
21    pub action: AuditAction,
22
23    /// Target box ID (if applicable).
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub box_id: Option<String>,
26
27    /// Actor who initiated the action (e.g., "cli", "sdk", "cri", "system").
28    #[serde(default)]
29    pub actor: String,
30
31    /// Outcome of the action.
32    pub outcome: AuditOutcome,
33
34    /// Human-readable description.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub message: Option<String>,
37
38    /// Additional structured metadata.
39    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40    pub metadata: HashMap<String, serde_json::Value>,
41}
42
43/// Categories of auditable actions.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum AuditAction {
47    // Box lifecycle
48    BoxCreate,
49    BoxStart,
50    BoxStop,
51    BoxDestroy,
52    BoxRestart,
53
54    // Execution
55    ExecCommand,
56    ExecAttach,
57
58    // Image operations
59    ImagePull,
60    ImagePush,
61    ImageBuild,
62    ImageDelete,
63
64    // Network operations
65    NetworkCreate,
66    NetworkDelete,
67    NetworkConnect,
68    NetworkDisconnect,
69
70    // Volume operations
71    VolumeCreate,
72    VolumeDelete,
73
74    // Security events
75    SignatureVerify,
76    AttestationVerify,
77    SecretInject,
78    SealData,
79    UnsealData,
80
81    // Authentication
82    RegistryLogin,
83    RegistryLogout,
84
85    // System
86    SystemPrune,
87    ConfigChange,
88}
89
90/// Outcome of an audited action.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum AuditOutcome {
94    /// Action completed successfully.
95    Success,
96    /// Action failed.
97    Failure,
98    /// Action was denied (e.g., signature verification failed).
99    Denied,
100}
101
102impl AuditEvent {
103    /// Create a new audit event.
104    pub fn new(action: AuditAction, outcome: AuditOutcome) -> Self {
105        let timestamp = chrono::Utc::now();
106        // Generate a unique ID from timestamp nanos (no uuid dependency needed)
107        let id = format!("audit-{}", timestamp.timestamp_nanos_opt().unwrap_or(0));
108        Self {
109            id,
110            timestamp,
111            action,
112            box_id: None,
113            actor: "cli".to_string(),
114            outcome,
115            message: None,
116            metadata: HashMap::new(),
117        }
118    }
119
120    /// Set the box ID.
121    pub fn with_box_id(mut self, box_id: impl Into<String>) -> Self {
122        self.box_id = Some(box_id.into());
123        self
124    }
125
126    /// Set the actor.
127    pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
128        self.actor = actor.into();
129        self
130    }
131
132    /// Set a human-readable message.
133    pub fn with_message(mut self, message: impl Into<String>) -> Self {
134        self.message = Some(message.into());
135        self
136    }
137
138    /// Add a metadata key-value pair.
139    pub fn with_metadata(
140        mut self,
141        key: impl Into<String>,
142        value: impl Into<serde_json::Value>,
143    ) -> Self {
144        self.metadata.insert(key.into(), value.into());
145        self
146    }
147}
148
149/// Configuration for the audit log.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct AuditConfig {
152    /// Enable audit logging (default: true).
153    #[serde(default = "default_true")]
154    pub enabled: bool,
155
156    /// Maximum audit log file size in bytes before rotation (default: 50 MB).
157    #[serde(default = "default_max_size")]
158    pub max_size: u64,
159
160    /// Maximum number of rotated audit log files to keep (default: 10).
161    #[serde(default = "default_max_files")]
162    pub max_files: u32,
163}
164
165fn default_true() -> bool {
166    true
167}
168
169fn default_max_size() -> u64 {
170    50 * 1024 * 1024 // 50 MB
171}
172
173fn default_max_files() -> u32 {
174    10
175}
176
177impl Default for AuditConfig {
178    fn default() -> Self {
179        Self {
180            enabled: true,
181            max_size: 50 * 1024 * 1024,
182            max_files: 10,
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_audit_event_new() {
193        let event = AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success);
194        assert_eq!(event.action, AuditAction::BoxCreate);
195        assert_eq!(event.outcome, AuditOutcome::Success);
196        assert_eq!(event.actor, "cli");
197        assert!(event.box_id.is_none());
198        assert!(!event.id.is_empty());
199    }
200
201    #[test]
202    fn test_audit_event_builder() {
203        let event = AuditEvent::new(AuditAction::ExecCommand, AuditOutcome::Success)
204            .with_box_id("box-123")
205            .with_actor("sdk")
206            .with_message("Executed /bin/ls")
207            .with_metadata("exit_code", serde_json::json!(0));
208
209        assert_eq!(event.box_id, Some("box-123".to_string()));
210        assert_eq!(event.actor, "sdk");
211        assert_eq!(event.message, Some("Executed /bin/ls".to_string()));
212        assert_eq!(event.metadata["exit_code"], serde_json::json!(0));
213    }
214
215    #[test]
216    fn test_audit_event_serde_roundtrip() {
217        let event = AuditEvent::new(AuditAction::ImagePull, AuditOutcome::Success)
218            .with_box_id("box-456")
219            .with_message("Pulled nginx:latest")
220            .with_metadata("image", serde_json::json!("nginx:latest"));
221
222        let json = serde_json::to_string(&event).unwrap();
223        let parsed: AuditEvent = serde_json::from_str(&json).unwrap();
224        assert_eq!(parsed.action, AuditAction::ImagePull);
225        assert_eq!(parsed.outcome, AuditOutcome::Success);
226        assert_eq!(parsed.box_id, Some("box-456".to_string()));
227        assert_eq!(parsed.metadata["image"], serde_json::json!("nginx:latest"));
228    }
229
230    #[test]
231    fn test_audit_action_serde() {
232        let actions = vec![
233            AuditAction::BoxCreate,
234            AuditAction::BoxStart,
235            AuditAction::BoxStop,
236            AuditAction::BoxDestroy,
237            AuditAction::ExecCommand,
238            AuditAction::ImagePull,
239            AuditAction::SignatureVerify,
240            AuditAction::SecretInject,
241        ];
242        for action in actions {
243            let json = serde_json::to_string(&action).unwrap();
244            let parsed: AuditAction = serde_json::from_str(&json).unwrap();
245            assert_eq!(parsed, action);
246        }
247    }
248
249    #[test]
250    fn test_audit_outcome_serde() {
251        let outcomes = vec![
252            AuditOutcome::Success,
253            AuditOutcome::Failure,
254            AuditOutcome::Denied,
255        ];
256        for outcome in outcomes {
257            let json = serde_json::to_string(&outcome).unwrap();
258            let parsed: AuditOutcome = serde_json::from_str(&json).unwrap();
259            assert_eq!(parsed, outcome);
260        }
261    }
262
263    #[test]
264    fn test_audit_config_default() {
265        let config = AuditConfig::default();
266        assert!(config.enabled);
267        assert_eq!(config.max_size, 50 * 1024 * 1024);
268        assert_eq!(config.max_files, 10);
269    }
270
271    #[test]
272    fn test_audit_config_serde() {
273        let config = AuditConfig {
274            enabled: false,
275            max_size: 100 * 1024 * 1024,
276            max_files: 5,
277        };
278        let json = serde_json::to_string(&config).unwrap();
279        let parsed: AuditConfig = serde_json::from_str(&json).unwrap();
280        assert!(!parsed.enabled);
281        assert_eq!(parsed.max_size, 100 * 1024 * 1024);
282        assert_eq!(parsed.max_files, 5);
283    }
284
285    #[test]
286    fn test_audit_event_empty_metadata_skipped() {
287        let event = AuditEvent::new(AuditAction::BoxStop, AuditOutcome::Success);
288        let json = serde_json::to_string(&event).unwrap();
289        // Empty metadata should not appear in JSON
290        assert!(!json.contains("metadata"));
291    }
292
293    #[test]
294    fn test_audit_event_none_fields_skipped() {
295        let event = AuditEvent::new(AuditAction::SystemPrune, AuditOutcome::Success);
296        let json = serde_json::to_string(&event).unwrap();
297        assert!(!json.contains("box_id"));
298        assert!(!json.contains("message"));
299    }
300
301    #[test]
302    fn test_audit_action_all_variants() {
303        // Ensure all variants serialize to snake_case
304        let variants = vec![
305            (AuditAction::BoxCreate, "\"box_create\""),
306            (AuditAction::BoxStart, "\"box_start\""),
307            (AuditAction::BoxStop, "\"box_stop\""),
308            (AuditAction::BoxDestroy, "\"box_destroy\""),
309            (AuditAction::BoxRestart, "\"box_restart\""),
310            (AuditAction::ExecCommand, "\"exec_command\""),
311            (AuditAction::ExecAttach, "\"exec_attach\""),
312            (AuditAction::ImagePull, "\"image_pull\""),
313            (AuditAction::ImagePush, "\"image_push\""),
314            (AuditAction::ImageBuild, "\"image_build\""),
315            (AuditAction::ImageDelete, "\"image_delete\""),
316            (AuditAction::NetworkCreate, "\"network_create\""),
317            (AuditAction::NetworkDelete, "\"network_delete\""),
318            (AuditAction::NetworkConnect, "\"network_connect\""),
319            (AuditAction::NetworkDisconnect, "\"network_disconnect\""),
320            (AuditAction::VolumeCreate, "\"volume_create\""),
321            (AuditAction::VolumeDelete, "\"volume_delete\""),
322            (AuditAction::SignatureVerify, "\"signature_verify\""),
323            (AuditAction::AttestationVerify, "\"attestation_verify\""),
324            (AuditAction::SecretInject, "\"secret_inject\""),
325            (AuditAction::SealData, "\"seal_data\""),
326            (AuditAction::UnsealData, "\"unseal_data\""),
327            (AuditAction::RegistryLogin, "\"registry_login\""),
328            (AuditAction::RegistryLogout, "\"registry_logout\""),
329            (AuditAction::SystemPrune, "\"system_prune\""),
330            (AuditAction::ConfigChange, "\"config_change\""),
331        ];
332        for (action, expected) in variants {
333            let json = serde_json::to_string(&action).unwrap();
334            assert_eq!(json, expected, "Failed for {:?}", action);
335        }
336    }
337}