Skip to main content

systemprompt_security/authz/types/
kinds.rs

1//! Rule and entity kind tags with parse/display.
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;
8use std::str::FromStr;
9
10use serde::{Deserialize, Serialize};
11
12use crate::authz::error::AuthzError;
13
14/// Open subject-dimension vocabulary bound to `access_control_rules.rule_type`.
15///
16/// Core mints exactly two: [`RuleType::USER`] and [`RuleType::ROLE`]. Every
17/// other dimension (department, cost centre, clearance, ...) is an extension
18/// concern, minted with [`RuleType::extension`] and taught to the resolver via
19/// a [`SubjectDimension`][sd] registered by
20/// [`register_subject_attribute_provider!`][macro]. Core never interprets an
21/// extension slug. [`RuleType::extension_static`] mints a literal slug in a
22/// `const`, so a malformed one fails the build instead of a runtime check.
23///
24/// This mirrors [`AuthzContext`][ctx]: the column is an open vocabulary
25/// validated at the Rust boundary rather than by a SQL `CHECK`, so an
26/// unrecognised-but-well-formed slug is data, not an error.
27///
28/// [sd]: crate::authz::subject::SubjectDimension
29/// [macro]: crate::register_subject_attribute_provider
30/// [ctx]: super::request::AuthzContext
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32#[serde(transparent)]
33pub struct RuleType(Cow<'static, str>);
34
35impl RuleType {
36    pub const USER: Self = Self(Cow::Borrowed("user"));
37    pub const ROLE: Self = Self(Cow::Borrowed("role"));
38
39    pub fn extension(slug: impl Into<Cow<'static, str>>) -> Result<Self, AuthzError> {
40        let slug = slug.into();
41        if !is_extension_slug(slug.as_bytes()) {
42            return Err(AuthzError::InvalidRuleType(slug.into_owned()));
43        }
44        Ok(Self(slug))
45    }
46
47    #[must_use]
48    pub const fn extension_static(slug: &'static str) -> Self {
49        assert!(
50            is_extension_slug(slug.as_bytes()),
51            "extension rule type must be [a-z0-9_], not edged by `_`, and not `user` or `role`"
52        );
53        Self(Cow::Borrowed(slug))
54    }
55
56    #[must_use]
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60}
61
62const fn is_extension_slug(slug: &[u8]) -> bool {
63    if matches!(slug, [] | [b'_', ..] | [.., b'_'])
64        || bytes_eq(slug, b"user")
65        || bytes_eq(slug, b"role")
66    {
67        return false;
68    }
69    let mut rest = slug;
70    while let [byte, tail @ ..] = rest {
71        if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') {
72            return false;
73        }
74        rest = tail;
75    }
76    true
77}
78
79const fn bytes_eq(left: &[u8], right: &[u8]) -> bool {
80    if left.len() != right.len() {
81        return false;
82    }
83    let (mut left, mut right) = (left, right);
84    while let ([a, left_tail @ ..], [b, right_tail @ ..]) = (left, right) {
85        if *a != *b {
86            return false;
87        }
88        left = left_tail;
89        right = right_tail;
90    }
91    true
92}
93
94impl fmt::Display for RuleType {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(self.as_str())
97    }
98}
99
100impl From<&str> for RuleType {
101    fn from(s: &str) -> Self {
102        match s {
103            "user" => Self::USER,
104            "role" => Self::ROLE,
105            other => Self(Cow::Owned(other.to_owned())),
106        }
107    }
108}
109
110impl sqlx::Type<sqlx::Postgres> for RuleType {
111    fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
112        <str as sqlx::Type<sqlx::Postgres>>::type_info()
113    }
114
115    fn compatible(ty: &<sqlx::Postgres as sqlx::Database>::TypeInfo) -> bool {
116        <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
117    }
118}
119
120impl<'q> sqlx::Encode<'q, sqlx::Postgres> for RuleType {
121    fn encode_by_ref(
122        &self,
123        buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer,
124    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
125        <&str as sqlx::Encode<'q, sqlx::Postgres>>::encode(self.as_str(), buf)
126    }
127}
128
129impl<'r> sqlx::Decode<'r, sqlx::Postgres> for RuleType {
130    fn decode(
131        value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
132    ) -> Result<Self, sqlx::error::BoxDynError> {
133        let raw = <&str as sqlx::Decode<'r, sqlx::Postgres>>::decode(value)?;
134        Ok(Self::from(raw))
135    }
136}
137
138#[derive(
139    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
140)]
141#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
142#[serde(rename_all = "lowercase")]
143pub enum Access {
144    Allow,
145    Deny,
146}
147
148impl fmt::Display for Access {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(match *self {
151            Self::Allow => "allow",
152            Self::Deny => "deny",
153        })
154    }
155}
156
157impl FromStr for Access {
158    type Err = AuthzError;
159
160    fn from_str(s: &str) -> Result<Self, Self::Err> {
161        match s {
162            "allow" => Ok(Self::Allow),
163            "deny" => Ok(Self::Deny),
164            other => Err(AuthzError::InvalidAccess(other.to_owned())),
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum EntityKind {
172    GatewayRoute,
173    McpServer,
174    Plugin,
175    Agent,
176    Marketplace,
177    Skill,
178    Hook,
179    SlackWorkspace,
180    SlackChannel,
181    TeamsTenant,
182    TeamsConversation,
183}
184
185impl EntityKind {
186    pub const ALL: &'static [Self] = &[
187        Self::GatewayRoute,
188        Self::McpServer,
189        Self::Plugin,
190        Self::Agent,
191        Self::Marketplace,
192        Self::Skill,
193        Self::Hook,
194        Self::SlackWorkspace,
195        Self::SlackChannel,
196        Self::TeamsTenant,
197        Self::TeamsConversation,
198    ];
199
200    pub const fn as_str(self) -> &'static str {
201        match self {
202            Self::GatewayRoute => "gateway_route",
203            Self::McpServer => "mcp_server",
204            Self::Plugin => "plugin",
205            Self::Agent => "agent",
206            Self::Marketplace => "marketplace",
207            Self::Skill => "skill",
208            Self::Hook => "hook",
209            Self::SlackWorkspace => "slack_workspace",
210            Self::SlackChannel => "slack_channel",
211            Self::TeamsTenant => "teams_tenant",
212            Self::TeamsConversation => "teams_conversation",
213        }
214    }
215}
216
217impl FromStr for EntityKind {
218    type Err = AuthzError;
219
220    fn from_str(s: &str) -> Result<Self, Self::Err> {
221        match s {
222            "gateway_route" => Ok(Self::GatewayRoute),
223            "mcp_server" => Ok(Self::McpServer),
224            "plugin" => Ok(Self::Plugin),
225            "agent" => Ok(Self::Agent),
226            "marketplace" => Ok(Self::Marketplace),
227            "skill" => Ok(Self::Skill),
228            "hook" => Ok(Self::Hook),
229            "slack_workspace" => Ok(Self::SlackWorkspace),
230            "slack_channel" => Ok(Self::SlackChannel),
231            "teams_tenant" => Ok(Self::TeamsTenant),
232            "teams_conversation" => Ok(Self::TeamsConversation),
233            other => Err(AuthzError::UnknownEntityKind(other.to_owned())),
234        }
235    }
236}
237
238impl fmt::Display for EntityKind {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        f.write_str(self.as_str())
241    }
242}
243
244impl sqlx::Type<sqlx::Postgres> for EntityKind {
245    fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
246        <str as sqlx::Type<sqlx::Postgres>>::type_info()
247    }
248
249    fn compatible(ty: &<sqlx::Postgres as sqlx::Database>::TypeInfo) -> bool {
250        <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
251    }
252}
253
254impl<'q> sqlx::Encode<'q, sqlx::Postgres> for EntityKind {
255    fn encode_by_ref(
256        &self,
257        buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer,
258    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
259        <&str as sqlx::Encode<'q, sqlx::Postgres>>::encode(self.as_str(), buf)
260    }
261}
262
263impl<'r> sqlx::Decode<'r, sqlx::Postgres> for EntityKind {
264    fn decode(
265        value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
266    ) -> Result<Self, sqlx::error::BoxDynError> {
267        let raw = <&str as sqlx::Decode<'r, sqlx::Postgres>>::decode(value)?;
268        Ok(Self::from_str(raw)?)
269    }
270}