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