khive_gate/audit.rs
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::{ActorRef, GateDecision, Obligation};
5
6// ---------- Audit event ----------
7
8/// Structured audit record emitted once per gate consultation.
9///
10/// The JSON projection of this struct is the **public contract** — field names
11/// are stable. Adding fields is non-breaking; removing or renaming requires a
12/// new ADR.
13///
14/// Events are emitted via `tracing::info!` as structured JSON. When the
15/// dispatch registry is configured with an `EventStore`, the same envelope is
16/// also persisted as an audit event.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct AuditEvent {
19 /// Wall-clock timestamp of the gate check (UTC, RFC3339 in JSON).
20 pub timestamp: DateTime<Utc>,
21 /// Caller identity as given to the gate.
22 pub actor: ActorRef,
23 /// Namespace in which the verb was invoked.
24 pub namespace: String,
25 /// Verb being dispatched.
26 pub verb: String,
27 /// Gate outcome — `"allow"` or `"deny"`.
28 pub decision: AuditDecision,
29 /// Deny reason, present only when `decision == "deny"`.
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub deny_reason: Option<String>,
32 /// Obligations attached by the policy on Allow (empty array on Deny).
33 /// Always serialized — `obligations: []` is the wire shape when there
34 /// are none, so non-Rust consumers do not need to special-case absence
35 /// vs. emptiness.
36 #[serde(default)]
37 pub obligations: Vec<Obligation>,
38 /// Name of the gate implementation that produced this decision.
39 pub gate_impl: String,
40 /// Correlation token — `GateContext::session_id` when present, else `None`.
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub session_id: Option<String>,
43}
44
45/// The outcome field of an [`AuditEvent`], serialised as `"allow"` / `"deny"`.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum AuditDecision {
49 Allow,
50 Deny,
51}
52
53impl AuditEvent {
54 /// Build an `AuditEvent` from the gate inputs and output.
55 pub fn from_check(req: &crate::GateRequest, decision: &GateDecision, gate_impl: &str) -> Self {
56 let (audit_decision, deny_reason, obligations) = match decision {
57 GateDecision::Allow { obligations } => {
58 (AuditDecision::Allow, None, obligations.clone())
59 }
60 GateDecision::Deny { reason } => {
61 (AuditDecision::Deny, Some(reason.clone()), Vec::new())
62 }
63 };
64 Self {
65 timestamp: req.context.timestamp.unwrap_or_else(chrono::Utc::now),
66 actor: req.actor.clone(),
67 namespace: req.namespace.as_str().to_string(),
68 verb: req.verb.clone(),
69 decision: audit_decision,
70 deny_reason,
71 obligations,
72 gate_impl: gate_impl.to_string(),
73 session_id: req.context.session_id.clone(),
74 }
75 }
76}