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