agentguard_core/
request.rs

1//! Agent authorization requests and a builder.
2
3use crate::action::AgentAction;
4use crate::context::AgentContext;
5use crate::error::{Error, Result};
6use crate::observability::TraceContext;
7use crate::principal::Principal;
8use crate::resource::Resource;
9use cedar_policy::{Context, EntityId, EntityTypeName, EntityUid, Request};
10use serde::{Deserialize, Serialize};
11use std::str::FromStr;
12
13/// A full authorization request: principal + action + resource + context.
14///
15/// Construct via [`AgentRequest::new`] for a quick request, or
16/// [`AgentRequestBuilder`] for type-safe incremental construction.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[non_exhaustive]
19pub struct AgentRequest {
20    pub principal: Principal,
21    pub action: AgentAction,
22    pub resource: Resource,
23    pub context: AgentContext,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub request_id: Option<String>,
26    /// Optional W3C Trace Context (parsed from `traceparent` header).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub trace: Option<TraceContext>,
29    /// Tenant ID for multi-tenant deployments. Propagated through to
30    /// `DecisionRecord` so per-tenant SAR queries, blast-radius
31    /// analyses, and audit log scans can scope by tenant.
32    ///
33    /// No format constraint is enforced here; the caller decides
34    /// (UUID, slug, email, etc). `with_tenant_id` trims whitespace
35    /// and converts empty strings to `None`.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub tenant_id: Option<String>,
38}
39
40impl AgentRequest {
41    /// Construct a request with a fresh UUID v7 request id.
42    pub fn new(
43        principal: Principal,
44        action: AgentAction,
45        resource: Resource,
46        context: AgentContext,
47    ) -> Self {
48        Self {
49            principal,
50            action,
51            resource,
52            context,
53            request_id: Some(uuid::Uuid::now_v7().to_string()),
54            trace: None,
55            tenant_id: None,
56        }
57    }
58
59    /// Override the auto-generated request id.
60    pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
61        self.request_id = Some(id.into());
62        self
63    }
64
65    /// Attach a W3C Trace Context.
66    pub fn with_trace(mut self, trace: TraceContext) -> Self {
67        self.trace = Some(trace);
68        self
69    }
70
71    /// Set the tenant ID. Whitespace is trimmed; empty/whitespace-only
72    /// values become `None`. No other format constraint.
73    pub fn with_tenant_id(mut self, tid: impl Into<String>) -> Self {
74        let s = tid.into();
75        self.tenant_id = if s.trim().is_empty() {
76            None
77        } else {
78            Some(s.trim().to_string())
79        };
80        self
81    }
82
83    /// Convert to a `cedar_policy::Request`. `schema` is used to construct
84    /// a typed context (validates context shape per action) when available.
85    ///
86    /// # Errors
87    /// Returns `Error::InvalidPrincipal` / `Error::InvalidResource` if
88    /// the principal type or resource type name is not a valid Cedar
89    /// identifier. Returns `Error::InvalidContext` if the context cannot
90    /// be parsed against the schema.
91    pub fn to_cedar_request(&self, schema: Option<&cedar_policy::Schema>) -> Result<Request> {
92        let principal_eid = EntityId::new(&**self.principal.id());
93        let principal_etype =
94            EntityTypeName::from_str(self.principal.entity_type()).map_err(|e| {
95                Error::InvalidPrincipal(format!("{}: {}", self.principal.entity_type(), e))
96            })?;
97        let principal_uid = EntityUid::from_type_name_and_id(principal_etype, principal_eid);
98
99        let action_eid = EntityId::new(self.action.action_id());
100        let action_etype = EntityTypeName::from_str("Action")
101            .map_err(|e| Error::InvalidPrincipal(format!("Action: {}", e)))?;
102        let action_uid = EntityUid::from_type_name_and_id(action_etype, action_eid);
103
104        let resource_eid = EntityId::new(&self.resource.uid);
105        let resource_etype = EntityTypeName::from_str(&self.resource.entity_type)
106            .map_err(|e| Error::InvalidResource(format!("{}: {}", self.resource.entity_type, e)))?;
107        let resource_uid = EntityUid::from_type_name_and_id(resource_etype, resource_eid);
108
109        // Build context JSON.
110        let mut ctx_map = self.context.to_json_object();
111        let ctx_json = serde_json::Value::Object(std::mem::take(&mut ctx_map));
112
113        let context = match schema {
114            Some(s) => {
115                let json_str = serde_json::to_string(&ctx_json)?;
116                Context::from_json_str(&json_str, Some((s, &action_uid)))
117                    .map_err(|e| Error::InvalidContext(e.to_string()))?
118            }
119            None => Context::from_json_str(&serde_json::to_string(&ctx_json)?, None)
120                .map_err(|e| Error::InvalidContext(e.to_string()))?,
121        };
122
123        let req = Request::new(principal_uid, action_uid, resource_uid, context, schema)
124            .map_err(|e| Error::InvalidContext(e.to_string()))?;
125
126        Ok(req)
127    }
128}
129
130/// Builder for [`AgentRequest`] with type-safe setters.
131///
132/// # Examples
133/// ```
134/// use agentguard_core::{AgentRequestBuilder, Principal, AgentAction, Resource, AgentContext};
135/// let req = AgentRequestBuilder::new(Principal::user("alice"))
136///     .action(AgentAction::tool("send_email"))
137///     .resource(Resource::new("Mailbox", "alice@acme"))
138///     .context(AgentContext::new())
139///     .build();
140/// ```
141#[derive(Debug, Clone)]
142pub struct AgentRequestBuilder {
143    principal: Principal,
144    action: Option<AgentAction>,
145    resource: Option<Resource>,
146    context: AgentContext,
147    request_id: Option<String>,
148    trace: Option<TraceContext>,
149    tenant_id: Option<String>,
150}
151
152impl AgentRequestBuilder {
153    /// Start building a request for the given principal.
154    pub fn new(principal: impl Into<Principal>) -> Self {
155        Self {
156            principal: principal.into(),
157            action: None,
158            resource: None,
159            context: AgentContext::new(),
160            request_id: None,
161            trace: None,
162            tenant_id: None,
163        }
164    }
165
166    /// Set the action (tool call).
167    pub fn action(mut self, a: impl Into<AgentAction>) -> Self {
168        self.action = Some(a.into());
169        self
170    }
171
172    /// Set the resource.
173    pub fn resource(mut self, r: impl Into<Resource>) -> Self {
174        self.resource = Some(r.into());
175        self
176    }
177
178    /// Set the context.
179    pub fn context(mut self, c: impl Into<AgentContext>) -> Self {
180        self.context = c.into();
181        self
182    }
183
184    /// Override the auto-generated request id.
185    pub fn request_id(mut self, id: impl Into<String>) -> Self {
186        self.request_id = Some(id.into());
187        self
188    }
189
190    /// Attach a W3C Trace Context.
191    pub fn trace(mut self, trace: TraceContext) -> Self {
192        self.trace = Some(trace);
193        self
194    }
195
196    /// Attach a trace by parsing a `traceparent` string.
197    ///
198    /// # Errors
199    /// Returns `Error::Other` (or a parse error) if the traceparent string
200    /// is malformed.
201    pub fn traceparent(mut self, tp: &str) -> Result<Self> {
202        let trace: TraceContext = tp.parse()?;
203        self.trace = Some(trace);
204        Ok(self)
205    }
206
207    /// Set the tenant ID. Whitespace is trimmed; empty/whitespace-only
208    /// values become `None`. No other format constraint.
209    pub fn tenant_id(mut self, tid: impl Into<String>) -> Self {
210        let s = tid.into();
211        self.tenant_id = if s.trim().is_empty() {
212            None
213        } else {
214            Some(s.trim().to_string())
215        };
216        self
217    }
218
219    /// Finalize the request. Returns an error if action or resource is missing.
220    pub fn build(self) -> Result<AgentRequest> {
221        let action = self
222            .action
223            .ok_or_else(|| Error::InvalidPrincipal("action is required".into()))?;
224        let resource = self
225            .resource
226            .ok_or_else(|| Error::InvalidResource("resource is required".into()))?;
227        let mut req = AgentRequest::new(self.principal, action, resource, self.context);
228        if let Some(id) = self.request_id {
229            req.request_id = Some(id);
230        }
231        req.trace = self.trace;
232        req.tenant_id = self.tenant_id;
233        Ok(req)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::AgentAction;
241
242    #[test]
243    fn builder_sets_all_fields() {
244        let req = AgentRequestBuilder::new(Principal::user("alice"))
245            .action(AgentAction::tool("send_email"))
246            .resource(Resource::new("Mailbox", "alice@acme"))
247            .context(AgentContext::new().with_arg("to", "[email protected]"))
248            .build()
249            .unwrap();
250        assert_eq!(format!("{}", req.principal), "User::\"alice\"");
251        assert_eq!(
252            format!("{}", req.action),
253            "Action::\"ToolCall::send_email\""
254        );
255        assert_eq!(format!("{}", req.resource), "Mailbox::\"alice@acme\"");
256        assert!(req.request_id.is_some());
257    }
258
259    #[test]
260    fn builder_requires_action() {
261        let res = AgentRequestBuilder::new(Principal::user("alice"))
262            .resource(Resource::new("Mailbox", "alice@acme"))
263            .build();
264        assert!(res.is_err());
265    }
266
267    #[test]
268    fn builder_requires_resource() {
269        let res = AgentRequestBuilder::new(Principal::user("alice"))
270            .action(AgentAction::tool("send_email"))
271            .build();
272        assert!(res.is_err());
273    }
274
275    #[test]
276    fn new_auto_assigns_request_id() {
277        let req = AgentRequest::new(
278            Principal::user("alice"),
279            AgentAction::tool("send_email"),
280            Resource::new("Mailbox", "alice@acme"),
281            AgentContext::new(),
282        );
283        assert!(req.request_id.is_some());
284    }
285
286    #[test]
287    fn tenant_id_round_trips() {
288        let req = AgentRequestBuilder::new(Principal::user("alice"))
289            .action(AgentAction::tool("send_email"))
290            .resource(Resource::new("Mailbox", "alice@acme"))
291            .tenant_id("acme-corp")
292            .build()
293            .unwrap();
294        assert_eq!(req.tenant_id.as_deref(), Some("acme-corp"));
295
296        // Whitespace-only becomes None.
297        let req = AgentRequestBuilder::new(Principal::user("alice"))
298            .action(AgentAction::tool("send_email"))
299            .resource(Resource::new("Mailbox", "alice@acme"))
300            .tenant_id("   ")
301            .build()
302            .unwrap();
303        assert_eq!(req.tenant_id, None);
304
305        // Surrounding whitespace is trimmed.
306        let req = AgentRequestBuilder::new(Principal::user("alice"))
307            .action(AgentAction::tool("send_email"))
308            .resource(Resource::new("Mailbox", "alice@acme"))
309            .tenant_id("  acme-corp  ")
310            .build()
311            .unwrap();
312        assert_eq!(req.tenant_id.as_deref(), Some("acme-corp"));
313
314        // Serde round-trips through JSON.
315        let json = serde_json::to_string(&req).unwrap();
316        let parsed: AgentRequest = serde_json::from_str(&json).unwrap();
317        assert_eq!(parsed.tenant_id.as_deref(), Some("acme-corp"));
318    }
319
320    /// T1: full serde round-trip with every optional field populated.
321    /// Catches drift between Serialize/Deserialize impls.
322    #[test]
323    fn full_serde_round_trip() {
324        let req = AgentRequestBuilder::new(Principal::user("alice"))
325            .action(AgentAction::tool_op("send", "email"))
326            .resource(Resource::new("Mailbox", "alice@acme"))
327            .context(AgentContext::new().with_arg("to", "[email protected]"))
328            .request_id("req-123")
329            .tenant_id("acme-corp")
330            .build()
331            .unwrap();
332        let json = serde_json::to_string(&req).unwrap();
333        let parsed: AgentRequest = serde_json::from_str(&json).unwrap();
334        assert_eq!(parsed, req);
335    }
336}