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_MEMORY_WRITE: &str = "agent.memory.write";
14pub const ACTION_AGENT_MEMORY_DELETE: &str = "agent.memory.delete";
16pub const ACTION_AGENT_MEMORY_READ: &str = "agent.memory.read";
20pub const ACTION_OPERATOR_MEMORY_READ: &str = "operator.memory.read";
25pub const ACTION_AGENT_MEMORY_ADMIN: &str = "agent.memory.admin";
28pub const ACTION_MOB_MEMORY_READ: &str = "mob.memory.read";
30pub const ACTION_MOB_MEMORY_PROPOSE: &str = "mob.memory.propose";
32pub const ACTION_MOB_MEMORY_COMMIT: &str = "mob.memory.commit";
36pub const ACTION_MEMORY_QUARANTINE_REVIEW: &str = "memory.quarantine.review";
38pub const ACTION_AGENT_SPAWN: &str = "agent.spawn";
40pub const ACTION_AGENT_RESPAWN: &str = "agent.respawn";
42pub const ACTION_AGENT_RETIRE: &str = "agent.retire";
44pub const ACTION_AGENT_RESET: &str = "agent.reset";
46pub const ACTION_GATING_VIEW: &str = "gating.view";
48pub const ACTION_GATING_DECIDE: &str = "gating.decide";
50pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
52pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
54pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
57pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
59pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
61
62pub const ACCESS_ACTIONS: &[&str] = &[
64 ACTION_AGENT_VIEW,
65 ACTION_AGENT_SEND,
66 ACTION_AGENT_MEMORY_WRITE,
67 ACTION_AGENT_MEMORY_DELETE,
68 ACTION_AGENT_MEMORY_READ,
69 ACTION_AGENT_MEMORY_ADMIN,
70 ACTION_OPERATOR_MEMORY_READ,
71 ACTION_MOB_MEMORY_READ,
72 ACTION_MOB_MEMORY_PROPOSE,
73 ACTION_MOB_MEMORY_COMMIT,
74 ACTION_MEMORY_QUARANTINE_REVIEW,
75 ACTION_AGENT_SPAWN,
76 ACTION_AGENT_RESPAWN,
77 ACTION_AGENT_RETIRE,
78 ACTION_AGENT_RESET,
79 ACTION_GATING_VIEW,
80 ACTION_GATING_DECIDE,
81 ACTION_MOB_OBSERVE,
82 ACTION_RUNTIME_ADMIN,
83 ACTION_MOBPACK_AUTHOR,
84 ACTION_MOBPACK_DEPLOY,
85 ACTION_ACCESS_ADMIN,
86];
87
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
91pub struct AccessControlConfig {
92 #[serde(default)]
95 pub enabled: bool,
96 #[serde(default)]
100 pub admins: Vec<String>,
101 #[serde(default)]
105 pub groups: BTreeMap<String, AccessGroup>,
106 #[serde(default)]
109 pub rules: Vec<AccessRule>,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub struct AccessGroup {
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub description: Option<String>,
117 #[serde(default)]
118 pub members: Vec<String>,
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum AccessEffect {
125 #[default]
126 Allow,
127 Deny,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct AccessRule {
148 pub id: String,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub description: Option<String>,
151 #[serde(default)]
152 pub effect: AccessEffect,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub subjects: Vec<String>,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub groups: Vec<String>,
157 pub actions: Vec<String>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub agents: Vec<String>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
161 pub roles: Vec<String>,
162 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163 pub match_labels: BTreeMap<String, String>,
164}
165
166impl AccessRule {
167 pub fn has_resource_selector(&self) -> bool {
169 !self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum AccessConfigError {
176 EnabledWithoutAdmins,
177 EmptyRuleId,
178 DuplicateRuleId(String),
179 EmptyActions(String),
180 UnknownAction { rule: String, action: String },
181 UnknownGroup { rule: String, group: String },
182 UnknownRule(String),
183 Parse(String),
184 Io(String),
185}
186
187impl std::fmt::Display for AccessConfigError {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 match self {
190 Self::EnabledWithoutAdmins => write!(
191 f,
192 "access control cannot be enabled without at least one admin subject"
193 ),
194 Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
195 Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
196 Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
197 Self::UnknownAction { rule, action } => {
198 write!(f, "access rule {rule}: unknown action {action:?}")
199 }
200 Self::UnknownGroup { rule, group } => {
201 write!(f, "access rule {rule}: unknown group {group:?}")
202 }
203 Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
204 Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
205 Self::Io(message) => write!(f, "access config io error: {message}"),
206 }
207 }
208}
209
210impl std::error::Error for AccessConfigError {}
211
212fn action_pattern_is_known(pattern: &str) -> bool {
213 if pattern == "*" {
214 return true;
215 }
216 if let Some(prefix) = pattern.strip_suffix(".*") {
217 return ACCESS_ACTIONS.iter().any(|action| {
218 action
219 .strip_prefix(prefix)
220 .is_some_and(|rest| rest.starts_with('.'))
221 });
222 }
223 ACCESS_ACTIONS.contains(&pattern)
224}
225
226pub(crate) fn action_pattern_matches(pattern: &str, action: &str) -> bool {
230 if pattern == "*" || pattern == action {
231 return true;
232 }
233 pattern.strip_suffix(".*").is_some_and(|prefix| {
234 action
235 .strip_prefix(prefix)
236 .is_some_and(|rest| rest.starts_with('.'))
237 })
238}
239
240fn pattern_mentions_memory(pattern: &str) -> bool {
246 pattern
247 .strip_suffix(".*")
248 .unwrap_or(pattern)
249 .split('.')
250 .any(|segment| segment == "memory")
251}
252
253pub fn normalize_access_config_for_memory_actions(config: &mut AccessControlConfig) -> bool {
270 let mentions_memory = config
271 .rules
272 .iter()
273 .flat_map(|rule| rule.actions.iter())
274 .any(|pattern| pattern_mentions_memory(pattern));
275 if mentions_memory {
276 return false;
277 }
278 let mut changed = false;
279 for rule in &mut config.rules {
280 let matches_view = rule
281 .actions
282 .iter()
283 .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_VIEW));
284 let matches_read = rule
285 .actions
286 .iter()
287 .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_MEMORY_READ));
288 if matches_view && !matches_read {
289 rule.actions.push(ACTION_AGENT_MEMORY_READ.to_string());
290 changed = true;
291 }
292 }
293 if changed {
294 tracing::warn!(
295 target: "mobkit::access",
296 "access config predates memory read actions; granting agent.memory.read wherever \
297 agent.view is granted (write explicit agent.memory.read / mob.memory.read / \
298 memory.quarantine.review rules to silence this)"
299 );
300 }
301 changed
302}
303
304pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
310 if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
311 return Err(AccessConfigError::EnabledWithoutAdmins);
312 }
313 let mut seen_ids = std::collections::BTreeSet::new();
314 for rule in &config.rules {
315 if rule.id.trim().is_empty() {
316 return Err(AccessConfigError::EmptyRuleId);
317 }
318 if !seen_ids.insert(rule.id.as_str()) {
319 return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
320 }
321 if rule.actions.is_empty() {
322 return Err(AccessConfigError::EmptyActions(rule.id.clone()));
323 }
324 for action in &rule.actions {
325 if !action_pattern_is_known(action) {
326 return Err(AccessConfigError::UnknownAction {
327 rule: rule.id.clone(),
328 action: action.clone(),
329 });
330 }
331 }
332 for group in &rule.groups {
333 if !config.groups.contains_key(group) {
334 return Err(AccessConfigError::UnknownGroup {
335 rule: rule.id.clone(),
336 group: group.clone(),
337 });
338 }
339 }
340 }
341 Ok(())
342}
343
344#[cfg(test)]
345#[allow(clippy::expect_used, clippy::unwrap_used)]
346mod tests {
347 use super::*;
348
349 fn rule(id: &str, actions: &[&str]) -> AccessRule {
350 AccessRule {
351 id: id.to_string(),
352 actions: actions.iter().map(ToString::to_string).collect(),
353 ..AccessRule::default()
354 }
355 }
356
357 #[test]
358 fn default_config_is_disabled_and_valid() {
359 let config = AccessControlConfig::default();
360 assert!(!config.enabled);
361 assert!(validate_access_config(&config).is_ok());
362 }
363
364 #[test]
365 fn enabling_requires_admins() {
366 let config = AccessControlConfig {
367 enabled: true,
368 ..AccessControlConfig::default()
369 };
370 assert_eq!(
371 validate_access_config(&config),
372 Err(AccessConfigError::EnabledWithoutAdmins)
373 );
374 }
375
376 #[test]
377 fn rules_require_known_actions() {
378 let mut config = AccessControlConfig {
379 admins: vec!["root@example.test".to_string()],
380 rules: vec![rule(
381 "r1",
382 &["agent.view", "agent.memory.*", "agent.*", "*"],
383 )],
384 ..AccessControlConfig::default()
385 };
386 assert!(validate_access_config(&config).is_ok());
387 config.rules.push(rule("r2", &["agent.fly"]));
388 assert!(matches!(
389 validate_access_config(&config),
390 Err(AccessConfigError::UnknownAction { .. })
391 ));
392 }
393
394 #[test]
395 fn rules_reject_duplicate_ids_and_unknown_groups() {
396 let mut config = AccessControlConfig {
397 rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
398 ..AccessControlConfig::default()
399 };
400 assert_eq!(
401 validate_access_config(&config),
402 Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
403 );
404 config.rules.pop();
405 config.rules[0].groups = vec!["ops".to_string()];
406 assert!(matches!(
407 validate_access_config(&config),
408 Err(AccessConfigError::UnknownGroup { .. })
409 ));
410 }
411
412 #[test]
413 fn memory_actions_validate() {
414 let config = AccessControlConfig {
415 admins: vec!["root@example.test".to_string()],
416 rules: vec![rule(
417 "r1",
418 &[
419 "agent.memory.read",
420 "agent.memory.admin",
421 "operator.memory.read",
422 "mob.memory.read",
423 "mob.memory.propose",
424 "mob.memory.commit",
425 "memory.quarantine.review",
426 "mob.memory.*",
427 "memory.*",
428 ],
429 )],
430 ..AccessControlConfig::default()
431 };
432 assert!(validate_access_config(&config).is_ok());
433 }
434
435 #[test]
436 fn memory_naive_config_grants_read_alongside_view() {
437 let mut deny_view = rule("deny-secret", &["agent.view"]);
440 deny_view.effect = AccessEffect::Deny;
441 deny_view.agents = vec!["identity:secret".to_string()];
442 let mut config = AccessControlConfig {
443 enabled: true,
444 admins: vec!["root@example.test".to_string()],
445 rules: vec![
446 rule("view-all", &["agent.view"]),
447 deny_view,
448 rule("send-one", &["agent.send"]),
449 ],
450 ..AccessControlConfig::default()
451 };
452 assert!(normalize_access_config_for_memory_actions(&mut config));
453 let actions_of = |id: &str| {
454 config
455 .rules
456 .iter()
457 .find(|rule| rule.id == id)
458 .expect("rule")
459 .actions
460 .clone()
461 };
462 assert!(actions_of("view-all").contains(&"agent.memory.read".to_string()));
463 assert!(
464 actions_of("deny-secret").contains(&"agent.memory.read".to_string()),
465 "denies mirror too, so read cannot outlive a view deny"
466 );
467 assert!(!actions_of("send-one").contains(&"agent.memory.read".to_string()));
468 assert!(!normalize_access_config_for_memory_actions(&mut config));
470 }
471
472 #[test]
473 fn config_mentioning_any_memory_action_is_taken_literally() {
474 let mut config = AccessControlConfig {
478 enabled: true,
479 admins: vec!["root@example.test".to_string()],
480 rules: vec![
481 rule("view-all", &["agent.view"]),
482 rule("writer", &["agent.memory.write"]),
483 ],
484 ..AccessControlConfig::default()
485 };
486 assert!(!normalize_access_config_for_memory_actions(&mut config));
487 assert!(
488 !config.rules[0]
489 .actions
490 .contains(&"agent.memory.read".to_string())
491 );
492
493 let mut config = AccessControlConfig {
495 rules: vec![
496 rule("view-all", &["agent.view"]),
497 rule("mem", &["agent.memory.*"]),
498 ],
499 ..AccessControlConfig::default()
500 };
501 assert!(!normalize_access_config_for_memory_actions(&mut config));
502 }
503
504 #[test]
505 fn broad_wildcards_do_not_trigger_or_need_compat() {
506 let mut config = AccessControlConfig {
509 rules: vec![rule("all-agent-verbs", &["agent.*"])],
510 ..AccessControlConfig::default()
511 };
512 assert!(!normalize_access_config_for_memory_actions(&mut config));
513 assert!(action_pattern_matches("agent.*", "agent.memory.read"));
514 assert!(action_pattern_matches("*", "memory.quarantine.review"));
515 assert!(!action_pattern_matches("agent.*", "mob.memory.read"));
516 }
517
518 #[test]
519 fn config_round_trips_through_toml() {
520 let config = AccessControlConfig {
521 enabled: true,
522 admins: vec!["root@example.test".to_string()],
523 groups: BTreeMap::from([(
524 "ops".to_string(),
525 AccessGroup {
526 description: Some("Operations".to_string()),
527 members: vec!["alice@example.test".to_string()],
528 },
529 )]),
530 rules: vec![AccessRule {
531 id: "ops-see-all".to_string(),
532 description: Some("ops see everything".to_string()),
533 effect: AccessEffect::Allow,
534 groups: vec!["ops".to_string()],
535 actions: vec!["agent.view".to_string()],
536 ..AccessRule::default()
537 }],
538 };
539 let toml = toml::to_string_pretty(&config).expect("serialize");
540 let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
541 assert_eq!(parsed, config);
542 }
543}