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` or under `services/governance/`) that
5//! declares the role- and department-level rules every instance should boot
6//! with. The bootstrap loader (in `systemprompt-sync`) parses this struct,
7//! hands it to [`super::ingestion::AccessControlIngestionService`], and the
8//! service projects it into `access_control_rules`.
9//!
10//! The contract is one-way (YAML → DB). Per-user overrides
11//! (`rule_type='user'`) are operational state and never appear in this
12//! schema — the loader rejects any rule that has neither `roles:` nor
13//! `departments:`.
14//!
15//! `departments[]` is declarative metadata: the loader validates that every
16//! department referenced by a rule appears in this list (typo guard) but
17//! does not persist the entries — there is no `departments` table.
18
19use serde::{Deserialize, Serialize};
20
21use super::error::AuthzError;
22use super::types::{Access, EntityKind};
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct AccessControlConfig {
27    #[serde(default)]
28    pub departments: Vec<DepartmentEntry>,
29    #[serde(default)]
30    pub rules: Vec<RuleEntry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct DepartmentEntry {
36    pub name: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub description: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub manager_email: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct RuleEntry {
46    pub entity_type: EntityKind,
47    pub entity_id: String,
48    pub access: Access,
49    #[serde(default)]
50    pub roles: Vec<String>,
51    #[serde(default)]
52    pub departments: Vec<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub justification: Option<String>,
55}
56
57impl AccessControlConfig {
58    pub fn validate(&self) -> Result<(), AuthzError> {
59        let mut problems: Vec<String> = Vec::new();
60
61        let mut declared: std::collections::HashSet<&str> =
62            std::collections::HashSet::with_capacity(self.departments.len());
63        for (idx, dept) in self.departments.iter().enumerate() {
64            if dept.name.trim().is_empty() {
65                problems.push(format!("departments[{idx}]: name is empty"));
66                continue;
67            }
68            if !declared.insert(dept.name.as_str()) {
69                problems.push(format!(
70                    "departments[{idx}]: duplicate department name '{}'",
71                    dept.name
72                ));
73            }
74        }
75
76        for (idx, rule) in self.rules.iter().enumerate() {
77            if rule.entity_id.trim().is_empty() {
78                problems.push(format!("rules[{idx}]: entity_id is empty"));
79            }
80            if rule.roles.is_empty() && rule.departments.is_empty() {
81                problems.push(format!(
82                    "rules[{idx}]: must declare at least one of roles[] or departments[] — \
83                     per-user rules belong to runtime state, not YAML"
84                ));
85            }
86            for dept in &rule.departments {
87                if !declared.contains(dept.as_str()) {
88                    problems.push(format!(
89                        "rules[{idx}]: references undeclared department '{dept}' (add it to the \
90                         top-level departments: list)"
91                    ));
92                }
93            }
94            for role in &rule.roles {
95                if role.trim().is_empty() {
96                    problems.push(format!("rules[{idx}]: empty role string"));
97                }
98            }
99        }
100
101        if problems.is_empty() {
102            Ok(())
103        } else {
104            Err(AuthzError::Validation(problems.join("; ")))
105        }
106    }
107}