agentguard_core/decision/
record.rs

1//! Decision record schema (v2).
2
3use crate::authorize::Decision;
4use crate::observability::{SpanId, TraceId};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Structured record written for every authorization decision.
9///
10/// Old readers (v1) ignore all unknown fields, so adding fields is
11/// backward-compatible at the JSON level.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct DecisionRecord {
14    pub id: String,
15    pub timestamp: chrono::DateTime<chrono::Utc>,
16    pub effect: String,
17    pub policies: Vec<String>,
18    pub request_id: Option<String>,
19    pub principal: String,
20    pub action: String,
21    pub resource: String,
22    pub reasons: Vec<String>,
23    pub session_id: Option<String>,
24    pub agent_chain: Option<Vec<String>>,
25    /// W3C Trace Context trace ID, propagated from the request.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub trace_id: Option<TraceId>,
28    /// W3C Trace Context span ID, propagated from the request.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub span_id: Option<SpanId>,
31    /// Tenant ID for multi-tenant deployments.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub tenant_id: Option<String>,
34    /// Subject ID for SAR queries (GDPR Art. 15).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub subject_id: Option<String>,
37    /// Authenticated PDP caller. This is server-added audit metadata and is
38    /// intentionally separate from Cedar context and the evaluated principal.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub authenticated_actor: Option<AuthenticatedActor>,
41}
42
43/// Provenance for a request accepted by a standalone authenticated PDP.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct AuthenticatedActor {
46    pub subject_type: String,
47    pub subject_id: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub tenant_id: Option<String>,
50    pub credential_id: String,
51    pub can_act_as: bool,
52}
53
54impl DecisionRecord {
55    pub fn from_decision(
56        d: &Decision,
57        session_id: Option<String>,
58        agent_chain: Option<Vec<String>>,
59    ) -> Self {
60        let req = &d.request;
61        let principal = req
62            .get("principal")
63            .and_then(|p| p.get("uid"))
64            .and_then(|u| u.as_str())
65            .map(|s| s.to_string())
66            .or_else(|| req.get("principal").map(|p| p.to_string()))
67            .unwrap_or_default();
68        let action = req
69            .get("action")
70            .map(|a| {
71                if let Some(tool) = a.get("tool").and_then(|t| t.as_str()) {
72                    if let Some(op) = a.get("operation").and_then(|o| o.as_str()) {
73                        format!("{}::{}", tool, op)
74                    } else {
75                        tool.to_string()
76                    }
77                } else {
78                    a.to_string()
79                }
80            })
81            .unwrap_or_default();
82        let resource = req
83            .get("resource")
84            .and_then(|r| r.get("uid"))
85            .and_then(|u| u.as_str())
86            .map(|s| s.to_string())
87            .unwrap_or_default();
88
89        Self {
90            id: Uuid::now_v7().to_string(),
91            timestamp: chrono::Utc::now(),
92            effect: format!("{:?}", d.effect).to_lowercase(),
93            policies: d.policies.clone(),
94            request_id: req
95                .get("request_id")
96                .and_then(|v| v.as_str())
97                .map(String::from),
98            principal,
99            action,
100            resource,
101            reasons: d.reasons.clone(),
102            session_id,
103            agent_chain,
104            trace_id: req
105                .get("trace")
106                .and_then(|t| t.get("trace_id"))
107                .and_then(|s| s.as_str())
108                .and_then(|s| s.parse().ok()),
109            span_id: req
110                .get("trace")
111                .and_then(|t| t.get("span_id"))
112                .and_then(|s| s.as_str())
113                .and_then(|s| s.parse().ok()),
114            tenant_id: req
115                .get("tenant_id")
116                .and_then(|v| v.as_str())
117                .map(String::from),
118            subject_id: req
119                .get("subject_id")
120                .and_then(|v| v.as_str())
121                .map(String::from),
122            authenticated_actor: req
123                .get("authenticated_actor")
124                .cloned()
125                .and_then(|actor| serde_json::from_value(actor).ok()),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::authorize::Effect;
134
135    #[test]
136    fn decision_record_preserves_authenticated_caller_provenance() {
137        let decision = Decision {
138            effect: Effect::Allow,
139            policies: vec!["policy0".into()],
140            reasons: vec![],
141            request: serde_json::json!({
142                "principal":{"uid":"research-agent"},
143                "authenticated_actor":{
144                    "subject_type":"Agent",
145                    "subject_id":"console-service",
146                    "tenant_id":"tenant-a",
147                    "credential_id":"key-123",
148                    "can_act_as":true
149                }
150            }),
151            trace: None,
152            from_cache: false,
153        };
154
155        let record = DecisionRecord::from_decision(&decision, None, None);
156        assert_eq!(record.principal, "research-agent");
157        assert_eq!(
158            record.authenticated_actor,
159            Some(AuthenticatedActor {
160                subject_type: "Agent".into(),
161                subject_id: "console-service".into(),
162                tenant_id: Some("tenant-a".into()),
163                credential_id: "key-123".into(),
164                can_act_as: true,
165            })
166        );
167    }
168
169    #[test]
170    fn older_decision_record_json_without_actor_remains_compatible() {
171        let value = serde_json::json!({
172            "id":"legacy",
173            "timestamp":"2025-01-01T00:00:00Z",
174            "effect":"allow",
175            "policies":[],
176            "request_id":null,
177            "principal":"alice",
178            "action":"read",
179            "resource":"doc",
180            "reasons":[],
181            "session_id":null,
182            "agent_chain":null
183        });
184        let record: DecisionRecord = serde_json::from_value(value).unwrap();
185        assert_eq!(record.authenticated_actor, None);
186    }
187}