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.
22///
23/// This mirrors [`AuthzContext`][ctx]: the column is an open vocabulary
24/// validated at the Rust boundary rather than by a SQL `CHECK`, so an
25/// unrecognised-but-well-formed slug is data, not an error.
26///
27/// [sd]: crate::authz::subject::SubjectDimension
28/// [macro]: crate::register_subject_attribute_provider
29/// [ctx]: super::request::AuthzContext
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct RuleType(Cow<'static, str>);
33
34impl RuleType {
35    /// Rule targeting one user by id.
36    pub const USER: Self = Self(Cow::Borrowed("user"));
37    /// Rule targeting every holder of a role.
38    pub const ROLE: Self = Self(Cow::Borrowed("role"));
39
40    /// Mints an extension-owned dimension slug.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`AuthzError::InvalidRuleType`] when the slug is empty, is not
45    /// lowercase `snake_case`, or collides with a core built-in. The shape
46    /// requirement keeps dimensions from independent extensions from
47    /// colliding with each other or with `user` / `role`.
48    pub fn extension(slug: impl Into<Cow<'static, str>>) -> Result<Self, AuthzError> {
49        let slug = slug.into();
50        let well_formed = !slug.is_empty()
51            && !slug.starts_with('_')
52            && !slug.ends_with('_')
53            && slug
54                .chars()
55                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
56        if !well_formed || slug == Self::USER.as_str() || slug == Self::ROLE.as_str() {
57            return Err(AuthzError::InvalidRuleType(slug.into_owned()));
58        }
59        Ok(Self(slug))
60    }
61
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68impl fmt::Display for RuleType {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74impl From<&str> for RuleType {
75    /// A rule type read back from storage is data, not input: an extension
76    /// dimension core has never heard of round-trips instead of poisoning the
77    /// read. Minting a *new* slug goes through [`RuleType::extension`], which
78    /// is where the shape rules are enforced.
79    fn from(s: &str) -> Self {
80        match s {
81            "user" => Self::USER,
82            "role" => Self::ROLE,
83            other => Self(Cow::Owned(other.to_owned())),
84        }
85    }
86}
87
88impl sqlx::Type<sqlx::Postgres> for RuleType {
89    fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
90        <str as sqlx::Type<sqlx::Postgres>>::type_info()
91    }
92
93    fn compatible(ty: &<sqlx::Postgres as sqlx::Database>::TypeInfo) -> bool {
94        <str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
95    }
96}
97
98impl<'q> sqlx::Encode<'q, sqlx::Postgres> for RuleType {
99    fn encode_by_ref(
100        &self,
101        buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer,
102    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
103        <&str as sqlx::Encode<'q, sqlx::Postgres>>::encode(self.as_str(), buf)
104    }
105}
106
107impl<'r> sqlx::Decode<'r, sqlx::Postgres> for RuleType {
108    fn decode(
109        value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
110    ) -> Result<Self, sqlx::error::BoxDynError> {
111        let raw = <&str as sqlx::Decode<'r, sqlx::Postgres>>::decode(value)?;
112        Ok(Self::from(raw))
113    }
114}
115
116#[derive(
117    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, sqlx::Type,
118)]
119#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
120#[serde(rename_all = "lowercase")]
121pub enum Access {
122    Allow,
123    Deny,
124}
125
126impl fmt::Display for Access {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(match *self {
129            Self::Allow => "allow",
130            Self::Deny => "deny",
131        })
132    }
133}
134
135impl FromStr for Access {
136    type Err = AuthzError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        match s {
140            "allow" => Ok(Self::Allow),
141            "deny" => Ok(Self::Deny),
142            other => Err(AuthzError::InvalidAccess(other.to_owned())),
143        }
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum EntityKind {
150    GatewayRoute,
151    McpServer,
152    Plugin,
153    Agent,
154    Marketplace,
155    Skill,
156    Hook,
157    SlackWorkspace,
158    SlackChannel,
159    TeamsTenant,
160    TeamsConversation,
161}
162
163impl EntityKind {
164    pub const fn as_str(self) -> &'static str {
165        match self {
166            Self::GatewayRoute => "gateway_route",
167            Self::McpServer => "mcp_server",
168            Self::Plugin => "plugin",
169            Self::Agent => "agent",
170            Self::Marketplace => "marketplace",
171            Self::Skill => "skill",
172            Self::Hook => "hook",
173            Self::SlackWorkspace => "slack_workspace",
174            Self::SlackChannel => "slack_channel",
175            Self::TeamsTenant => "teams_tenant",
176            Self::TeamsConversation => "teams_conversation",
177        }
178    }
179}
180
181impl FromStr for EntityKind {
182    type Err = AuthzError;
183
184    fn from_str(s: &str) -> Result<Self, Self::Err> {
185        match s {
186            "gateway_route" => Ok(Self::GatewayRoute),
187            "mcp_server" => Ok(Self::McpServer),
188            "plugin" => Ok(Self::Plugin),
189            "agent" => Ok(Self::Agent),
190            "marketplace" => Ok(Self::Marketplace),
191            "skill" => Ok(Self::Skill),
192            "hook" => Ok(Self::Hook),
193            "slack_workspace" => Ok(Self::SlackWorkspace),
194            "slack_channel" => Ok(Self::SlackChannel),
195            "teams_tenant" => Ok(Self::TeamsTenant),
196            "teams_conversation" => Ok(Self::TeamsConversation),
197            other => Err(AuthzError::Validation(format!(
198                "unknown entity_type: {other}"
199            ))),
200        }
201    }
202}
203
204impl fmt::Display for EntityKind {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.write_str(self.as_str())
207    }
208}