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-level rules every instance should boot with. The
6//! bootstrap loader (in `systemprompt-sync`) parses this struct, hands it
7//! to [`super::ingestion::AccessControlIngestionService`], and the service
8//! 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 no `roles:` set.
13//!
14//! Per-tenant attribute-based rules (department, clearance, jurisdiction,
15//! ...) are NOT modelled here: they live in extension-owned tables and
16//! are evaluated by an extension `AuthzDecisionHook` composed alongside
17//! the core resolver via [`super::CompositeAuthzHook`].
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 rules: Vec<RuleEntry>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct RuleEntry {
34    pub entity_type: EntityKind,
35    pub entity_id: String,
36    pub access: Access,
37    #[serde(default)]
38    pub roles: Vec<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub justification: Option<String>,
41}
42
43impl AccessControlConfig {
44    pub fn validate(&self) -> Result<(), AuthzError> {
45        let mut problems: Vec<String> = Vec::new();
46
47        for (idx, rule) in self.rules.iter().enumerate() {
48            if rule.entity_id.trim().is_empty() {
49                problems.push(format!("rules[{idx}]: entity_id is empty"));
50            }
51            if rule.roles.is_empty() {
52                problems.push(format!(
53                    "rules[{idx}]: must declare at least one role — per-user rules belong to \
54                     runtime state, not YAML, and attribute-based rules belong in an extension \
55                     hook"
56                ));
57            }
58            for role in &rule.roles {
59                if role.trim().is_empty() {
60                    problems.push(format!("rules[{idx}]: empty role string"));
61                }
62            }
63        }
64
65        if problems.is_empty() {
66            Ok(())
67        } else {
68            Err(AuthzError::Validation(problems.join("; ")))
69        }
70    }
71}