Skip to main content

systemprompt_security/authz/
types.rs

1//! Wire and storage types for authorization decisions.
2//!
3//! Types fall into two groups:
4//!
5//! 1. **Storage** — [`RuleType`], [`Access`], [`AccessRule`] map to columns in
6//!    `access_control_rules`. They round-trip through serde and sqlx.
7//! 2. **Decision** — [`Decision`] is the in-process resolver output;
8//!    [`AuthzRequest`] / [`AuthzDecision`] are the webhook wire format sent to
9//!    and parsed back from extension hook handlers.
10
11use std::borrow::Cow;
12use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16use systemprompt_identifiers::{
17    Actor, AgentId, HookId, MarketplaceId, McpServerId, McpToolName, ModelId, PluginId, PolicyId,
18    RouteId, RuleId, SecretPatternId, SkillId, TraceId, UserId,
19};
20use thiserror::Error;
21
22use super::error::AuthzError;
23use crate::policy::types::{AccessScope, RateLimitWindow, SecretLocation};
24
25#[derive(
26    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
27)]
28#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
29#[serde(rename_all = "lowercase")]
30pub enum RuleType {
31    User,
32    Role,
33    Department,
34}
35
36impl fmt::Display for RuleType {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(match *self {
39            Self::User => "user",
40            Self::Role => "role",
41            Self::Department => "department",
42        })
43    }
44}
45
46impl FromStr for RuleType {
47    type Err = AuthzError;
48
49    fn from_str(s: &str) -> Result<Self, Self::Err> {
50        match s {
51            "user" => Ok(Self::User),
52            "role" => Ok(Self::Role),
53            "department" => Ok(Self::Department),
54            other => Err(AuthzError::InvalidRuleType(other.to_owned())),
55        }
56    }
57}
58
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
61)]
62#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
63#[serde(rename_all = "lowercase")]
64pub enum Access {
65    Allow,
66    Deny,
67}
68
69impl fmt::Display for Access {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(match *self {
72            Self::Allow => "allow",
73            Self::Deny => "deny",
74        })
75    }
76}
77
78impl FromStr for Access {
79    type Err = AuthzError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        match s {
83            "allow" => Ok(Self::Allow),
84            "deny" => Ok(Self::Deny),
85            other => Err(AuthzError::InvalidAccess(other.to_owned())),
86        }
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum EntityKind {
93    GatewayRoute,
94    McpServer,
95    Plugin,
96    Agent,
97    Marketplace,
98    Skill,
99    Hook,
100}
101
102impl EntityKind {
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::GatewayRoute => "gateway_route",
106            Self::McpServer => "mcp_server",
107            Self::Plugin => "plugin",
108            Self::Agent => "agent",
109            Self::Marketplace => "marketplace",
110            Self::Skill => "skill",
111            Self::Hook => "hook",
112        }
113    }
114}
115
116impl FromStr for EntityKind {
117    type Err = AuthzError;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        match s {
121            "gateway_route" => Ok(Self::GatewayRoute),
122            "mcp_server" => Ok(Self::McpServer),
123            "plugin" => Ok(Self::Plugin),
124            "agent" => Ok(Self::Agent),
125            "marketplace" => Ok(Self::Marketplace),
126            "skill" => Ok(Self::Skill),
127            "hook" => Ok(Self::Hook),
128            other => Err(AuthzError::Validation(format!(
129                "unknown entity_type: {other}"
130            ))),
131        }
132    }
133}
134
135impl fmt::Display for EntityKind {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(self.as_str())
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
142pub struct AccessRule {
143    pub id: RuleId,
144    pub rule_type: RuleType,
145    pub rule_value: String,
146    pub access: Access,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub justification: Option<String>,
149}
150
151/// One row from `access_control_entities`.
152///
153/// Owns the per-entity `default_included` flag and a provenance string
154/// identifying which loader pass first registered the entity
155/// (`"profile:<name>"`, `"roles.yaml"`, `"departments.yaml"`, or
156/// `"bootstrap:*"` for rows promoted from older schemas by a migration).
157/// Callers pair this with [`AccessRule`]s from
158/// `access_control_rules` and hand both to [`super::resolver::resolve`].
159///
160/// A `None` lookup result means the entity is unknown to access control and
161/// the resolver returns [`DenyReason::UnknownEntity`] rather than the generic
162/// `NotAssigned`.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct EntityRow {
165    pub kind: EntityKind,
166    pub id: String,
167    pub default_included: bool,
168    pub source: String,
169}
170
171/// Why an [`AuthzRequest`] was allowed. Carries enough structure for the
172/// audit row to attribute the decision without re-deriving it.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum MatchedBy {
176    UserAllow,
177    RoleAllow {
178        role: String,
179    },
180    DepartmentAllow {
181        department: String,
182    },
183    /// No matching rule, but the entity's `default_included` flag was set.
184    DefaultIncluded,
185    /// Allowed by a named tool-use governance policy (secret scan, etc).
186    PolicyAllow {
187        policy_id: PolicyId,
188        detail: Cow<'static, str>,
189    },
190}
191
192/// Structured deny rationale.
193///
194/// Variants cover both the user→entity resolver
195/// (`UserDeny`, `RoleDeny`, `DepartmentDeny`, `NotAssigned`, `UnknownEntity`),
196/// the hook plane (`HookUnavailable`), and the tool-use governance chain
197/// (`SecretLeak`, `ScopeViolation`, `ToolBlocked`, `RateLimitExceeded`). The
198/// human-readable `#[error]` strings double as the `reason` column in the
199/// `governance_decisions` audit row.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
201#[serde(tag = "kind", rename_all = "snake_case")]
202pub enum DenyReason {
203    #[error("user {user_id} explicitly denied for {entity}")]
204    UserDeny {
205        entity: EntityRef,
206        user_id: UserId,
207        #[serde(default, skip_serializing_if = "Option::is_none")]
208        justification: Option<String>,
209    },
210    #[error("role {role} denied for {entity}")]
211    RoleDeny {
212        entity: EntityRef,
213        role: String,
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        justification: Option<String>,
216    },
217    #[error("department {department} denied for {entity}")]
218    DepartmentDeny {
219        entity: EntityRef,
220        department: String,
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        justification: Option<String>,
223    },
224    #[error(
225        "{entity}: not assigned to user {user_id} with roles {roles:?} (no allow rule; \
226         default_included = false). Add an allow rule in services/access-control/roles.yaml."
227    )]
228    NotAssigned {
229        entity: EntityRef,
230        user_id: UserId,
231        roles: Vec<String>,
232    },
233    #[error(
234        "{entity}: unknown to access control. Add an entity row via the publish pipeline or \
235         roles.yaml."
236    )]
237    UnknownEntity { entity: EntityRef },
238    #[error("authz hook unavailable for policy {policy}")]
239    HookUnavailable { policy: String },
240    #[error("secret detected: {pattern_name} at {location:?}")]
241    SecretLeak {
242        pattern_id: SecretPatternId,
243        pattern_name: Cow<'static, str>,
244        location: SecretLocation,
245    },
246    #[error("tool {tool} requires {required} scope")]
247    ScopeViolation {
248        tool: McpToolName,
249        required: AccessScope,
250    },
251    #[error("tool {tool} blocked by list {list_id}")]
252    ToolBlocked { tool: McpToolName, list_id: String },
253    #[error("rate limit {window:?} exceeded; retry after {retry_after_ms}ms")]
254    RateLimitExceeded {
255        window: RateLimitWindow,
256        retry_after_ms: u64,
257    },
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(tag = "decision", rename_all = "lowercase")]
262pub enum Decision {
263    Allow { matched_by: MatchedBy },
264    Deny { reason: DenyReason },
265}
266
267impl Decision {
268    #[must_use]
269    pub const fn tag(&self) -> DecisionTag {
270        match self {
271            Self::Allow { .. } => DecisionTag::Allow,
272            Self::Deny { .. } => DecisionTag::Deny,
273        }
274    }
275}
276
277/// Discriminant-only view of [`Decision`] / [`AuthzDecision`], bound to the
278/// `governance_decisions.decision` column.
279///
280/// Typing the column at the Rust boundary couples it to the SQL CHECK
281/// allow-list; adding a `Decision` variant without extending the constraint
282/// fails the build.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
284#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
285#[serde(rename_all = "lowercase")]
286pub enum DecisionTag {
287    Allow,
288    Deny,
289}
290
291impl DecisionTag {
292    #[must_use]
293    pub const fn as_str(self) -> &'static str {
294        match self {
295            Self::Allow => "allow",
296            Self::Deny => "deny",
297        }
298    }
299}
300
301impl fmt::Display for DecisionTag {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        f.write_str(self.as_str())
304    }
305}
306
307impl From<&AuthzDecision> for DecisionTag {
308    fn from(d: &AuthzDecision) -> Self {
309        match d {
310            AuthzDecision::Allow => Self::Allow,
311            AuthzDecision::Deny { .. } => Self::Deny,
312        }
313    }
314}
315
316/// Tagged-union reference to an authz target. Bundles the discriminator
317/// (`EntityKind`) and the typed id so they can never drift apart on the wire.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
320pub enum EntityRef {
321    GatewayRoute(RouteId),
322    McpServer(McpServerId),
323    Plugin(PluginId),
324    Agent(AgentId),
325    Marketplace(MarketplaceId),
326    Skill(SkillId),
327    Hook(HookId),
328}
329
330impl EntityRef {
331    #[must_use]
332    pub const fn kind(&self) -> EntityKind {
333        match self {
334            Self::GatewayRoute(_) => EntityKind::GatewayRoute,
335            Self::McpServer(_) => EntityKind::McpServer,
336            Self::Plugin(_) => EntityKind::Plugin,
337            Self::Agent(_) => EntityKind::Agent,
338            Self::Marketplace(_) => EntityKind::Marketplace,
339            Self::Skill(_) => EntityKind::Skill,
340            Self::Hook(_) => EntityKind::Hook,
341        }
342    }
343
344    #[must_use]
345    pub fn id_str(&self) -> &str {
346        match self {
347            Self::GatewayRoute(id) => id.as_str(),
348            Self::McpServer(id) => id.as_str(),
349            Self::Plugin(id) => id.as_str(),
350            Self::Agent(id) => id.as_str(),
351            Self::Marketplace(id) => id.as_str(),
352            Self::Skill(id) => id.as_str(),
353            Self::Hook(id) => id.as_str(),
354        }
355    }
356}
357
358impl fmt::Display for EntityRef {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        write!(f, "{}:{}", self.kind().as_str(), self.id_str())
361    }
362}
363
364/// Typed per-request context forwarded to the authz hook. The variant is
365/// the type of enforcement site that fired; downstream policy handlers
366/// pattern-match instead of poking at `serde_json::Value`.
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
368#[serde(tag = "kind", rename_all = "snake_case")]
369pub enum AuthzContext {
370    /// Gateway `/v1/messages` invocation — carries the model literal the
371    /// client requested (the route in `entity` already encodes routing
372    /// policy, but the literal is needed for audit and downstream rules).
373    GatewayInvocation { model: ModelId },
374    /// MCP tool call about to be dispatched.
375    McpToolCall { tool: McpToolName },
376    /// No context (RBAC server-attach checks, etc).
377    #[default]
378    None,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct AuthzRequest {
383    pub entity: EntityRef,
384    pub user_id: UserId,
385    #[serde(default)]
386    pub roles: Vec<String>,
387    #[serde(default)]
388    pub department: String,
389    pub trace_id: TraceId,
390    #[serde(default)]
391    pub context: AuthzContext,
392    /// RFC 8693 delegation lineage forwarded from
393    /// `RequestContext.auth.act_chain`. Empty when no token-exchange chain
394    /// is present.
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub act_chain: Vec<Actor>,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(tag = "decision", rename_all = "lowercase")]
401pub enum AuthzDecision {
402    Allow,
403    Deny { reason: DenyReason, policy: String },
404}