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_WORKGRAPH_VIEW: &str = "workgraph.view";
52pub const ACTION_WORKGRAPH_MANAGE: &str = "workgraph.manage";
54pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
56pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
58pub const ACTION_TOPOLOGY_VIEW: &str = "topology.view";
61pub const ACTION_TOPOLOGY_AUDIT: &str = "topology.audit";
65pub const ACTION_TOPOLOGY_CONNECT: &str = "topology.connect";
67pub const ACTION_TOPOLOGY_DISCONNECT: &str = "topology.disconnect";
69pub const ACTION_TOPOLOGY_RECONNECT: &str = "topology.reconnect";
72pub const ACTION_TOPOLOGY_BULK: &str = "topology.bulk";
74pub const ACTION_TOPOLOGY_CROSS_AUTHORITY: &str = "topology.cross_authority";
78pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
81pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
83pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
85
86pub const ACCESS_ACTIONS: &[&str] = &[
88 ACTION_AGENT_VIEW,
89 ACTION_AGENT_SEND,
90 ACTION_AGENT_MEMORY_WRITE,
91 ACTION_AGENT_MEMORY_DELETE,
92 ACTION_AGENT_MEMORY_READ,
93 ACTION_AGENT_MEMORY_ADMIN,
94 ACTION_OPERATOR_MEMORY_READ,
95 ACTION_MOB_MEMORY_READ,
96 ACTION_MOB_MEMORY_PROPOSE,
97 ACTION_MOB_MEMORY_COMMIT,
98 ACTION_MEMORY_QUARANTINE_REVIEW,
99 ACTION_AGENT_SPAWN,
100 ACTION_AGENT_RESPAWN,
101 ACTION_AGENT_RETIRE,
102 ACTION_AGENT_RESET,
103 ACTION_GATING_VIEW,
104 ACTION_GATING_DECIDE,
105 ACTION_WORKGRAPH_VIEW,
106 ACTION_WORKGRAPH_MANAGE,
107 ACTION_MOB_OBSERVE,
108 ACTION_RUNTIME_ADMIN,
109 ACTION_TOPOLOGY_VIEW,
110 ACTION_TOPOLOGY_AUDIT,
111 ACTION_TOPOLOGY_CONNECT,
112 ACTION_TOPOLOGY_DISCONNECT,
113 ACTION_TOPOLOGY_RECONNECT,
114 ACTION_TOPOLOGY_BULK,
115 ACTION_TOPOLOGY_CROSS_AUTHORITY,
116 ACTION_MOBPACK_AUTHOR,
117 ACTION_MOBPACK_DEPLOY,
118 ACTION_ACCESS_ADMIN,
119];
120
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub struct AccessControlConfig {
125 #[serde(default)]
128 pub enabled: bool,
129 #[serde(default)]
133 pub admins: Vec<String>,
134 #[serde(default)]
138 pub groups: BTreeMap<String, AccessGroup>,
139 #[serde(default)]
142 pub rules: Vec<AccessRule>,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub struct AccessGroup {
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub description: Option<String>,
150 #[serde(default)]
151 pub members: Vec<String>,
152}
153
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "lowercase")]
157pub enum AccessEffect {
158 #[default]
159 Allow,
160 Deny,
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
180pub struct AccessRule {
181 pub id: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub description: Option<String>,
184 #[serde(default)]
185 pub effect: AccessEffect,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub subjects: Vec<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub groups: Vec<String>,
190 pub actions: Vec<String>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub agents: Vec<String>,
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub roles: Vec<String>,
195 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196 pub match_labels: BTreeMap<String, String>,
197}
198
199impl AccessRule {
200 pub fn has_resource_selector(&self) -> bool {
202 !self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum AccessConfigError {
209 EnabledWithoutAdmins,
210 EmptyRuleId,
211 DuplicateRuleId(String),
212 EmptyActions(String),
213 UnknownAction { rule: String, action: String },
214 UnknownGroup { rule: String, group: String },
215 UnknownRule(String),
216 Parse(String),
217 Io(String),
218}
219
220impl std::fmt::Display for AccessConfigError {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 match self {
223 Self::EnabledWithoutAdmins => write!(
224 f,
225 "access control cannot be enabled without at least one admin subject"
226 ),
227 Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
228 Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
229 Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
230 Self::UnknownAction { rule, action } => {
231 write!(f, "access rule {rule}: unknown action {action:?}")
232 }
233 Self::UnknownGroup { rule, group } => {
234 write!(f, "access rule {rule}: unknown group {group:?}")
235 }
236 Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
237 Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
238 Self::Io(message) => write!(f, "access config io error: {message}"),
239 }
240 }
241}
242
243impl std::error::Error for AccessConfigError {}
244
245fn action_pattern_is_known(pattern: &str) -> bool {
246 if pattern == "*" {
247 return true;
248 }
249 if let Some(prefix) = pattern.strip_suffix(".*") {
250 return ACCESS_ACTIONS.iter().any(|action| {
251 action
252 .strip_prefix(prefix)
253 .is_some_and(|rest| rest.starts_with('.'))
254 });
255 }
256 ACCESS_ACTIONS.contains(&pattern)
257}
258
259pub(crate) fn action_pattern_matches(pattern: &str, action: &str) -> bool {
263 if pattern == "*" || pattern == action {
264 return true;
265 }
266 pattern.strip_suffix(".*").is_some_and(|prefix| {
267 action
268 .strip_prefix(prefix)
269 .is_some_and(|rest| rest.starts_with('.'))
270 })
271}
272
273fn pattern_mentions_memory(pattern: &str) -> bool {
279 pattern
280 .strip_suffix(".*")
281 .unwrap_or(pattern)
282 .split('.')
283 .any(|segment| segment == "memory")
284}
285
286pub fn normalize_access_config_for_memory_actions(config: &mut AccessControlConfig) -> bool {
303 let mentions_memory = config
304 .rules
305 .iter()
306 .flat_map(|rule| rule.actions.iter())
307 .any(|pattern| pattern_mentions_memory(pattern));
308 if mentions_memory {
309 return false;
310 }
311 let mut changed = false;
312 for rule in &mut config.rules {
313 let matches_view = rule
314 .actions
315 .iter()
316 .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_VIEW));
317 let matches_read = rule
318 .actions
319 .iter()
320 .any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_MEMORY_READ));
321 if matches_view && !matches_read {
322 rule.actions.push(ACTION_AGENT_MEMORY_READ.to_string());
323 changed = true;
324 }
325 }
326 if changed {
327 tracing::warn!(
328 target: "mobkit::access",
329 "access config predates memory read actions; granting agent.memory.read wherever \
330 agent.view is granted (write explicit agent.memory.read / mob.memory.read / \
331 memory.quarantine.review rules to silence this)"
332 );
333 }
334 changed
335}
336
337pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
343 if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
344 return Err(AccessConfigError::EnabledWithoutAdmins);
345 }
346 let mut seen_ids = std::collections::BTreeSet::new();
347 for rule in &config.rules {
348 if rule.id.trim().is_empty() {
349 return Err(AccessConfigError::EmptyRuleId);
350 }
351 if !seen_ids.insert(rule.id.as_str()) {
352 return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
353 }
354 if rule.actions.is_empty() {
355 return Err(AccessConfigError::EmptyActions(rule.id.clone()));
356 }
357 for action in &rule.actions {
358 if !action_pattern_is_known(action) {
359 return Err(AccessConfigError::UnknownAction {
360 rule: rule.id.clone(),
361 action: action.clone(),
362 });
363 }
364 }
365 for group in &rule.groups {
366 if !config.groups.contains_key(group) {
367 return Err(AccessConfigError::UnknownGroup {
368 rule: rule.id.clone(),
369 group: group.clone(),
370 });
371 }
372 }
373 }
374 Ok(())
375}
376
377#[cfg(test)]
378#[allow(clippy::expect_used, clippy::unwrap_used)]
379mod tests {
380 use super::*;
381
382 fn rule(id: &str, actions: &[&str]) -> AccessRule {
383 AccessRule {
384 id: id.to_string(),
385 actions: actions.iter().map(ToString::to_string).collect(),
386 ..AccessRule::default()
387 }
388 }
389
390 #[test]
391 fn default_config_is_disabled_and_valid() {
392 let config = AccessControlConfig::default();
393 assert!(!config.enabled);
394 assert!(validate_access_config(&config).is_ok());
395 }
396
397 #[test]
398 fn enabling_requires_admins() {
399 let config = AccessControlConfig {
400 enabled: true,
401 ..AccessControlConfig::default()
402 };
403 assert_eq!(
404 validate_access_config(&config),
405 Err(AccessConfigError::EnabledWithoutAdmins)
406 );
407 }
408
409 #[test]
410 fn rules_require_known_actions() {
411 let mut config = AccessControlConfig {
412 admins: vec!["root@example.test".to_string()],
413 rules: vec![rule(
414 "r1",
415 &["agent.view", "agent.memory.*", "agent.*", "*"],
416 )],
417 ..AccessControlConfig::default()
418 };
419 assert!(validate_access_config(&config).is_ok());
420 config.rules.push(rule("r2", &["agent.fly"]));
421 assert!(matches!(
422 validate_access_config(&config),
423 Err(AccessConfigError::UnknownAction { .. })
424 ));
425 }
426
427 #[test]
428 fn rules_reject_duplicate_ids_and_unknown_groups() {
429 let mut config = AccessControlConfig {
430 rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
431 ..AccessControlConfig::default()
432 };
433 assert_eq!(
434 validate_access_config(&config),
435 Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
436 );
437 config.rules.pop();
438 config.rules[0].groups = vec!["ops".to_string()];
439 assert!(matches!(
440 validate_access_config(&config),
441 Err(AccessConfigError::UnknownGroup { .. })
442 ));
443 }
444
445 #[test]
446 fn memory_actions_validate() {
447 let config = AccessControlConfig {
448 admins: vec!["root@example.test".to_string()],
449 rules: vec![rule(
450 "r1",
451 &[
452 "agent.memory.read",
453 "agent.memory.admin",
454 "operator.memory.read",
455 "mob.memory.read",
456 "mob.memory.propose",
457 "mob.memory.commit",
458 "memory.quarantine.review",
459 "mob.memory.*",
460 "memory.*",
461 ],
462 )],
463 ..AccessControlConfig::default()
464 };
465 assert!(validate_access_config(&config).is_ok());
466 }
467
468 #[test]
469 fn memory_naive_config_grants_read_alongside_view() {
470 let mut deny_view = rule("deny-secret", &["agent.view"]);
473 deny_view.effect = AccessEffect::Deny;
474 deny_view.agents = vec!["identity:secret".to_string()];
475 let mut config = AccessControlConfig {
476 enabled: true,
477 admins: vec!["root@example.test".to_string()],
478 rules: vec![
479 rule("view-all", &["agent.view"]),
480 deny_view,
481 rule("send-one", &["agent.send"]),
482 ],
483 ..AccessControlConfig::default()
484 };
485 assert!(normalize_access_config_for_memory_actions(&mut config));
486 let actions_of = |id: &str| {
487 config
488 .rules
489 .iter()
490 .find(|rule| rule.id == id)
491 .expect("rule")
492 .actions
493 .clone()
494 };
495 assert!(actions_of("view-all").contains(&"agent.memory.read".to_string()));
496 assert!(
497 actions_of("deny-secret").contains(&"agent.memory.read".to_string()),
498 "denies mirror too, so read cannot outlive a view deny"
499 );
500 assert!(!actions_of("send-one").contains(&"agent.memory.read".to_string()));
501 assert!(!normalize_access_config_for_memory_actions(&mut config));
503 }
504
505 #[test]
506 fn config_mentioning_any_memory_action_is_taken_literally() {
507 let mut config = AccessControlConfig {
511 enabled: true,
512 admins: vec!["root@example.test".to_string()],
513 rules: vec![
514 rule("view-all", &["agent.view"]),
515 rule("writer", &["agent.memory.write"]),
516 ],
517 ..AccessControlConfig::default()
518 };
519 assert!(!normalize_access_config_for_memory_actions(&mut config));
520 assert!(
521 !config.rules[0]
522 .actions
523 .contains(&"agent.memory.read".to_string())
524 );
525
526 let mut config = AccessControlConfig {
528 rules: vec![
529 rule("view-all", &["agent.view"]),
530 rule("mem", &["agent.memory.*"]),
531 ],
532 ..AccessControlConfig::default()
533 };
534 assert!(!normalize_access_config_for_memory_actions(&mut config));
535 }
536
537 #[test]
538 fn broad_wildcards_do_not_trigger_or_need_compat() {
539 let mut config = AccessControlConfig {
542 rules: vec![rule("all-agent-verbs", &["agent.*"])],
543 ..AccessControlConfig::default()
544 };
545 assert!(!normalize_access_config_for_memory_actions(&mut config));
546 assert!(action_pattern_matches("agent.*", "agent.memory.read"));
547 assert!(action_pattern_matches("*", "memory.quarantine.review"));
548 assert!(!action_pattern_matches("agent.*", "mob.memory.read"));
549 }
550
551 #[test]
552 fn config_round_trips_through_toml() {
553 let config = AccessControlConfig {
554 enabled: true,
555 admins: vec!["root@example.test".to_string()],
556 groups: BTreeMap::from([(
557 "ops".to_string(),
558 AccessGroup {
559 description: Some("Operations".to_string()),
560 members: vec!["alice@example.test".to_string()],
561 },
562 )]),
563 rules: vec![AccessRule {
564 id: "ops-see-all".to_string(),
565 description: Some("ops see everything".to_string()),
566 effect: AccessEffect::Allow,
567 groups: vec!["ops".to_string()],
568 actions: vec!["agent.view".to_string()],
569 ..AccessRule::default()
570 }],
571 };
572 let toml = toml::to_string_pretty(&config).expect("serialize");
573 let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
574 assert_eq!(parsed, config);
575 }
576}