Skip to main content

systemprompt_security/authz/types/
request.rs

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