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    AttributeAllow {
28        rule_type: RuleType,
29        value: String,
30    },
31    DefaultIncluded,
32    PolicyAllow {
33        policy_id: PolicyId,
34        detail: Cow<'static, str>,
35    },
36}
37
38/// Structured deny rationale.
39///
40/// Variants cover both the user→entity resolver
41/// (`UserDeny`, `RoleDeny`, `NotAssigned`, `UnknownEntity`),
42/// the hook plane (`HookUnavailable`), and the tool-use governance chain
43/// (`SecretLeak`, `ScopeViolation`, `ToolBlocked`, `RateLimitExceeded`). The
44/// human-readable `#[error]` strings double as the `reason` column in the
45/// `governance_decisions` audit row.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum DenyReason {
49    #[error("user {user_id} explicitly denied for {entity}")]
50    UserDeny {
51        entity: EntityRef,
52        user_id: UserId,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        justification: Option<String>,
55    },
56    #[error("role {role} denied for {entity}")]
57    RoleDeny {
58        entity: EntityRef,
59        role: String,
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        justification: Option<String>,
62    },
63    #[error("{rule_type} {value} denied for {entity}")]
64    AttributeDeny {
65        entity: EntityRef,
66        rule_type: RuleType,
67        value: String,
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        justification: Option<String>,
70    },
71    #[error(
72        "{entity}: not assigned to user {user_id} with roles {roles:?} (no allow rule; \
73         default_included = false). Add an allow rule in services/access-control/roles.yaml."
74    )]
75    NotAssigned {
76        entity: EntityRef,
77        user_id: UserId,
78        roles: Vec<String>,
79    },
80    #[error(
81        "{entity}: unknown to access control. Add an entity row via the publish pipeline or \
82         roles.yaml."
83    )]
84    UnknownEntity { entity: EntityRef },
85    // Why: `detail` carries the underlying failure so the audit row can tell a
86    // transient database fault from a malformed rule. Without it every fault on
87    // this plane writes a byte-identical row and the cause survives only in a
88    // log line. `serde(default)` so rows written before the field parse back.
89    #[error("authz hook unavailable for policy {policy}: {detail}")]
90    HookUnavailable {
91        policy: String,
92        #[serde(default)]
93        detail: String,
94    },
95    #[error("{detail}")]
96    PolicyViolation {
97        policy: String,
98        detail: Cow<'static, str>,
99    },
100    #[error("secret detected: {pattern_name} at {location}")]
101    SecretLeak {
102        pattern_id: SecretPatternId,
103        pattern_name: Cow<'static, str>,
104        location: SecretLocation,
105    },
106    #[error("tool {tool} requires {required} scope")]
107    ScopeViolation {
108        tool: McpToolName,
109        required: AccessScope,
110    },
111    #[error("tool {tool} blocked by list {list_id}")]
112    ToolBlocked { tool: McpToolName, list_id: String },
113    #[error("rate limit {window:?} exceeded; retry after {retry_after_ms}ms")]
114    RateLimitExceeded {
115        window: RateLimitWindow,
116        retry_after_ms: u64,
117    },
118}
119
120/// Why a governed call was held for a human decision instead of being
121/// allowed or denied outright.
122///
123/// A `Pending` verdict is *not* a refusal: the chain has found nothing wrong
124/// with the call, only that policy requires a named human to authorise it
125/// before it runs. The enforcement point is responsible for parking the call
126/// and resuming it — see the `require_approval` policy.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
128#[serde(tag = "kind", rename_all = "snake_case")]
129pub enum PendingReason {
130    #[error("tool {tool} requires human approval (matched {rule})")]
131    ApprovalRequired { tool: McpToolName, rule: String },
132}
133
134/// The verdict of one policy chain run.
135///
136/// `Warn` is the observability verdict: a policy in `mode: warn` found what it
137/// would normally refuse, the finding is recorded verbatim, and the call
138/// proceeds anyway. It carries the same [`DenyReason`] the enforcing form
139/// would have carried, so a warn row and a deny row are directly comparable —
140/// that is the whole point of warn mode, which exists so tunables can be
141/// adjusted from real traffic instead of guesses.
142///
143/// Every enforcement point must treat `Warn` as an allow. A site that lets it
144/// fall into a deny arm turns warn mode back into enforcement silently, which
145/// is the one failure this type exists to prevent.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(tag = "decision", rename_all = "lowercase")]
148pub enum Decision {
149    Allow { matched_by: MatchedBy },
150    Warn { reason: DenyReason },
151    Deny { reason: DenyReason },
152    Pending { reason: PendingReason },
153}
154
155impl Decision {
156    #[must_use]
157    pub const fn tag(&self) -> DecisionTag {
158        match self {
159            Self::Allow { .. } => DecisionTag::Allow,
160            Self::Warn { .. } => DecisionTag::Warn,
161            Self::Deny { .. } => DecisionTag::Deny,
162            Self::Pending { .. } => DecisionTag::Pending,
163        }
164    }
165
166    // Why: the predicate every enforcement point should use. Matching on
167    // `Allow` alone turns warn mode back into enforcement silently, which is
168    // the one failure the `Warn` variant exists to prevent.
169    #[must_use]
170    pub const fn permits(&self) -> bool {
171        matches!(self, Self::Allow { .. } | Self::Warn { .. })
172    }
173}
174
175/// Discriminant-only view of [`Decision`] / [`super::request::AuthzDecision`],
176/// bound to the `governance_decisions.decision` column.
177///
178/// Typing the column at the Rust boundary couples it to the SQL CHECK
179/// allow-list; adding a `Decision` variant without extending the constraint
180/// fails the build.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
182#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
183#[serde(rename_all = "lowercase")]
184pub enum DecisionTag {
185    Allow,
186    Warn,
187    Deny,
188    Pending,
189}
190
191impl DecisionTag {
192    #[must_use]
193    pub const fn as_str(self) -> &'static str {
194        match self {
195            Self::Allow => "allow",
196            Self::Warn => "warn",
197            Self::Deny => "deny",
198            Self::Pending => "pending",
199        }
200    }
201}
202
203impl fmt::Display for DecisionTag {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.write_str(self.as_str())
206    }
207}
208
209impl From<&super::request::AuthzDecision> for DecisionTag {
210    fn from(d: &super::request::AuthzDecision) -> Self {
211        match d {
212            super::request::AuthzDecision::Allow => Self::Allow,
213            super::request::AuthzDecision::Deny { .. } => Self::Deny,
214        }
215    }
216}