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::{Actor, McpToolName, ModelId, SessionId, TraceId, UserId};
6
7use super::decision::DenyReason;
8use super::entity_ref::EntityRef;
9
10/// Open enforcement-site context attached to an [`AuthzRequest`].
11///
12/// Replaces the previous closed enum so tenants can add their own
13/// enforcement sites (skill execution, order submission, file egress, ...)
14/// without a core change.
15///
16/// `kind` is a dotted-namespaced literal. Core mints three:
17///
18/// - `"none"` — no context (server-attach RBAC, etc).
19/// - `"gateway.invocation"` — payload `{ "model": "..." }`.
20/// - `"mcp.tool_call"` — payload `{ "tool": "..." }`.
21///
22/// Tenants mint their own (e.g. `"acme.order_submission"`) and recognise
23/// them in their hook. Core never interprets `payload`.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct AuthzContext {
26    pub kind: Cow<'static, str>,
27    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
28    pub payload: serde_json::Value,
29}
30
31impl Default for AuthzContext {
32    fn default() -> Self {
33        Self::none()
34    }
35}
36
37impl AuthzContext {
38    pub const NONE_KIND: &'static str = "none";
39    pub const GATEWAY_INVOCATION_KIND: &'static str = "gateway.invocation";
40    pub const MCP_TOOL_CALL_KIND: &'static str = "mcp.tool_call";
41
42    #[must_use]
43    pub const fn none() -> Self {
44        Self {
45            kind: Cow::Borrowed(Self::NONE_KIND),
46            payload: serde_json::Value::Null,
47        }
48    }
49
50    #[must_use]
51    pub fn gateway_invocation(model: &ModelId) -> Self {
52        Self {
53            kind: Cow::Borrowed(Self::GATEWAY_INVOCATION_KIND),
54            payload: serde_json::json!({ "model": model.as_str() }),
55        }
56    }
57
58    #[must_use]
59    pub fn mcp_tool_call(tool: &McpToolName) -> Self {
60        Self {
61            kind: Cow::Borrowed(Self::MCP_TOOL_CALL_KIND),
62            payload: serde_json::json!({ "tool": tool.as_str() }),
63        }
64    }
65
66    /// `kind` must be dotted-namespaced (e.g. `"acme.order_submission"`) so
67    /// kinds from independent extensions cannot collide.
68    #[must_use]
69    pub fn extension(kind: impl Into<Cow<'static, str>>, payload: serde_json::Value) -> Self {
70        Self {
71            kind: kind.into(),
72            payload,
73        }
74    }
75
76    #[must_use]
77    pub fn gateway_invocation_model(&self) -> Option<ModelId> {
78        if self.kind != Self::GATEWAY_INVOCATION_KIND {
79            return None;
80        }
81        self.payload
82            .get("model")
83            .and_then(|v| v.as_str())
84            .map(ModelId::new)
85    }
86
87    #[must_use]
88    pub fn mcp_tool_call_tool(&self) -> Option<McpToolName> {
89        if self.kind != Self::MCP_TOOL_CALL_KIND {
90            return None;
91        }
92        self.payload
93            .get("tool")
94            .and_then(|v| v.as_str())
95            .map(McpToolName::new)
96    }
97
98    #[must_use]
99    pub fn is_none(&self) -> bool {
100        self.kind == Self::NONE_KIND
101    }
102
103    pub const MARKETPLACE_FLOOR_KEY: &'static str = "marketplace.attribute_floor";
104
105    /// The floor is an opaque tenant-namespaced bag the ABAC hook interprets;
106    /// core copies it verbatim. Keyed under [`MARKETPLACE_FLOOR_KEY`] so it
107    /// never collides with the typed `model` / `tool` payload entries, and
108    /// `kind` plus any existing payload are preserved.
109    ///
110    /// [`MARKETPLACE_FLOOR_KEY`]: Self::MARKETPLACE_FLOOR_KEY
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#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct AuthzRequest {
144    pub entity: EntityRef,
145    pub user_id: UserId,
146    #[serde(default)]
147    pub roles: Vec<String>,
148    /// Opaque ABAC attribute bag forwarded from `JwtClaims.attributes`.
149    /// Tenants namespace keys (e.g. `"acme.desk"`, `"boeing.clearance"`);
150    /// core never interprets values.
151    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152    pub attributes: BTreeMap<String, serde_json::Value>,
153    pub trace_id: TraceId,
154    /// Attested session this authorization request was made under, when the
155    /// enforcement site has one (gateway path). Threaded into the audit row's
156    /// `session_id` column; non-session paths (server-attach RBAC, MCP) leave
157    /// it `None`.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub session_id: Option<SessionId>,
160    #[serde(default)]
161    pub context: AuthzContext,
162    /// RFC 8693 delegation lineage forwarded from
163    /// `RequestContext.auth.act_chain`. Empty when no token-exchange chain
164    /// is present.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub act_chain: Vec<Actor>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(tag = "decision", rename_all = "lowercase")]
171pub enum AuthzDecision {
172    Allow,
173    Deny { reason: DenyReason, policy: String },
174}