agentguard_core/decision/
formatter.rs1use crate::decision::record::DecisionRecord;
15use serde::Serialize;
16use std::str::FromStr;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum AuditFormat {
25 Jsonl,
27 Cef,
29 Leef,
31 Ecs,
33}
34
35impl AuditFormat {
36 pub fn name(self) -> &'static str {
38 match self {
39 AuditFormat::Jsonl => "jsonl",
40 AuditFormat::Cef => "cef",
41 AuditFormat::Leef => "leef",
42 AuditFormat::Ecs => "ecs",
43 }
44 }
45
46 pub fn format(self, rec: &DecisionRecord) -> String {
48 match self {
49 AuditFormat::Jsonl => serde_json::to_string(rec).unwrap_or_default(),
50 AuditFormat::Cef => format_cef(rec),
51 AuditFormat::Leef => format_leef(rec),
52 AuditFormat::Ecs => format_ecs(rec),
53 }
54 }
55}
56
57impl FromStr for AuditFormat {
58 type Err = String;
59 fn from_str(s: &str) -> Result<Self, Self::Err> {
60 match s {
61 "jsonl" => Ok(AuditFormat::Jsonl),
62 "cef" => Ok(AuditFormat::Cef),
63 "leef" => Ok(AuditFormat::Leef),
64 "ecs" => Ok(AuditFormat::Ecs),
65 other => Err(format!(
66 "unknown audit format: {other} (use jsonl|cef|leef|ecs)"
67 )),
68 }
69 }
70}
71
72impl std::fmt::Display for AuditFormat {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str(self.name())
75 }
76}
77
78fn format_cef(rec: &DecisionRecord) -> String {
79 let severity = if rec.effect == "allow" { 1 } else { 5 };
80 let mut extensions = format!(
81 "src={} suser={} act={} outcome={} cs1={} cs1Label=agentguardDecision cs2={} cs2Label=agentguardPolicy cs3={} cs3Label=agentguardResource",
82 escape(rec.principal.as_str()),
83 escape(rec.principal.as_str()),
84 escape(&rec.action),
85 if rec.effect == "allow" { "Success" } else { "Failure" },
86 escape(&rec.effect),
87 escape(&rec.policies.join(",")),
88 escape(&rec.resource),
89 );
90 if let Some(actor) = &rec.authenticated_actor {
91 extensions.push_str(&format!(
92 " cs4={} cs4Label=agentguardAuthenticatedActor cs5={} cs5Label=agentguardCredentialId cs6={} cs6Label=agentguardActAs",
93 escape(&format!("{}::{}", actor.subject_type, actor.subject_id)),
94 escape(&actor.credential_id),
95 actor.can_act_as,
96 ));
97 }
98 format!(
99 "CEF:0|agentguard|agentguard|2.0|0|authz_decision|{}|{}",
100 severity, extensions
101 )
102}
103
104fn format_leef(rec: &DecisionRecord) -> String {
105 let mut s = format!(
106 "LEEF:2.0|agentguard|agentguard|2.0|authz_decision|\
107 devTime={} usrName={} action={} outcome={} src={}",
108 rec.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
109 escape(&rec.principal),
110 escape(&rec.action),
111 if rec.effect == "allow" {
112 "Success"
113 } else {
114 "Failure"
115 },
116 escape(&rec.resource),
117 );
118 if !rec.policies.is_empty() {
119 s.push_str(&format!(
120 " agentguardPolicy={}",
121 escape(&rec.policies.join(","))
122 ));
123 }
124 if let Some(t) = &rec.trace_id {
125 s.push_str(&format!(" traceId={}", escape(&t.to_string())));
126 }
127 if let Some(actor) = &rec.authenticated_actor {
128 s.push_str(&format!(
129 " agentguardAuthenticatedActor={} agentguardCredentialId={} agentguardActAs={}",
130 escape(&format!("{}::{}", actor.subject_type, actor.subject_id)),
131 escape(&actor.credential_id),
132 actor.can_act_as,
133 ));
134 }
135 s
136}
137
138#[derive(Serialize)]
139struct EcsRecord<'a> {
140 #[serde(rename = "@timestamp")]
141 timestamp: String,
142 #[serde(rename = "event.action")]
143 event_action: &'a str,
144 #[serde(rename = "event.outcome")]
145 outcome: &'a str,
146 #[serde(rename = "event.kind")]
147 kind: &'a str,
148 #[serde(rename = "event.category")]
149 category: &'a str,
150 #[serde(rename = "user.name")]
151 user_name: &'a str,
152 #[serde(rename = "labels.agentguard_principal")]
153 principal: &'a str,
154 #[serde(rename = "labels.agentguard_action")]
155 agent_action: &'a str,
156 #[serde(rename = "labels.agentguard_resource")]
157 resource: &'a str,
158 #[serde(rename = "labels.agentguard_policies")]
159 policies: String,
160 #[serde(
161 rename = "labels.agentguard_decision_id",
162 skip_serializing_if = "Option::is_none"
163 )]
164 decision_id: Option<&'a str>,
165 #[serde(
166 rename = "labels.agentguard_trace_id",
167 skip_serializing_if = "Option::is_none"
168 )]
169 trace_id: Option<String>,
170 #[serde(
171 rename = "labels.agentguard_authenticated_actor",
172 skip_serializing_if = "Option::is_none"
173 )]
174 authenticated_actor: Option<String>,
175 #[serde(
176 rename = "labels.agentguard_credential_id",
177 skip_serializing_if = "Option::is_none"
178 )]
179 credential_id: Option<&'a str>,
180 #[serde(
181 rename = "labels.agentguard_act_as",
182 skip_serializing_if = "Option::is_none"
183 )]
184 act_as: Option<bool>,
185}
186
187fn format_ecs(rec: &DecisionRecord) -> String {
188 let r = EcsRecord {
189 timestamp: rec.timestamp.to_rfc3339(),
190 event_action: "authz_decision",
191 outcome: if rec.effect == "allow" {
192 "success"
193 } else {
194 "failure"
195 },
196 kind: "event",
197 category: "authentication",
198 user_name: &rec.principal,
199 principal: &rec.principal,
200 agent_action: &rec.action,
201 resource: &rec.resource,
202 policies: rec.policies.join(","),
203 decision_id: Some(&rec.id),
204 trace_id: rec.trace_id.as_ref().map(|t| t.to_string()),
205 authenticated_actor: rec
206 .authenticated_actor
207 .as_ref()
208 .map(|actor| format!("{}::{}", actor.subject_type, actor.subject_id)),
209 credential_id: rec
210 .authenticated_actor
211 .as_ref()
212 .map(|actor| actor.credential_id.as_str()),
213 act_as: rec
214 .authenticated_actor
215 .as_ref()
216 .map(|actor| actor.can_act_as),
217 };
218 serde_json::to_string(&r).unwrap_or_default()
219}
220
221fn escape(s: &str) -> String {
222 s.replace('\\', "\\\\")
223 .replace('=', "\\=")
224 .replace('\n', " ")
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use chrono::Utc;
231 use uuid::Uuid;
232
233 fn fixture() -> DecisionRecord {
234 DecisionRecord {
235 id: Uuid::new_v4().to_string(),
236 timestamp: Utc::now(),
237 effect: "allow".into(),
238 policies: vec!["policy0".into()],
239 request_id: None,
240 principal: "alice".into(),
241 action: "send_email".into(),
242 resource: "alice@acme".into(),
243 reasons: vec![],
244 session_id: None,
245 agent_chain: None,
246 trace_id: None,
247 span_id: None,
248 tenant_id: None,
249 subject_id: None,
250 authenticated_actor: None,
251 }
252 }
253
254 #[test]
255 fn jsonl_format_round_trips() {
256 let line = AuditFormat::Jsonl.format(&fixture());
257 let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
258 assert_eq!(parsed["principal"], "alice");
259 }
260
261 #[test]
262 fn cef_includes_required_fields() {
263 let line = AuditFormat::Cef.format(&fixture());
264 assert!(line.starts_with("CEF:0|agentguard|agentguard|"));
265 assert!(line.contains("suser=alice") || line.contains("suser\\=alice"));
266 assert!(line.contains("outcome=Success") || line.contains("outcome\\=Success"));
267 }
268
269 #[test]
270 fn leef_format_starts_correctly() {
271 let line = AuditFormat::Leef.format(&fixture());
272 assert!(line.starts_with("LEEF:2.0|agentguard|"));
273 assert!(line.contains("usrName=alice"));
274 }
275
276 #[test]
277 fn ecs_emits_required_fields() {
278 let line = AuditFormat::Ecs.format(&fixture());
279 let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
280 assert_eq!(parsed["event.action"], "authz_decision");
281 assert_eq!(parsed["event.outcome"], "success");
282 assert_eq!(parsed["user.name"], "alice");
283 assert_eq!(parsed["labels.agentguard_principal"], "alice");
284 }
285
286 #[test]
287 fn format_from_str_round_trips() {
288 for name in ["jsonl", "cef", "leef", "ecs"] {
289 let f: AuditFormat = name.parse().unwrap();
290 assert_eq!(f.name(), name);
291 }
292 }
293
294 #[test]
295 fn unknown_format_errors() {
296 assert!("xml".parse::<AuditFormat>().is_err());
297 }
298
299 #[test]
300 fn siem_exports_preserve_authenticated_act_as_provenance() {
301 let mut record = fixture();
302 record.authenticated_actor = Some(crate::decision::record::AuthenticatedActor {
303 subject_type: "Agent".into(),
304 subject_id: "console-service".into(),
305 tenant_id: Some("tenant-a".into()),
306 credential_id: "key-123".into(),
307 can_act_as: true,
308 });
309
310 let cef = AuditFormat::Cef.format(&record);
311 assert!(cef.contains("console-service"));
312 assert!(cef.contains("key-123"));
313 assert!(cef.contains("cs6=true"));
314
315 let leef = AuditFormat::Leef.format(&record);
316 assert!(leef.contains("agentguardAuthenticatedActor=Agent::console-service"));
317 assert!(leef.contains("agentguardCredentialId=key-123"));
318
319 let ecs: serde_json::Value =
320 serde_json::from_str(&AuditFormat::Ecs.format(&record)).unwrap();
321 assert_eq!(
322 ecs["labels.agentguard_authenticated_actor"],
323 "Agent::console-service"
324 );
325 assert_eq!(ecs["labels.agentguard_credential_id"], "key-123");
326 assert_eq!(ecs["labels.agentguard_act_as"], true);
327 }
328}