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, ContextId, McpToolName, ModelId, SessionId, TaskId, TraceId, UserId,
12};
13
14use super::decision::DenyReason;
15use super::entity_ref::EntityRef;
16
17/// Open enforcement-site context attached to an [`AuthzRequest`].
18///
19/// Replaces the previous closed enum so tenants can add their own
20/// enforcement sites (skill execution, order submission, file egress, ...)
21/// without a core change.
22///
23/// `kind` is a dotted-namespaced literal. Core mints three:
24///
25/// - `"none"` — no context (server-attach RBAC, etc).
26/// - `"gateway.invocation"` — payload `{ "model": "..." }`.
27/// - `"mcp.tool_call"` — payload `{ "tool": "..." }`.
28///
29/// Tenants mint their own (e.g. `"acme.order_submission"`) and recognise
30/// them in their hook. Core never interprets `payload`.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct AuthzContext {
33    pub kind: Cow<'static, str>,
34    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
35    pub payload: serde_json::Value,
36}
37
38impl Default for AuthzContext {
39    fn default() -> Self {
40        Self::none()
41    }
42}
43
44impl AuthzContext {
45    pub const NONE_KIND: &'static str = "none";
46    pub const GATEWAY_INVOCATION_KIND: &'static str = "gateway.invocation";
47    pub const MCP_TOOL_CALL_KIND: &'static str = "mcp.tool_call";
48
49    #[must_use]
50    pub const fn none() -> Self {
51        Self {
52            kind: Cow::Borrowed(Self::NONE_KIND),
53            payload: serde_json::Value::Null,
54        }
55    }
56
57    #[must_use]
58    pub fn gateway_invocation(model: &ModelId) -> Self {
59        Self {
60            kind: Cow::Borrowed(Self::GATEWAY_INVOCATION_KIND),
61            payload: serde_json::json!({ "model": model.as_str() }),
62        }
63    }
64
65    #[must_use]
66    pub fn mcp_tool_call(tool: &McpToolName) -> Self {
67        Self {
68            kind: Cow::Borrowed(Self::MCP_TOOL_CALL_KIND),
69            payload: serde_json::json!({ "tool": tool.as_str() }),
70        }
71    }
72
73    /// `kind` must be dotted-namespaced (e.g. `"acme.order_submission"`) so
74    /// kinds from independent extensions cannot collide.
75    #[must_use]
76    pub fn extension(kind: impl Into<Cow<'static, str>>, payload: serde_json::Value) -> Self {
77        Self {
78            kind: kind.into(),
79            payload,
80        }
81    }
82
83    #[must_use]
84    pub fn gateway_invocation_model(&self) -> Option<ModelId> {
85        if self.kind != Self::GATEWAY_INVOCATION_KIND {
86            return None;
87        }
88        self.payload
89            .get("model")
90            .and_then(|v| v.as_str())
91            .map(ModelId::new)
92    }
93
94    #[must_use]
95    pub fn mcp_tool_call_tool(&self) -> Option<McpToolName> {
96        if self.kind != Self::MCP_TOOL_CALL_KIND {
97            return None;
98        }
99        self.payload
100            .get("tool")
101            .and_then(|v| v.as_str())
102            .map(McpToolName::new)
103    }
104
105    #[must_use]
106    pub fn is_none(&self) -> bool {
107        self.kind == Self::NONE_KIND
108    }
109
110    pub const MARKETPLACE_FLOOR_KEY: &'static str = "marketplace.attribute_floor";
111
112    /// The floor is an opaque tenant-namespaced bag the ABAC hook interprets;
113    /// core copies it verbatim. Keyed under [`MARKETPLACE_FLOOR_KEY`] so it
114    /// never collides with the typed `model` / `tool` payload entries, and
115    /// `kind` plus any existing payload are preserved.
116    ///
117    /// [`MARKETPLACE_FLOOR_KEY`]: Self::MARKETPLACE_FLOOR_KEY
118    #[must_use]
119    pub fn with_marketplace_floor(&self, floor: &BTreeMap<String, serde_json::Value>) -> Self {
120        let mut payload = match self.payload.clone() {
121            serde_json::Value::Object(map) => map,
122            _ => serde_json::Map::new(),
123        };
124        let floor_value = floor
125            .iter()
126            .map(|(k, v)| (k.clone(), v.clone()))
127            .collect::<serde_json::Map<String, serde_json::Value>>();
128        payload.insert(
129            Self::MARKETPLACE_FLOOR_KEY.to_owned(),
130            serde_json::Value::Object(floor_value),
131        );
132        Self {
133            kind: self.kind.clone(),
134            payload: serde_json::Value::Object(payload),
135        }
136    }
137
138    #[must_use]
139    pub fn marketplace_floor(&self) -> Option<BTreeMap<String, serde_json::Value>> {
140        let obj = self.payload.get(Self::MARKETPLACE_FLOOR_KEY)?.as_object()?;
141        Some(
142            obj.iter()
143                .map(|(k, v)| (k.clone(), v.clone()))
144                .collect::<BTreeMap<String, serde_json::Value>>(),
145        )
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct AuthzRequest {
151    pub entity: EntityRef,
152    pub user_id: UserId,
153    #[serde(default)]
154    pub roles: Vec<String>,
155    /// Opaque ABAC attribute bag forwarded from `JwtClaims.attributes`.
156    /// Tenants namespace keys (e.g. `"acme.desk"`, `"boeing.clearance"`);
157    /// core never interprets values.
158    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
159    pub attributes: BTreeMap<String, serde_json::Value>,
160    pub trace_id: TraceId,
161    /// Attested session this authorization request was made under, when the
162    /// enforcement site has one (gateway path). Threaded into the audit row's
163    /// `session_id` column; non-session paths (server-attach RBAC, MCP) leave
164    /// it `None`.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub session_id: Option<SessionId>,
167    #[serde(default)]
168    pub context: AuthzContext,
169    /// A2A conversation the enforcement site is acting within, when it has
170    /// one (gateway-derived conversation, MCP tool-call execution context,
171    /// messaging). Threaded into the audit row's `context_id` column so agent
172    /// conversations reconstruct by key instead of user+time-window joins.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub context_id: Option<ContextId>,
175    /// A2A task the enforcement site is acting within, when one exists
176    /// (internal agent tool-calls). Threaded into the audit row's `task_id`
177    /// column.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub task_id: Option<TaskId>,
180    /// RFC 8693 delegation lineage forwarded from
181    /// `RequestContext.auth.act_chain`. Empty when no token-exchange chain
182    /// is present.
183    #[serde(default, skip_serializing_if = "Vec::is_empty")]
184    pub act_chain: Vec<Actor>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(tag = "decision", rename_all = "lowercase")]
189pub enum AuthzDecision {
190    Allow,
191    Deny { reason: DenyReason, policy: String },
192}