Skip to main content

greentic_deploy_spec/
audit.rs

1//! Audit-event and authorization-decision wire shapes.
2//!
3//! A7 emits these locally (`greentic-deployer` owns the FS append writer
4//! `AuditLog` and the local authorization gate `authorize_local_only`); A8
5//! reuses the same shapes as the remote-store contract surface — a mutating
6//! HTTP call returns the [`AuditEvent`] it recorded, and the RBAC decision is
7//! an [`AuditDecision`]. Only the serializable shapes live here so the wire
8//! contract has a single owner (the deployer keeps the behavior).
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::version::SchemaVersion;
15
16/// Phase A authorization policy identifier (`AuditDecision.policy`).
17pub const POLICY_LOCAL_ONLY: &str = "local-only";
18
19/// One append-only audit record. Covers every field plan §389 requires.
20#[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/// Who performed the mutation. `kind` is a string (one value `local-user`
41/// today; A8's remote path adds service/operator kinds).
42#[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/// Authorization (RBAC) decision. Phase A uses the `local-only` policy; A8's
52/// remote store returns the same shape from its RBAC engine.
53#[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/// Outcome of the mutation as recorded in the audit event.
61#[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}