1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7pub const ACTION_AGENT_VIEW: &str = "agent.view";
10pub const ACTION_AGENT_SEND: &str = "agent.send";
12pub const ACTION_AGENT_SPAWN: &str = "agent.spawn";
14pub const ACTION_AGENT_RESPAWN: &str = "agent.respawn";
16pub const ACTION_AGENT_RETIRE: &str = "agent.retire";
18pub const ACTION_AGENT_RESET: &str = "agent.reset";
20pub const ACTION_GATING_VIEW: &str = "gating.view";
22pub const ACTION_GATING_DECIDE: &str = "gating.decide";
24pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
26pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
28pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
31pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
33pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
35
36pub const ACCESS_ACTIONS: &[&str] = &[
38 ACTION_AGENT_VIEW,
39 ACTION_AGENT_SEND,
40 ACTION_AGENT_SPAWN,
41 ACTION_AGENT_RESPAWN,
42 ACTION_AGENT_RETIRE,
43 ACTION_AGENT_RESET,
44 ACTION_GATING_VIEW,
45 ACTION_GATING_DECIDE,
46 ACTION_MOB_OBSERVE,
47 ACTION_RUNTIME_ADMIN,
48 ACTION_MOBPACK_AUTHOR,
49 ACTION_MOBPACK_DEPLOY,
50 ACTION_ACCESS_ADMIN,
51];
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56pub struct AccessControlConfig {
57 #[serde(default)]
60 pub enabled: bool,
61 #[serde(default)]
65 pub admins: Vec<String>,
66 #[serde(default)]
70 pub groups: BTreeMap<String, AccessGroup>,
71 #[serde(default)]
74 pub rules: Vec<AccessRule>,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub struct AccessGroup {
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub description: Option<String>,
82 #[serde(default)]
83 pub members: Vec<String>,
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "lowercase")]
89pub enum AccessEffect {
90 #[default]
91 Allow,
92 Deny,
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
112pub struct AccessRule {
113 pub id: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub description: Option<String>,
116 #[serde(default)]
117 pub effect: AccessEffect,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub subjects: Vec<String>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub groups: Vec<String>,
122 pub actions: Vec<String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub agents: Vec<String>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub roles: Vec<String>,
127 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128 pub match_labels: BTreeMap<String, String>,
129}
130
131impl AccessRule {
132 pub fn has_resource_selector(&self) -> bool {
134 !self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum AccessConfigError {
141 EnabledWithoutAdmins,
142 EmptyRuleId,
143 DuplicateRuleId(String),
144 EmptyActions(String),
145 UnknownAction { rule: String, action: String },
146 UnknownGroup { rule: String, group: String },
147 UnknownRule(String),
148 Parse(String),
149 Io(String),
150}
151
152impl std::fmt::Display for AccessConfigError {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::EnabledWithoutAdmins => write!(
156 f,
157 "access control cannot be enabled without at least one admin subject"
158 ),
159 Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
160 Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
161 Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
162 Self::UnknownAction { rule, action } => {
163 write!(f, "access rule {rule}: unknown action {action:?}")
164 }
165 Self::UnknownGroup { rule, group } => {
166 write!(f, "access rule {rule}: unknown group {group:?}")
167 }
168 Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
169 Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
170 Self::Io(message) => write!(f, "access config io error: {message}"),
171 }
172 }
173}
174
175impl std::error::Error for AccessConfigError {}
176
177fn action_pattern_is_known(pattern: &str) -> bool {
178 if pattern == "*" {
179 return true;
180 }
181 if let Some(prefix) = pattern.strip_suffix(".*") {
182 return ACCESS_ACTIONS.iter().any(|action| {
183 action
184 .rsplit_once('.')
185 .is_some_and(|(action_prefix, _)| action_prefix == prefix)
186 });
187 }
188 ACCESS_ACTIONS.contains(&pattern)
189}
190
191pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
197 if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
198 return Err(AccessConfigError::EnabledWithoutAdmins);
199 }
200 let mut seen_ids = std::collections::BTreeSet::new();
201 for rule in &config.rules {
202 if rule.id.trim().is_empty() {
203 return Err(AccessConfigError::EmptyRuleId);
204 }
205 if !seen_ids.insert(rule.id.as_str()) {
206 return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
207 }
208 if rule.actions.is_empty() {
209 return Err(AccessConfigError::EmptyActions(rule.id.clone()));
210 }
211 for action in &rule.actions {
212 if !action_pattern_is_known(action) {
213 return Err(AccessConfigError::UnknownAction {
214 rule: rule.id.clone(),
215 action: action.clone(),
216 });
217 }
218 }
219 for group in &rule.groups {
220 if !config.groups.contains_key(group) {
221 return Err(AccessConfigError::UnknownGroup {
222 rule: rule.id.clone(),
223 group: group.clone(),
224 });
225 }
226 }
227 }
228 Ok(())
229}
230
231#[cfg(test)]
232#[allow(clippy::expect_used, clippy::unwrap_used)]
233mod tests {
234 use super::*;
235
236 fn rule(id: &str, actions: &[&str]) -> AccessRule {
237 AccessRule {
238 id: id.to_string(),
239 actions: actions.iter().map(ToString::to_string).collect(),
240 ..AccessRule::default()
241 }
242 }
243
244 #[test]
245 fn default_config_is_disabled_and_valid() {
246 let config = AccessControlConfig::default();
247 assert!(!config.enabled);
248 assert!(validate_access_config(&config).is_ok());
249 }
250
251 #[test]
252 fn enabling_requires_admins() {
253 let config = AccessControlConfig {
254 enabled: true,
255 ..AccessControlConfig::default()
256 };
257 assert_eq!(
258 validate_access_config(&config),
259 Err(AccessConfigError::EnabledWithoutAdmins)
260 );
261 }
262
263 #[test]
264 fn rules_require_known_actions() {
265 let mut config = AccessControlConfig {
266 admins: vec!["root@example.test".to_string()],
267 rules: vec![rule("r1", &["agent.view", "agent.*", "*"])],
268 ..AccessControlConfig::default()
269 };
270 assert!(validate_access_config(&config).is_ok());
271 config.rules.push(rule("r2", &["agent.fly"]));
272 assert!(matches!(
273 validate_access_config(&config),
274 Err(AccessConfigError::UnknownAction { .. })
275 ));
276 }
277
278 #[test]
279 fn rules_reject_duplicate_ids_and_unknown_groups() {
280 let mut config = AccessControlConfig {
281 rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
282 ..AccessControlConfig::default()
283 };
284 assert_eq!(
285 validate_access_config(&config),
286 Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
287 );
288 config.rules.pop();
289 config.rules[0].groups = vec!["ops".to_string()];
290 assert!(matches!(
291 validate_access_config(&config),
292 Err(AccessConfigError::UnknownGroup { .. })
293 ));
294 }
295
296 #[test]
297 fn config_round_trips_through_toml() {
298 let config = AccessControlConfig {
299 enabled: true,
300 admins: vec!["root@example.test".to_string()],
301 groups: BTreeMap::from([(
302 "ops".to_string(),
303 AccessGroup {
304 description: Some("Operations".to_string()),
305 members: vec!["alice@example.test".to_string()],
306 },
307 )]),
308 rules: vec![AccessRule {
309 id: "ops-see-all".to_string(),
310 description: Some("ops see everything".to_string()),
311 effect: AccessEffect::Allow,
312 groups: vec!["ops".to_string()],
313 actions: vec!["agent.view".to_string()],
314 ..AccessRule::default()
315 }],
316 };
317 let toml = toml::to_string_pretty(&config).expect("serialize");
318 let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
319 assert_eq!(parsed, config);
320 }
321}