agentstatedeveloper_core/
audit.rs1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::error::Result;
19use std::path::Path;
20
21pub const HASH_PREFIX: &str = "b3:";
25
26pub mod event_types {
32 pub const LEDGER_APPEND: &str = "ledger.append";
33 pub const LEDGER_APPROVE: &str = "ledger.approve";
34 pub const LEDGER_REJECT: &str = "ledger.reject";
35 pub const LEDGER_WITHDRAW: &str = "ledger.withdraw";
36 pub const LEDGER_SUPERSEDE: &str = "ledger.supersede";
37 pub const LEDGER_REBIND: &str = "ledger.rebind";
38 pub const EFFECT_DECLARE: &str = "effect.declare";
39 pub const INDEX_RUN: &str = "index.run";
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AuditEvent {
47 pub event_id: String,
49 pub event_type: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub subject_id: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub secondary_id: Option<String>,
55 pub actor_id: String,
56 pub actor_kind: String,
57 pub timestamp: DateTime<Utc>,
58 pub outcome: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub matched_policy: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub reason: Option<String>,
63 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
64 pub payload: serde_json::Value,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub prev_event_hash: Option<String>,
69 #[serde(default)]
72 pub event_hash: String,
73}
74
75impl AuditEvent {
76 pub fn new(
77 event_type: impl Into<String>,
78 actor_id: impl Into<String>,
79 actor_kind: impl Into<String>,
80 outcome: impl Into<String>,
81 ) -> Self {
82 Self {
83 event_id: format!("evt_{}", Uuid::new_v4().simple()),
84 event_type: event_type.into(),
85 subject_id: None,
86 secondary_id: None,
87 actor_id: actor_id.into(),
88 actor_kind: actor_kind.into(),
89 timestamp: Utc::now(),
90 outcome: outcome.into(),
91 matched_policy: None,
92 reason: None,
93 payload: serde_json::Value::Null,
94 prev_event_hash: None,
95 event_hash: String::new(),
96 }
97 }
98
99 pub fn is_signed(&self) -> bool {
103 !self.event_hash.is_empty()
104 }
105
106 pub fn with_subject(mut self, subject_id: impl Into<String>) -> Self {
107 self.subject_id = Some(subject_id.into());
108 self
109 }
110
111 pub fn with_secondary(mut self, secondary_id: impl Into<String>) -> Self {
112 self.secondary_id = Some(secondary_id.into());
113 self
114 }
115
116 pub fn with_matched_policy(mut self, matched_policy: Option<String>) -> Self {
117 self.matched_policy = matched_policy;
118 self
119 }
120
121 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
122 self.reason = Some(reason.into());
123 self
124 }
125
126 pub fn with_payload(mut self, payload: serde_json::Value) -> Self {
127 self.payload = payload;
128 self
129 }
130}
131
132pub trait AuditSink: Send + Sync {
137 fn emit(&self, event: &AuditEvent) -> Result<()>;
138
139 fn emit_all(&self, events: &[AuditEvent]) -> Result<()> {
140 for e in events {
141 self.emit(e)?;
142 }
143 Ok(())
144 }
145}
146
147pub fn emit_audit(sink: &dyn AuditSink, event: AuditEvent) {
150 if let Err(e) = sink.emit(&event) {
151 eprintln!("warning: audit emit failed: {e}");
152 }
153}
154
155pub struct NullSink;
158
159impl AuditSink for NullSink {
160 fn emit(&self, _event: &AuditEvent) -> Result<()> {
161 Ok(())
162 }
163}
164
165pub fn read_jsonl(path: &Path) -> Result<Vec<AuditEvent>> {
172 if !path.exists() {
173 return Ok(Vec::new());
174 }
175 let raw = std::fs::read_to_string(path)?;
176 let mut out = Vec::new();
177 for line in raw.lines() {
178 let line = line.trim();
179 if line.is_empty() {
180 continue;
181 }
182 let event: AuditEvent = serde_json::from_str(line)?;
183 out.push(event);
184 }
185 Ok(out)
186}
187
188#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn event_serialization_round_trip() {
198 let e = AuditEvent::new(
199 event_types::LEDGER_APPEND,
200 "alice",
201 "human",
202 "awaiting-approval",
203 )
204 .with_subject("led_abc123")
205 .with_secondary("sym_xyz")
206 .with_matched_policy(Some("/policies/code/hazard@1".into()))
207 .with_reason("requires human approval")
208 .with_payload(serde_json::json!({"kind": "hazard", "qname": "payments.x"}));
209
210 let json = serde_json::to_string(&e).unwrap();
211 let parsed: AuditEvent = serde_json::from_str(&json).unwrap();
212 assert_eq!(parsed.event_id, e.event_id);
213 assert_eq!(parsed.event_type, event_types::LEDGER_APPEND);
214 assert_eq!(parsed.actor_id, "alice");
215 }
216
217 #[test]
218 fn null_sink_accepts_everything() {
219 let sink = NullSink;
220 let e = AuditEvent::new("x", "y", "z", "success");
221 sink.emit(&e).unwrap();
222 }
223
224 #[test]
225 fn read_empty_path_returns_empty() {
226 let path = std::path::PathBuf::from("/tmp/does-not-exist-asd-audit-test.jsonl");
227 if path.exists() {
228 std::fs::remove_file(&path).ok();
229 }
230 let events = read_jsonl(&path).unwrap();
231 assert!(events.is_empty());
232 }
233}