Skip to main content

systemprompt_security/authz/
config.rs

1//! YAML schema for declarative access-control baselines.
2//!
3//! A deployment commits an [`AccessControlConfig`] (typically at
4//! `services/access-control/*.yaml`) that declares the role-level rules every
5//! instance should boot with. A loader parses this struct, hands it to
6//! [`super::ingestion::AccessControlIngestionService`], and the service
7//! projects it into `access_control_entities` + `access_control_rules`.
8//!
9//! Each rule names its subject set in exactly one of two ways:
10//!
11//! - `entity_id` — a literal catalog id; the loader self-materialises the
12//!   entity row, so the grant survives a clean install even if nothing else
13//!   registered the entity yet.
14//! - `entity_match` — a `*`-glob expanded against the entities already present
15//!   in the catalog for that [`EntityKind`]; one rule per matched id. The glob
16//!   never creates entities — it only grants ones a prior pass materialised.
17//!
18//! The contract is one-way (YAML → DB). Per-user overrides (`rule_type='user'`)
19//! are operational state and never appear here — the loader rejects any rule
20//! with no `roles:` set. Per-tenant attribute rules live in extension-owned
21//! tables and are evaluated by an extension `AuthzDecisionHook`.
22//!
23//! Copyright (c) systemprompt.io — Business Source License 1.1.
24//! See <https://systemprompt.io> for licensing details.
25
26use serde::{Deserialize, Serialize, Serializer};
27
28use super::error::AuthzError;
29use super::types::{Access, EntityKind};
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct AccessControlConfig {
34    #[serde(default)]
35    pub rules: Vec<RuleEntry>,
36}
37
38#[derive(Debug, Clone)]
39pub enum RuleTarget {
40    Id(String),
41    Match(String),
42}
43
44#[derive(Debug, Clone)]
45pub struct RuleEntry {
46    pub entity_type: EntityKind,
47    pub target: RuleTarget,
48    pub access: Access,
49    pub default_included: bool,
50    pub roles: Vec<String>,
51    pub justification: Option<String>,
52}
53
54#[derive(Deserialize)]
55#[serde(deny_unknown_fields)]
56struct RuleEntryWire {
57    entity_type: EntityKind,
58    #[serde(default)]
59    entity_id: Option<String>,
60    #[serde(default)]
61    entity_match: Option<String>,
62    #[serde(default = "default_allow")]
63    access: Access,
64    #[serde(default)]
65    default_included: bool,
66    #[serde(default)]
67    roles: Vec<String>,
68    #[serde(default)]
69    justification: Option<String>,
70}
71
72const fn default_allow() -> Access {
73    Access::Allow
74}
75
76#[derive(Serialize)]
77struct RuleEntryOut<'a> {
78    entity_type: EntityKind,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    entity_id: Option<&'a str>,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    entity_match: Option<&'a str>,
83    access: Access,
84    default_included: bool,
85    roles: &'a [String],
86    #[serde(skip_serializing_if = "Option::is_none")]
87    justification: Option<&'a str>,
88}
89
90impl Serialize for RuleEntry {
91    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
92    where
93        S: Serializer,
94    {
95        let (entity_id, entity_match) = match &self.target {
96            RuleTarget::Id(id) => (Some(id.as_str()), None),
97            RuleTarget::Match(pattern) => (None, Some(pattern.as_str())),
98        };
99        RuleEntryOut {
100            entity_type: self.entity_type,
101            entity_id,
102            entity_match,
103            access: self.access,
104            default_included: self.default_included,
105            roles: &self.roles,
106            justification: self.justification.as_deref(),
107        }
108        .serialize(serializer)
109    }
110}
111
112impl<'de> Deserialize<'de> for RuleEntry {
113    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114    where
115        D: serde::Deserializer<'de>,
116    {
117        let wire = RuleEntryWire::deserialize(deserializer)?;
118        let target = match (wire.entity_id, wire.entity_match) {
119            (Some(id), None) => RuleTarget::Id(id),
120            (None, Some(pattern)) => RuleTarget::Match(pattern),
121            (Some(_), Some(_)) => {
122                return Err(serde::de::Error::custom(format!(
123                    "rule for entity_type={} sets both entity_id and entity_match; pick one",
124                    wire.entity_type.as_str()
125                )));
126            },
127            (None, None) => {
128                return Err(serde::de::Error::custom(format!(
129                    "rule for entity_type={} sets neither entity_id nor entity_match",
130                    wire.entity_type.as_str()
131                )));
132            },
133        };
134        Ok(Self {
135            entity_type: wire.entity_type,
136            target,
137            access: wire.access,
138            default_included: wire.default_included,
139            roles: wire.roles,
140            justification: wire.justification,
141        })
142    }
143}
144
145impl AccessControlConfig {
146    pub fn validate(&self) -> Result<(), AuthzError> {
147        let mut problems: Vec<String> = Vec::new();
148
149        for (idx, rule) in self.rules.iter().enumerate() {
150            match &rule.target {
151                RuleTarget::Id(id) if id.trim().is_empty() => {
152                    problems.push(format!("rules[{idx}]: entity_id is empty"));
153                },
154                RuleTarget::Match(pattern) if pattern.trim().is_empty() => {
155                    problems.push(format!("rules[{idx}]: entity_match is empty"));
156                },
157                _ => {},
158            }
159            if rule.roles.is_empty() {
160                problems.push(format!(
161                    "rules[{idx}]: must declare at least one role — per-user rules belong to \
162                     runtime state, not YAML, and attribute-based rules belong in an extension \
163                     hook"
164                ));
165            }
166            for role in &rule.roles {
167                if role.trim().is_empty() {
168                    problems.push(format!("rules[{idx}]: empty role string"));
169                }
170            }
171        }
172
173        if problems.is_empty() {
174            Ok(())
175        } else {
176            Err(AuthzError::Validation(problems.join("; ")))
177        }
178    }
179}