systemprompt_security/authz/types/
decision.rs1use 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::{EntityKind, RuleType};
15use crate::policy::types::{AccessScope, RateLimitWindow, SecretLocation};
16
17#[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#[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 #[error("{entity_kind:?} id {id:?} is not a valid entity identifier: {detail}")]
86 InvalidEntity {
87 entity_kind: EntityKind,
88 id: String,
89 detail: String,
90 },
91 #[error("authz hook unavailable for policy {policy}: {detail}")]
92 HookUnavailable {
93 policy: String,
94 #[serde(default)]
95 detail: String,
96 },
97 #[error("{detail}")]
98 PolicyViolation {
99 policy: String,
100 detail: Cow<'static, str>,
101 },
102 #[error("secret detected: {pattern_name} at {location}")]
103 SecretLeak {
104 pattern_id: SecretPatternId,
105 pattern_name: Cow<'static, str>,
106 location: SecretLocation,
107 },
108 #[error("tool {tool} requires {required} scope")]
109 ScopeViolation {
110 tool: McpToolName,
111 required: AccessScope,
112 },
113 #[error("tool {tool} blocked by list {list_id}")]
114 ToolBlocked { tool: McpToolName, list_id: String },
115 #[error("rate limit {window:?} exceeded; retry after {retry_after_ms}ms")]
116 RateLimitExceeded {
117 window: RateLimitWindow,
118 retry_after_ms: u64,
119 },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum PendingReason {
132 #[error("tool {tool} requires human approval (matched {rule})")]
133 ApprovalRequired { tool: McpToolName, rule: String },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "decision", rename_all = "lowercase")]
150pub enum Decision {
151 Allow { matched_by: MatchedBy },
152 Warn { reason: DenyReason },
153 Deny { reason: DenyReason },
154 Pending { reason: PendingReason },
155}
156
157impl Decision {
158 #[must_use]
159 pub const fn tag(&self) -> DecisionTag {
160 match self {
161 Self::Allow { .. } => DecisionTag::Allow,
162 Self::Warn { .. } => DecisionTag::Warn,
163 Self::Deny { .. } => DecisionTag::Deny,
164 Self::Pending { .. } => DecisionTag::Pending,
165 }
166 }
167
168 #[must_use]
169 pub const fn permits(&self) -> bool {
170 matches!(self, Self::Allow { .. } | Self::Warn { .. })
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
181#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
182#[serde(rename_all = "lowercase")]
183pub enum DecisionTag {
184 Allow,
185 Warn,
186 Deny,
187 Pending,
188}
189
190impl DecisionTag {
191 #[must_use]
192 pub const fn as_str(self) -> &'static str {
193 match self {
194 Self::Allow => "allow",
195 Self::Warn => "warn",
196 Self::Deny => "deny",
197 Self::Pending => "pending",
198 }
199 }
200}
201
202impl fmt::Display for DecisionTag {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 f.write_str(self.as_str())
205 }
206}
207
208impl From<&super::request::AuthzDecision> for DecisionTag {
209 fn from(d: &super::request::AuthzDecision) -> Self {
210 match d {
211 super::request::AuthzDecision::Allow => Self::Allow,
212 super::request::AuthzDecision::Deny { .. } => Self::Deny,
213 }
214 }
215}