Skip to main content

systemprompt_security/authz/types/
decision.rs

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