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
23use serde::{Deserialize, Serialize, Serializer};
24
25use super::error::AuthzError;
26use super::types::{Access, EntityKind};
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct AccessControlConfig {
31    #[serde(default)]
32    pub rules: Vec<RuleEntry>,
33}
34
35#[derive(Debug, Clone)]
36pub enum RuleTarget {
37    Id(String),
38    Match(String),
39}
40
41#[derive(Debug, Clone)]
42pub struct RuleEntry {
43    pub entity_type: EntityKind,
44    pub target: RuleTarget,
45    pub access: Access,
46    pub default_included: bool,
47    pub roles: Vec<String>,
48    pub justification: Option<String>,
49}
50
51#[derive(Deserialize)]
52#[serde(deny_unknown_fields)]
53struct RuleEntryWire {
54    entity_type: EntityKind,
55    #[serde(default)]
56    entity_id: Option<String>,
57    #[serde(default)]
58    entity_match: Option<String>,
59    #[serde(default = "default_allow")]
60    access: Access,
61    #[serde(default)]
62    default_included: bool,
63    #[serde(default)]
64    roles: Vec<String>,
65    #[serde(default)]
66    justification: Option<String>,
67}
68
69const fn default_allow() -> Access {
70    Access::Allow
71}
72
73#[derive(Serialize)]
74struct RuleEntryOut<'a> {
75    entity_type: EntityKind,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    entity_id: Option<&'a str>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    entity_match: Option<&'a str>,
80    access: Access,
81    default_included: bool,
82    roles: &'a [String],
83    #[serde(skip_serializing_if = "Option::is_none")]
84    justification: Option<&'a str>,
85}
86
87impl Serialize for RuleEntry {
88    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
89    where
90        S: Serializer,
91    {
92        let (entity_id, entity_match) = match &self.target {
93            RuleTarget::Id(id) => (Some(id.as_str()), None),
94            RuleTarget::Match(pattern) => (None, Some(pattern.as_str())),
95        };
96        RuleEntryOut {
97            entity_type: self.entity_type,
98            entity_id,
99            entity_match,
100            access: self.access,
101            default_included: self.default_included,
102            roles: &self.roles,
103            justification: self.justification.as_deref(),
104        }
105        .serialize(serializer)
106    }
107}
108
109impl<'de> Deserialize<'de> for RuleEntry {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: serde::Deserializer<'de>,
113    {
114        let wire = RuleEntryWire::deserialize(deserializer)?;
115        let target = match (wire.entity_id, wire.entity_match) {
116            (Some(id), None) => RuleTarget::Id(id),
117            (None, Some(pattern)) => RuleTarget::Match(pattern),
118            (Some(_), Some(_)) => {
119                return Err(serde::de::Error::custom(format!(
120                    "rule for entity_type={} sets both entity_id and entity_match; pick one",
121                    wire.entity_type.as_str()
122                )));
123            },
124            (None, None) => {
125                return Err(serde::de::Error::custom(format!(
126                    "rule for entity_type={} sets neither entity_id nor entity_match",
127                    wire.entity_type.as_str()
128                )));
129            },
130        };
131        Ok(Self {
132            entity_type: wire.entity_type,
133            target,
134            access: wire.access,
135            default_included: wire.default_included,
136            roles: wire.roles,
137            justification: wire.justification,
138        })
139    }
140}
141
142impl AccessControlConfig {
143    pub fn validate(&self) -> Result<(), AuthzError> {
144        let mut problems: Vec<String> = Vec::new();
145
146        for (idx, rule) in self.rules.iter().enumerate() {
147            match &rule.target {
148                RuleTarget::Id(id) if id.trim().is_empty() => {
149                    problems.push(format!("rules[{idx}]: entity_id is empty"));
150                },
151                RuleTarget::Match(pattern) if pattern.trim().is_empty() => {
152                    problems.push(format!("rules[{idx}]: entity_match is empty"));
153                },
154                _ => {},
155            }
156            if rule.roles.is_empty() {
157                problems.push(format!(
158                    "rules[{idx}]: must declare at least one role — per-user rules belong to \
159                     runtime state, not YAML, and attribute-based rules belong in an extension \
160                     hook"
161                ));
162            }
163            for role in &rule.roles {
164                if role.trim().is_empty() {
165                    problems.push(format!("rules[{idx}]: empty role string"));
166                }
167            }
168        }
169
170        if problems.is_empty() {
171            Ok(())
172        } else {
173            Err(AuthzError::Validation(problems.join("; ")))
174        }
175    }
176}