Skip to main content

vfa_tui/models/
audit.rs

1//! Audit log data models — entries and event types.
2
3use serde::{Deserialize, Serialize};
4
5/// A single append-only entry in the audit log.
6///
7/// Entries form a SHA-256 hash chain for tamper detection:
8/// `entry_hash = SHA256(prev_hash + timestamp + event_type + subject + details)`.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuditEntry {
11    /// Auto-increment primary key from SQLite.
12    pub id: i64,
13    /// ISO 8601 timestamp with millisecond precision (e.g. `"2025-01-01T00:00:00.000Z"`).
14    pub timestamp: String,
15    /// Discriminator for the kind of event recorded.
16    pub event_type: AuditEventType,
17    /// Asset or workspace identifier — what the event is about.
18    pub subject: String,
19    /// Structured detail blob; arbitrary JSON payload.
20    pub details: serde_json::Value,
21    /// Who initiated the action: `"system"`, `"headless"`, or a user identifier.
22    pub operator: String,
23    /// SHA-256 of `prev_hash || timestamp || event_type || subject || details`.
24    pub entry_hash: String,
25    /// Hash of the immediately preceding entry (empty string for the first entry).
26    pub prev_hash: String,
27}
28
29/// Discriminates the kind of event recorded in an [`AuditEntry`].
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum AuditEventType {
33    PolicyEvaluation,
34    Promotion,
35    InstallationDetected,
36    DriftDetected,
37    ViolationResolved,
38    OperatorAction,
39    GateExecution,
40    ConfigChange,
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    fn make_entry() -> AuditEntry {
48        AuditEntry {
49            id: 1,
50            timestamp: "2025-01-01T00:00:00.000Z".to_string(),
51            event_type: AuditEventType::PolicyEvaluation,
52            subject: "prod-workspace".to_string(),
53            details: serde_json::json!({"rule": "require-security-scanner", "passed": false}),
54            operator: "headless".to_string(),
55            entry_hash: "abc123".to_string(),
56            prev_hash: "".to_string(),
57        }
58    }
59
60    #[test]
61    fn audit_entry_round_trip() {
62        let entry = make_entry();
63        let json = serde_json::to_string(&entry).expect("serialize");
64        let decoded: AuditEntry = serde_json::from_str(&json).expect("deserialize");
65        assert_eq!(decoded.id, 1);
66        assert_eq!(decoded.timestamp, "2025-01-01T00:00:00.000Z");
67        assert_eq!(decoded.event_type, AuditEventType::PolicyEvaluation);
68        assert_eq!(decoded.subject, "prod-workspace");
69        assert_eq!(decoded.operator, "headless");
70        assert_eq!(decoded.entry_hash, "abc123");
71        assert_eq!(decoded.prev_hash, "");
72    }
73
74    #[test]
75    fn audit_event_type_round_trip() {
76        let variants = [
77            AuditEventType::PolicyEvaluation,
78            AuditEventType::Promotion,
79            AuditEventType::InstallationDetected,
80            AuditEventType::DriftDetected,
81            AuditEventType::ViolationResolved,
82            AuditEventType::OperatorAction,
83            AuditEventType::GateExecution,
84            AuditEventType::ConfigChange,
85        ];
86        for variant in &variants {
87            let json = serde_json::to_string(variant).expect("serialize");
88            let decoded: AuditEventType = serde_json::from_str(&json).expect("deserialize");
89            assert_eq!(&decoded, variant);
90        }
91    }
92
93    #[test]
94    fn audit_event_type_serialized_strings() {
95        assert_eq!(
96            serde_json::to_string(&AuditEventType::PolicyEvaluation).unwrap(),
97            "\"policy_evaluation\""
98        );
99        assert_eq!(
100            serde_json::to_string(&AuditEventType::Promotion).unwrap(),
101            "\"promotion\""
102        );
103        assert_eq!(
104            serde_json::to_string(&AuditEventType::InstallationDetected).unwrap(),
105            "\"installation_detected\""
106        );
107        assert_eq!(
108            serde_json::to_string(&AuditEventType::DriftDetected).unwrap(),
109            "\"drift_detected\""
110        );
111        assert_eq!(
112            serde_json::to_string(&AuditEventType::ViolationResolved).unwrap(),
113            "\"violation_resolved\""
114        );
115        assert_eq!(
116            serde_json::to_string(&AuditEventType::OperatorAction).unwrap(),
117            "\"operator_action\""
118        );
119        assert_eq!(
120            serde_json::to_string(&AuditEventType::GateExecution).unwrap(),
121            "\"gate_execution\""
122        );
123        assert_eq!(
124            serde_json::to_string(&AuditEventType::ConfigChange).unwrap(),
125            "\"config_change\""
126        );
127    }
128
129    #[test]
130    fn audit_entry_details_is_arbitrary_json() {
131        let entry = AuditEntry {
132            id: 42,
133            timestamp: "2025-06-01T12:00:00.000Z".to_string(),
134            event_type: AuditEventType::GateExecution,
135            subject: "validate:lint".to_string(),
136            details: serde_json::json!({
137                "gate": "validate:lint",
138                "exit_code": 0,
139                "duration_ms": 1500,
140            }),
141            operator: "system".to_string(),
142            entry_hash: "deadbeef".to_string(),
143            prev_hash: "cafebabe".to_string(),
144        };
145        let json = serde_json::to_string(&entry).unwrap();
146        let decoded: AuditEntry = serde_json::from_str(&json).unwrap();
147        assert_eq!(decoded.details["gate"], "validate:lint");
148        assert_eq!(decoded.details["exit_code"], 0);
149    }
150}