Skip to main content

khive_gate/
audit.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::{ActorRef, GateDecision, Obligation};
5
6/// Structured audit record emitted once per gate consultation.
7///
8/// JSON field names are stable; events reach tracing and the configured event store. See
9/// `crates/khive-gate/docs/api/audit-events.md`.
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct AuditEvent {
12    /// Wall-clock timestamp of the gate check (UTC, RFC3339 in JSON).
13    pub timestamp: DateTime<Utc>,
14    /// Caller identity as given to the gate.
15    pub actor: ActorRef,
16    /// Namespace in which the verb was invoked.
17    pub namespace: String,
18    /// Verb being dispatched.
19    pub verb: String,
20    /// Gate outcome — `"allow"`, `"deny"`, or `"gate_unavailable"`.
21    pub decision: AuditDecision,
22    /// Deny reason, present only when `decision == "deny"`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub deny_reason: Option<String>,
25    /// Obligations on allow; always serialized and empty on deny or outage.
26    #[serde(default)]
27    pub obligations: Vec<Obligation>,
28    /// Name of the gate implementation that produced this decision.
29    pub gate_impl: String,
30    /// Correlation token — `GateContext::session_id` when present, else `None`.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub session_id: Option<String>,
33    /// Original request parser position, not completion order. Unknown outside
34    /// a composed-request scope and in historical envelopes.
35    #[serde(default)]
36    pub op_index: Option<u32>,
37    /// Reference provenance; absent together with `op_index` when unknown.
38    #[serde(default)]
39    pub ref_resolution: Option<khive_types::RefResolution>,
40}
41
42/// The outcome field of an [`AuditEvent`].
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum AuditDecision {
46    Allow,
47    Deny,
48    GateUnavailable,
49}
50
51impl AuditEvent {
52    /// Attach provenance established by a request runner. A direct gate
53    /// consultation without that scope explicitly remains unattributed.
54    pub fn with_operation_attribution(
55        mut self,
56        operation: Option<khive_types::OperationAttribution>,
57    ) -> Self {
58        self.op_index = operation.map(|operation| operation.op_index);
59        self.ref_resolution = operation.map(|operation| operation.ref_resolution);
60        self
61    }
62
63    /// Project one request/decision pair into a timestamped stable audit envelope.
64    ///
65    /// See `crates/khive-gate/docs/api/audit-events.md`.
66    pub fn from_check(req: &crate::GateRequest, decision: &GateDecision, gate_impl: &str) -> Self {
67        let (audit_decision, deny_reason, obligations) = match decision {
68            GateDecision::Allow { obligations } => {
69                (AuditDecision::Allow, None, obligations.clone())
70            }
71            GateDecision::Deny { reason } => {
72                (AuditDecision::Deny, Some(reason.clone()), Vec::new())
73            }
74        };
75        Self {
76            timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
77            actor: req.actor.clone(),
78            namespace: req.namespace.as_str().to_string(),
79            verb: req.verb.clone(),
80            decision: audit_decision,
81            deny_reason,
82            obligations,
83            gate_impl: gate_impl.to_string(),
84            session_id: req.context.session_id.clone(),
85            op_index: None,
86            ref_resolution: None,
87        }
88    }
89
90    /// Project a gate infrastructure failure into the stable audit envelope.
91    pub fn gate_unavailable(req: &crate::GateRequest, gate_impl: &str) -> Self {
92        Self {
93            timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
94            actor: req.actor.clone(),
95            namespace: req.namespace.as_str().to_string(),
96            verb: req.verb.clone(),
97            decision: AuditDecision::GateUnavailable,
98            deny_reason: None,
99            obligations: Vec::new(),
100            gate_impl: gate_impl.to_string(),
101            session_id: req.context.session_id.clone(),
102            op_index: None,
103            ref_resolution: None,
104        }
105    }
106}
107
108#[cfg(test)]
109mod operation_tests {
110    use super::*;
111    use khive_types::{OperationAttribution, RefResolution};
112
113    #[test]
114    fn audit_operation_fields_are_closed_and_legacy_absence_stays_unknown() {
115        let request = crate::GateRequest::new(
116            crate::ActorRef::anonymous(),
117            khive_types::Namespace::local(),
118            "get",
119            serde_json::json!({}),
120        );
121        let direct = AuditEvent::from_check(
122            &request,
123            &GateDecision::Allow {
124                obligations: vec![],
125            },
126            "test",
127        );
128        assert_eq!((direct.op_index, direct.ref_resolution), (None, None));
129        let mut old = serde_json::to_value(&direct).unwrap();
130        old.as_object_mut().unwrap().remove("op_index");
131        old.as_object_mut().unwrap().remove("ref_resolution");
132        let legacy: AuditEvent = serde_json::from_value(old).unwrap();
133        assert_eq!((legacy.op_index, legacy.ref_resolution), (None, None));
134
135        let attributed = direct.with_operation_attribution(Some(OperationAttribution {
136            op_index: 3,
137            ref_resolution: RefResolution::Resolved,
138        }));
139        let mut value = serde_json::to_value(attributed).unwrap();
140        assert_eq!(value["op_index"], 3);
141        assert_eq!(value["ref_resolution"], "resolved");
142        value["ref_resolution"] = serde_json::json!("unknown");
143        assert!(serde_json::from_value::<AuditEvent>(value).is_err());
144    }
145}