systemprompt_security/authz/
config.rs1use 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}