Skip to main content

systemprompt_security/authz/types/
decision.rs

1//! Authorization decision types and structured deny reasons.
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::fmt;
8
9use serde::{Deserialize, Serialize};
10use systemprompt_identifiers::{McpToolName, PolicyId, SecretPatternId, UserId};
11use thiserror::Error;
12
13use super::entity_ref::EntityRef;
14use super::kinds::RuleType;
15use crate::policy::types::{AccessScope, RateLimitWindow, SecretLocation};
16
17/// Why an [`super::request::AuthzRequest`] was allowed. Carries enough
18/// structure for the audit row to attribute the decision without re-deriving
19/// it.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum MatchedBy {
23    UserAllow,
24    RoleAllow {
25        role: String,
26    },
27    /// Allowed by a rule on an extension-declared subject dimension
28    /// (`rule_type` outside core's `user` / `role`), e.g. a department grant.
29    AttributeAllow {
30        rule_type: RuleType,
31        value: String,
32    },
33    DefaultIncluded,
34    PolicyAllow {
35        policy_id: PolicyId,
36        detail: Cow<'static, str>,
37    },
38}
39
40/// Structured deny rationale.
41///
42/// Variants cover both the user→entity resolver
43/// (`UserDeny`, `RoleDeny`, `NotAssigned`, `UnknownEntity`),
44/// the hook plane (`HookUnavailable`), and the tool-use governance chain
45/// (`SecretLeak`, `ScopeViolation`, `ToolBlocked`, `RateLimitExceeded`). The
46/// human-readable `#[error]` strings double as the `reason` column in the
47/// `governance_decisions` audit row.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum DenyReason {
51    #[error("user {user_id} explicitly denied for {entity}")]
52    UserDeny {
53        entity: EntityRef,
54        user_id: UserId,
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        justification: Option<String>,
57    },
58    #[error("role {role} denied for {entity}")]
59    RoleDeny {
60        entity: EntityRef,
61        role: String,
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        justification: Option<String>,
64    },
65    /// Denied by a rule on an extension-declared subject dimension. `value` is
66    /// the dimension value the subject holds that the rule named, e.g.
67    /// `rule_type = "department"`, `value = "engineering"`.
68    #[error("{rule_type} {value} denied for {entity}")]
69    AttributeDeny {
70        entity: EntityRef,
71        rule_type: RuleType,
72        value: String,
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        justification: Option<String>,
75    },
76    #[error(
77        "{entity}: not assigned to user {user_id} with roles {roles:?} (no allow rule; \
78         default_included = false). Add an allow rule in services/access-control/roles.yaml."
79    )]
80    NotAssigned {
81        entity: EntityRef,
82        user_id: UserId,
83        roles: Vec<String>,
84    },
85    #[error(
86        "{entity}: unknown to access control. Add an entity row via the publish pipeline or \
87         roles.yaml."
88    )]
89    UnknownEntity { entity: EntityRef },
90    #[error("authz hook unavailable for policy {policy}")]
91    HookUnavailable { policy: String },
92    /// Deny issued by an extension authz hook (via `register_authz_hook!`
93    /// or `AppContextBuilder::with_authz_hook`). The outer
94    /// `AuthzDecision::Deny.policy` carries the policy identifier
95    /// (e.g. `"abac.itar"`); `detail` is the human-readable reason.
96    #[error("{detail}")]
97    PolicyViolation {
98        policy: String,
99        detail: Cow<'static, str>,
100    },
101    #[error("secret detected: {pattern_name} at {location:?}")]
102    SecretLeak {
103        pattern_id: SecretPatternId,
104        pattern_name: Cow<'static, str>,
105        location: SecretLocation,
106    },
107    #[error("tool {tool} requires {required} scope")]
108    ScopeViolation {
109        tool: McpToolName,
110        required: AccessScope,
111    },
112    #[error("tool {tool} blocked by list {list_id}")]
113    ToolBlocked { tool: McpToolName, list_id: String },
114    #[error("rate limit {window:?} exceeded; retry after {retry_after_ms}ms")]
115    RateLimitExceeded {
116        window: RateLimitWindow,
117        retry_after_ms: u64,
118    },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(tag = "decision", rename_all = "lowercase")]
123pub enum Decision {
124    Allow { matched_by: MatchedBy },
125    Deny { reason: DenyReason },
126}
127
128impl Decision {
129    #[must_use]
130    pub const fn tag(&self) -> DecisionTag {
131        match self {
132            Self::Allow { .. } => DecisionTag::Allow,
133            Self::Deny { .. } => DecisionTag::Deny,
134        }
135    }
136}
137
138/// Discriminant-only view of [`Decision`] / [`super::request::AuthzDecision`],
139/// bound to the `governance_decisions.decision` column.
140///
141/// Typing the column at the Rust boundary couples it to the SQL CHECK
142/// allow-list; adding a `Decision` variant without extending the constraint
143/// fails the build.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
145#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
146#[serde(rename_all = "lowercase")]
147pub enum DecisionTag {
148    Allow,
149    Deny,
150}
151
152impl DecisionTag {
153    #[must_use]
154    pub const fn as_str(self) -> &'static str {
155        match self {
156            Self::Allow => "allow",
157            Self::Deny => "deny",
158        }
159    }
160}
161
162impl fmt::Display for DecisionTag {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.write_str(self.as_str())
165    }
166}
167
168impl From<&super::request::AuthzDecision> for DecisionTag {
169    fn from(d: &super::request::AuthzDecision) -> Self {
170        match d {
171            super::request::AuthzDecision::Allow => Self::Allow,
172            super::request::AuthzDecision::Deny { .. } => Self::Deny,
173        }
174    }
175}