greentic_deploy_spec/
audit.rs1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::version::SchemaVersion;
15
16pub const POLICY_LOCAL_ONLY: &str = "local-only";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AuditEvent {
22 pub schema: SchemaVersion,
23 pub event_id: String,
24 pub ts: DateTime<Utc>,
25 pub actor: Actor,
26 pub env_id: String,
27 pub noun: String,
28 pub verb: String,
29 pub target: Value,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub previous_generation: Option<u64>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub new_generation: Option<u64>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub idempotency_key: Option<String>,
36 pub authorization: AuditDecision,
37 pub result: AuditResult,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Actor {
44 pub kind: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub user: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub uid: Option<u32>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(tag = "decision", rename_all = "kebab-case")]
55pub enum AuditDecision {
56 Allow { policy: String, reason: String },
57 Deny { policy: String, reason: String },
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "outcome", rename_all = "kebab-case")]
63pub enum AuditResult {
64 Ok,
65 Error { kind: String, message: String },
66 NotYetImplemented { detail: String },
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 fn sample() -> AuditEvent {
74 AuditEvent {
75 schema: SchemaVersion::AUDIT_EVENT_V1.into(),
76 event_id: "01JTKW5B4W4Q5Y1CQW93F7S5VH".to_string(),
77 ts: "2026-05-20T00:00:00Z".parse().unwrap(),
78 actor: Actor {
79 kind: "local-user".to_string(),
80 user: Some("tester".to_string()),
81 uid: Some(1000),
82 },
83 env_id: "local".to_string(),
84 noun: "env".to_string(),
85 verb: "create".to_string(),
86 target: serde_json::json!({"environment_id": "local"}),
87 previous_generation: None,
88 new_generation: Some(0),
89 idempotency_key: None,
90 authorization: AuditDecision::Allow {
91 policy: POLICY_LOCAL_ONLY.to_string(),
92 reason: "env `local` is the local env".to_string(),
93 },
94 result: AuditResult::Ok,
95 }
96 }
97
98 #[test]
99 fn audit_event_round_trips() {
100 let event = sample();
101 let json = serde_json::to_string(&event).unwrap();
102 let back: AuditEvent = serde_json::from_str(&json).unwrap();
103 assert_eq!(back.env_id, "local");
104 assert_eq!(back.verb, "create");
105 assert_eq!(back.new_generation, Some(0));
106 }
107
108 #[test]
109 fn decision_uses_tagged_kebab_case() {
110 let json = serde_json::to_value(AuditDecision::Deny {
111 policy: POLICY_LOCAL_ONLY.to_string(),
112 reason: "nope".to_string(),
113 })
114 .unwrap();
115 assert_eq!(json["decision"], "deny");
116 assert_eq!(json["policy"], POLICY_LOCAL_ONLY);
117 }
118
119 #[test]
120 fn result_tag_round_trips_each_variant() {
121 for result in [
122 AuditResult::Ok,
123 AuditResult::Error {
124 kind: "unauthorized".to_string(),
125 message: "denied".to_string(),
126 },
127 AuditResult::NotYetImplemented {
128 detail: "A9".to_string(),
129 },
130 ] {
131 let json = serde_json::to_string(&result).unwrap();
132 let back: AuditResult = serde_json::from_str(&json).unwrap();
133 assert_eq!(
134 std::mem::discriminant(&result),
135 std::mem::discriminant(&back)
136 );
137 }
138 }
139
140 #[test]
141 fn optional_generations_omitted_when_none() {
142 let mut event = sample();
143 event.new_generation = None;
144 let json = serde_json::to_value(&event).unwrap();
145 assert!(json.get("new_generation").is_none());
146 assert!(json.get("previous_generation").is_none());
147 assert!(json.get("idempotency_key").is_none());
148 }
149}