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