agentguard_core/decision/
formatter.rs

1//! Audit log formatters for SIEM ingestion.
2//!
3//! Four formats are supported:
4//! - `Jsonl` (default): pass-through JSON
5//! - `Cef`: ArcSight Common Event Format
6//! - `Leef`: IBM QRadar Log Event Extended Format
7//! - `Ecs`: Elastic Common Schema (JSON)
8//!
9//! The trait has been replaced by an enum (`AuditFormat`) + a single
10//! inherent `format()` method. This avoids the per-record `Box<dyn>`
11//! indirection in the CLI's export path and lets the compiler inline
12//! the format selection.
13
14use crate::decision::record::DecisionRecord;
15use serde::Serialize;
16use std::str::FromStr;
17
18/// Stable identifier for an audit format (e.g. `"jsonl"`, `"cef"`).
19///
20/// Used by the CLI's `--log-format` flag and the `AgentAuditFormat` HTTP
21/// field (when added in a future version).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[non_exhaustive]
24pub enum AuditFormat {
25    /// Pass-through JSON, one record per line.
26    Jsonl,
27    /// ArcSight Common Event Format.
28    Cef,
29    /// IBM QRadar Log Event Extended Format.
30    Leef,
31    /// Elastic Common Schema (JSON).
32    Ecs,
33}
34
35impl AuditFormat {
36    /// Stable lowercase string identifier for this format.
37    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    /// Format one record into the appropriate line(s).
47    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}