1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::{ActorRef, GateDecision, Obligation};
5
6#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct AuditEvent {
12 pub timestamp: DateTime<Utc>,
14 pub actor: ActorRef,
16 pub namespace: String,
18 pub verb: String,
20 pub decision: AuditDecision,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub deny_reason: Option<String>,
25 #[serde(default)]
27 pub obligations: Vec<Obligation>,
28 pub gate_impl: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub session_id: Option<String>,
33 #[serde(default)]
36 pub op_index: Option<u32>,
37 #[serde(default)]
39 pub ref_resolution: Option<khive_types::RefResolution>,
40}
41
42#[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 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 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 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}