Skip to main content

systemprompt_security/authz/types/
request.rs

1//! `AuthzRequest` and the open enforcement-site `AuthzContext`.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10use systemprompt_identifiers::{
11    Actor, ActorKind, AgentId, ClientId, ContextId, McpToolName, ModelId, SessionId, TaskId,
12    TraceId, UserId,
13};
14
15use super::decision::DenyReason;
16use super::entity_ref::EntityRef;
17use crate::policy::types::AccessScope;
18
19/// Open enforcement-site context attached to an [`AuthzRequest`].
20///
21/// Replaces the previous closed enum so tenants can add their own
22/// enforcement sites (skill execution, order submission, file egress, ...)
23/// without a core change.
24///
25/// `kind` is a dotted-namespaced literal. Core mints three:
26///
27/// - `"none"` — no context (server-attach RBAC, etc).
28/// - `"gateway.invocation"` — payload `{ "model": "..." }`.
29/// - `"mcp.tool_call"` — payload `{ "tool": "..." }`.
30///
31/// Tenants mint their own (e.g. `"acme.order_submission"`) and recognise
32/// them in their hook. Core never interprets `payload`.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct AuthzContext {
35    pub kind: Cow<'static, str>,
36    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
37    // JSON: extension authz context; the registering extension owns its shape.
38    pub payload: serde_json::Value,
39}
40
41impl Default for AuthzContext {
42    fn default() -> Self {
43        Self::none()
44    }
45}
46
47impl AuthzContext {
48    pub const NONE_KIND: &'static str = "none";
49    pub const GATEWAY_INVOCATION_KIND: &'static str = "gateway.invocation";
50    pub const MCP_TOOL_CALL_KIND: &'static str = "mcp.tool_call";
51
52    #[must_use]
53    pub const fn none() -> Self {
54        Self {
55            kind: Cow::Borrowed(Self::NONE_KIND),
56            payload: serde_json::Value::Null,
57        }
58    }
59
60    #[must_use]
61    pub fn gateway_invocation(model: &ModelId) -> Self {
62        Self {
63            kind: Cow::Borrowed(Self::GATEWAY_INVOCATION_KIND),
64            payload: serde_json::json!({ "model": model.as_str() }),
65        }
66    }
67
68    #[must_use]
69    pub fn mcp_tool_call(tool: &McpToolName) -> Self {
70        Self {
71            kind: Cow::Borrowed(Self::MCP_TOOL_CALL_KIND),
72            payload: serde_json::json!({ "tool": tool.as_str() }),
73        }
74    }
75
76    #[must_use]
77    // JSON: extension authz context; the registering extension owns its shape.
78    pub fn extension(kind: impl Into<Cow<'static, str>>, payload: serde_json::Value) -> Self {
79        Self {
80            kind: kind.into(),
81            payload,
82        }
83    }
84
85    #[must_use]
86    pub fn gateway_invocation_model(&self) -> Option<ModelId> {
87        if self.kind != Self::GATEWAY_INVOCATION_KIND {
88            return None;
89        }
90        self.payload
91            .get("model")
92            .and_then(|v| v.as_str())
93            .map(ModelId::new)
94    }
95
96    #[must_use]
97    pub fn mcp_tool_call_tool(&self) -> Option<McpToolName> {
98        if self.kind != Self::MCP_TOOL_CALL_KIND {
99            return None;
100        }
101        self.payload
102            .get("tool")
103            .and_then(|v| v.as_str())
104            .and_then(|tool| McpToolName::try_new(tool).ok())
105    }
106
107    #[must_use]
108    pub fn is_none(&self) -> bool {
109        self.kind == Self::NONE_KIND
110    }
111
112    pub const MARKETPLACE_FLOOR_KEY: &'static str = "marketplace.attribute_floor";
113
114    #[must_use]
115    // JSON: ABAC attribute values, declared per deployment in authz policy YAML.
116    pub fn with_marketplace_floor(&self, floor: &BTreeMap<String, serde_json::Value>) -> Self {
117        let mut payload = match self.payload.clone() {
118            serde_json::Value::Object(map) => map,
119            _ => serde_json::Map::new(),
120        };
121        let floor_value = floor
122            .iter()
123            .map(|(k, v)| (k.clone(), v.clone()))
124            .collect::<serde_json::Map<String, serde_json::Value>>();
125        payload.insert(
126            Self::MARKETPLACE_FLOOR_KEY.to_owned(),
127            serde_json::Value::Object(floor_value),
128        );
129        Self {
130            kind: self.kind.clone(),
131            payload: serde_json::Value::Object(payload),
132        }
133    }
134
135    #[must_use]
136    // JSON: ABAC attribute values, declared per deployment in authz policy YAML.
137    pub fn marketplace_floor(&self) -> Option<BTreeMap<String, serde_json::Value>> {
138        let obj = self.payload.get(Self::MARKETPLACE_FLOOR_KEY)?.as_object()?;
139        Some(
140            obj.iter()
141                .map(|(k, v)| (k.clone(), v.clone()))
142                .collect::<BTreeMap<String, serde_json::Value>>(),
143        )
144    }
145}
146
147/// One authorization question, as sent to the configured hook.
148///
149/// This struct crosses the wire as JSON to an out-of-process hook, so every
150/// field added after `user_id` is optional on the wire: a hook built against
151/// an older shape must still parse a newer request, or every governed call
152/// would fail closed while the two sides are deployed separately.
153///
154/// `actor` is the surface the request came through (user, mcp server, agent,
155/// job); its `user_id` MUST equal the top-level `user_id`. Build through
156/// [`AuthzRequest::for_actor`] so the two cannot diverge. `client_id` is the
157/// OAuth client from the validated token, never from a request header.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct AuthzRequest {
160    pub entity: EntityRef,
161    pub user_id: UserId,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub actor: Option<Actor>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub client_id: Option<ClientId>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub access_scope: Option<AccessScope>,
168    #[serde(default)]
169    pub roles: Vec<String>,
170    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
171    // JSON: ABAC attribute values, declared per deployment in authz policy YAML.
172    pub attributes: BTreeMap<String, serde_json::Value>,
173    pub trace_id: TraceId,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub session_id: Option<SessionId>,
176    #[serde(default)]
177    pub context: AuthzContext,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub context_id: Option<ContextId>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub task_id: Option<TaskId>,
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub act_chain: Vec<Actor>,
184}
185
186impl AuthzRequest {
187    #[must_use]
188    pub fn for_actor(mut self, actor: Actor) -> Self {
189        self.user_id = actor.user_id.clone();
190        self.actor = Some(actor);
191        self
192    }
193
194    #[must_use]
195    pub fn actor(&self) -> Actor {
196        self.actor
197            .clone()
198            .unwrap_or_else(|| Actor::user(self.user_id.clone()))
199    }
200
201    // Why: RFC 8693 puts the current actor in the outermost `act` claim;
202    // nested actors are prior delegates.
203    #[must_use]
204    pub fn verified_agent_id(&self) -> Option<&AgentId> {
205        match self.act_chain.first().map(|a| &a.kind) {
206            Some(ActorKind::Agent { agent_id }) => Some(agent_id),
207            _ => None,
208        }
209    }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "decision", rename_all = "lowercase")]
214pub enum AuthzDecision {
215    Allow,
216    Deny { reason: DenyReason, policy: String },
217}