1use std::borrow::Cow;
12use std::fmt;
13use std::str::FromStr;
14
15use serde::{Deserialize, Serialize};
16use systemprompt_identifiers::{
17 Actor, AgentId, HookId, MarketplaceId, McpServerId, McpToolName, ModelId, PluginId, PolicyId,
18 RouteId, RuleId, SecretPatternId, SkillId, TraceId, UserId,
19};
20use thiserror::Error;
21
22use super::error::AuthzError;
23use crate::policy::types::{AccessScope, RateLimitWindow, SecretLocation};
24
25#[derive(
26 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
27)]
28#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
29#[serde(rename_all = "lowercase")]
30pub enum RuleType {
31 User,
32 Role,
33 Department,
34}
35
36impl fmt::Display for RuleType {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(match *self {
39 Self::User => "user",
40 Self::Role => "role",
41 Self::Department => "department",
42 })
43 }
44}
45
46impl FromStr for RuleType {
47 type Err = AuthzError;
48
49 fn from_str(s: &str) -> Result<Self, Self::Err> {
50 match s {
51 "user" => Ok(Self::User),
52 "role" => Ok(Self::Role),
53 "department" => Ok(Self::Department),
54 other => Err(AuthzError::InvalidRuleType(other.to_owned())),
55 }
56 }
57}
58
59#[derive(
60 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
61)]
62#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
63#[serde(rename_all = "lowercase")]
64pub enum Access {
65 Allow,
66 Deny,
67}
68
69impl fmt::Display for Access {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str(match *self {
72 Self::Allow => "allow",
73 Self::Deny => "deny",
74 })
75 }
76}
77
78impl FromStr for Access {
79 type Err = AuthzError;
80
81 fn from_str(s: &str) -> Result<Self, Self::Err> {
82 match s {
83 "allow" => Ok(Self::Allow),
84 "deny" => Ok(Self::Deny),
85 other => Err(AuthzError::InvalidAccess(other.to_owned())),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum EntityKind {
93 GatewayRoute,
94 McpServer,
95 Plugin,
96 Agent,
97 Marketplace,
98 Skill,
99 Hook,
100}
101
102impl EntityKind {
103 pub const fn as_str(self) -> &'static str {
104 match self {
105 Self::GatewayRoute => "gateway_route",
106 Self::McpServer => "mcp_server",
107 Self::Plugin => "plugin",
108 Self::Agent => "agent",
109 Self::Marketplace => "marketplace",
110 Self::Skill => "skill",
111 Self::Hook => "hook",
112 }
113 }
114}
115
116impl FromStr for EntityKind {
117 type Err = AuthzError;
118
119 fn from_str(s: &str) -> Result<Self, Self::Err> {
120 match s {
121 "gateway_route" => Ok(Self::GatewayRoute),
122 "mcp_server" => Ok(Self::McpServer),
123 "plugin" => Ok(Self::Plugin),
124 "agent" => Ok(Self::Agent),
125 "marketplace" => Ok(Self::Marketplace),
126 "skill" => Ok(Self::Skill),
127 "hook" => Ok(Self::Hook),
128 other => Err(AuthzError::Validation(format!(
129 "unknown entity_type: {other}"
130 ))),
131 }
132 }
133}
134
135impl fmt::Display for EntityKind {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 f.write_str(self.as_str())
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)]
142pub struct AccessRule {
143 pub id: RuleId,
144 pub rule_type: RuleType,
145 pub rule_value: String,
146 pub access: Access,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub justification: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct EntityRow {
165 pub kind: EntityKind,
166 pub id: String,
167 pub default_included: bool,
168 pub source: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum MatchedBy {
176 UserAllow,
177 RoleAllow {
178 role: String,
179 },
180 DepartmentAllow {
181 department: String,
182 },
183 DefaultIncluded,
185 PolicyAllow {
187 policy_id: PolicyId,
188 detail: Cow<'static, str>,
189 },
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
201#[serde(tag = "kind", rename_all = "snake_case")]
202pub enum DenyReason {
203 #[error("user {user_id} explicitly denied for {entity}")]
204 UserDeny {
205 entity: EntityRef,
206 user_id: UserId,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 justification: Option<String>,
209 },
210 #[error("role {role} denied for {entity}")]
211 RoleDeny {
212 entity: EntityRef,
213 role: String,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 justification: Option<String>,
216 },
217 #[error("department {department} denied for {entity}")]
218 DepartmentDeny {
219 entity: EntityRef,
220 department: String,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 justification: Option<String>,
223 },
224 #[error(
225 "{entity}: not assigned to user {user_id} with roles {roles:?} (no allow rule; \
226 default_included = false). Add an allow rule in services/access-control/roles.yaml."
227 )]
228 NotAssigned {
229 entity: EntityRef,
230 user_id: UserId,
231 roles: Vec<String>,
232 },
233 #[error(
234 "{entity}: unknown to access control. Add an entity row via the publish pipeline or \
235 roles.yaml."
236 )]
237 UnknownEntity { entity: EntityRef },
238 #[error("authz hook unavailable for policy {policy}")]
239 HookUnavailable { policy: String },
240 #[error("secret detected: {pattern_name} at {location:?}")]
241 SecretLeak {
242 pattern_id: SecretPatternId,
243 pattern_name: Cow<'static, str>,
244 location: SecretLocation,
245 },
246 #[error("tool {tool} requires {required} scope")]
247 ScopeViolation {
248 tool: McpToolName,
249 required: AccessScope,
250 },
251 #[error("tool {tool} blocked by list {list_id}")]
252 ToolBlocked { tool: McpToolName, list_id: String },
253 #[error("rate limit {window:?} exceeded; retry after {retry_after_ms}ms")]
254 RateLimitExceeded {
255 window: RateLimitWindow,
256 retry_after_ms: u64,
257 },
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(tag = "decision", rename_all = "lowercase")]
262pub enum Decision {
263 Allow { matched_by: MatchedBy },
264 Deny { reason: DenyReason },
265}
266
267impl Decision {
268 #[must_use]
269 pub const fn tag(&self) -> DecisionTag {
270 match self {
271 Self::Allow { .. } => DecisionTag::Allow,
272 Self::Deny { .. } => DecisionTag::Deny,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
284#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
285#[serde(rename_all = "lowercase")]
286pub enum DecisionTag {
287 Allow,
288 Deny,
289}
290
291impl DecisionTag {
292 #[must_use]
293 pub const fn as_str(self) -> &'static str {
294 match self {
295 Self::Allow => "allow",
296 Self::Deny => "deny",
297 }
298 }
299}
300
301impl fmt::Display for DecisionTag {
302 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303 f.write_str(self.as_str())
304 }
305}
306
307impl From<&AuthzDecision> for DecisionTag {
308 fn from(d: &AuthzDecision) -> Self {
309 match d {
310 AuthzDecision::Allow => Self::Allow,
311 AuthzDecision::Deny { .. } => Self::Deny,
312 }
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
320pub enum EntityRef {
321 GatewayRoute(RouteId),
322 McpServer(McpServerId),
323 Plugin(PluginId),
324 Agent(AgentId),
325 Marketplace(MarketplaceId),
326 Skill(SkillId),
327 Hook(HookId),
328}
329
330impl EntityRef {
331 #[must_use]
332 pub const fn kind(&self) -> EntityKind {
333 match self {
334 Self::GatewayRoute(_) => EntityKind::GatewayRoute,
335 Self::McpServer(_) => EntityKind::McpServer,
336 Self::Plugin(_) => EntityKind::Plugin,
337 Self::Agent(_) => EntityKind::Agent,
338 Self::Marketplace(_) => EntityKind::Marketplace,
339 Self::Skill(_) => EntityKind::Skill,
340 Self::Hook(_) => EntityKind::Hook,
341 }
342 }
343
344 #[must_use]
345 pub fn id_str(&self) -> &str {
346 match self {
347 Self::GatewayRoute(id) => id.as_str(),
348 Self::McpServer(id) => id.as_str(),
349 Self::Plugin(id) => id.as_str(),
350 Self::Agent(id) => id.as_str(),
351 Self::Marketplace(id) => id.as_str(),
352 Self::Skill(id) => id.as_str(),
353 Self::Hook(id) => id.as_str(),
354 }
355 }
356}
357
358impl fmt::Display for EntityRef {
359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360 write!(f, "{}:{}", self.kind().as_str(), self.id_str())
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
368#[serde(tag = "kind", rename_all = "snake_case")]
369pub enum AuthzContext {
370 GatewayInvocation { model: ModelId },
374 McpToolCall { tool: McpToolName },
376 #[default]
378 None,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct AuthzRequest {
383 pub entity: EntityRef,
384 pub user_id: UserId,
385 #[serde(default)]
386 pub roles: Vec<String>,
387 #[serde(default)]
388 pub department: String,
389 pub trace_id: TraceId,
390 #[serde(default)]
391 pub context: AuthzContext,
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub act_chain: Vec<Actor>,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(tag = "decision", rename_all = "lowercase")]
401pub enum AuthzDecision {
402 Allow,
403 Deny { reason: DenyReason, policy: String },
404}