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