agentguard_core/decision/
record.rs1use crate::authorize::Decision;
4use crate::observability::{SpanId, TraceId};
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub trace_id: Option<TraceId>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub span_id: Option<SpanId>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub tenant_id: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub subject_id: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub authenticated_actor: Option<AuthenticatedActor>,
41}
42
43#[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}