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