1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, RwLock};
6
7use super::engine::{
8 AccessDecision, AccessPrincipal, AccessResource, evaluate_access, groups_for_subject,
9 principal_may_perform,
10};
11use super::model::{
12 ACTION_AGENT_VIEW, AccessConfigError, AccessControlConfig, AccessGroup, AccessRule,
13 validate_access_config,
14};
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct AgentResourceAttributes {
24 pub identity: String,
25 pub agent_id: Option<String>,
26 pub role: Option<String>,
27 pub labels: BTreeMap<String, String>,
28}
29
30struct AccessState {
31 config: Arc<AccessControlConfig>,
32 revision: u64,
33}
34
35struct AccessControllerInner {
36 state: RwLock<AccessState>,
37 persist_path: RwLock<Option<PathBuf>>,
38 attributes: RwLock<BTreeMap<String, Arc<AgentResourceAttributes>>>,
39 mutation: Mutex<()>,
44}
45
46#[derive(Clone)]
52pub struct AccessController {
53 inner: Arc<AccessControllerInner>,
54}
55
56impl std::fmt::Debug for AccessController {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 let (config, revision) = self.snapshot();
59 f.debug_struct("AccessController")
60 .field("enabled", &config.enabled)
61 .field("revision", &revision)
62 .field("rules", &config.rules.len())
63 .finish()
64 }
65}
66
67impl AccessController {
68 pub fn new(mut config: AccessControlConfig) -> Result<Self, AccessConfigError> {
70 super::model::normalize_access_config_for_memory_actions(&mut config);
74 validate_access_config(&config)?;
75 Ok(Self {
76 inner: Arc::new(AccessControllerInner {
77 state: RwLock::new(AccessState {
78 config: Arc::new(config),
79 revision: 0,
80 }),
81 persist_path: RwLock::new(None),
82 attributes: RwLock::new(BTreeMap::new()),
83 mutation: Mutex::new(()),
84 }),
85 })
86 }
87
88 pub fn disabled() -> Self {
90 Self::new(AccessControlConfig::default()).unwrap_or_else(|_| unreachable!())
91 }
92
93 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, AccessConfigError> {
97 let path = path.into();
98 let config = if path.is_file() {
99 let raw = std::fs::read_to_string(&path)
100 .map_err(|err| AccessConfigError::Io(err.to_string()))?;
101 toml::from_str::<AccessControlConfig>(&raw)
102 .map_err(|err| AccessConfigError::Parse(err.to_string()))?
103 } else {
104 AccessControlConfig::default()
105 };
106 let controller = Self::new(config)?;
107 *controller
108 .inner
109 .persist_path
110 .write()
111 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path);
112 Ok(controller)
113 }
114
115 pub fn with_persist_path(self, path: impl Into<PathBuf>) -> Self {
117 *self
118 .inner
119 .persist_path
120 .write()
121 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path.into());
122 self
123 }
124
125 pub fn snapshot(&self) -> (Arc<AccessControlConfig>, u64) {
127 let state = self
128 .inner
129 .state
130 .read()
131 .unwrap_or_else(std::sync::PoisonError::into_inner);
132 (Arc::clone(&state.config), state.revision)
133 }
134
135 pub fn enabled(&self) -> bool {
137 self.snapshot().0.enabled
138 }
139
140 pub fn replace_config(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
142 self.mutate(move |current| {
143 *current = config;
144 Ok(())
145 })
146 }
147
148 pub fn upsert_rule(&self, rule: AccessRule) -> Result<u64, AccessConfigError> {
150 self.mutate(move |config| {
151 match config
152 .rules
153 .iter_mut()
154 .find(|existing| existing.id == rule.id)
155 {
156 Some(existing) => *existing = rule,
157 None => config.rules.push(rule),
158 }
159 Ok(())
160 })
161 }
162
163 pub fn delete_rule(&self, rule_id: &str) -> Result<u64, AccessConfigError> {
165 self.mutate(|config| {
166 let before = config.rules.len();
167 config.rules.retain(|rule| rule.id != rule_id);
168 if config.rules.len() == before {
169 return Err(AccessConfigError::UnknownRule(rule_id.to_string()));
170 }
171 Ok(())
172 })
173 }
174
175 pub fn set_group(&self, name: &str, group: AccessGroup) -> Result<u64, AccessConfigError> {
177 self.mutate(move |config| {
178 config.groups.insert(name.to_string(), group);
179 Ok(())
180 })
181 }
182
183 pub fn delete_group(&self, name: &str) -> Result<u64, AccessConfigError> {
185 self.mutate(|config| {
186 config.groups.remove(name);
187 Ok(())
188 })
189 }
190
191 pub fn set_enabled(&self, enabled: bool) -> Result<u64, AccessConfigError> {
193 self.mutate(move |config| {
194 config.enabled = enabled;
195 Ok(())
196 })
197 }
198
199 fn mutate<F>(&self, mutator: F) -> Result<u64, AccessConfigError>
204 where
205 F: FnOnce(&mut AccessControlConfig) -> Result<(), AccessConfigError>,
206 {
207 let _mutation = self
208 .inner
209 .mutation
210 .lock()
211 .unwrap_or_else(std::sync::PoisonError::into_inner);
212 let mut config = (*self.snapshot().0).clone();
213 mutator(&mut config)?;
214 super::model::normalize_access_config_for_memory_actions(&mut config);
219 validate_access_config(&config)?;
220 self.commit(config)
221 }
222
223 fn commit(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
224 let persist_path = self
225 .inner
226 .persist_path
227 .read()
228 .unwrap_or_else(std::sync::PoisonError::into_inner)
229 .clone();
230 if let Some(path) = persist_path {
231 persist_config(&path, &config)?;
232 }
233 let mut state = self
234 .inner
235 .state
236 .write()
237 .unwrap_or_else(std::sync::PoisonError::into_inner);
238 state.config = Arc::new(config);
239 state.revision += 1;
240 Ok(state.revision)
241 }
242
243 pub fn view_for_subject(&self, subject: Option<&str>) -> AccessView {
246 let (config, _) = self.snapshot();
247 let principal = match subject {
248 Some(subject) => AccessPrincipal {
249 subject: Some(subject.to_string()),
250 groups: groups_for_subject(&config, subject),
251 },
252 None => AccessPrincipal::anonymous(),
253 };
254 let is_admin = principal
255 .subject
256 .as_deref()
257 .is_some_and(|subject| config.admins.iter().any(|admin| admin == subject));
258 AccessView {
259 inner: Arc::clone(&self.inner),
260 config,
261 principal,
262 is_admin,
263 }
264 }
265
266 pub fn record_agent_attributes(&self, attributes: AgentResourceAttributes) {
268 if attributes.identity.is_empty() {
269 return;
270 }
271 let mut cache = self
272 .inner
273 .attributes
274 .write()
275 .unwrap_or_else(std::sync::PoisonError::into_inner);
276 cache.insert(attributes.identity.clone(), Arc::new(attributes));
277 }
278
279 pub fn replace_agent_attributes(
288 &self,
289 attributes: impl IntoIterator<Item = AgentResourceAttributes>,
290 ) {
291 let next: BTreeMap<String, Arc<AgentResourceAttributes>> = attributes
292 .into_iter()
293 .filter(|entry| !entry.identity.is_empty())
294 .map(|entry| (entry.identity.clone(), Arc::new(entry)))
295 .collect();
296 if next.is_empty() {
297 return;
298 }
299 *self
300 .inner
301 .attributes
302 .write()
303 .unwrap_or_else(std::sync::PoisonError::into_inner) = next;
304 }
305}
306
307fn persist_config(path: &Path, config: &AccessControlConfig) -> Result<(), AccessConfigError> {
308 let rendered =
309 toml::to_string_pretty(config).map_err(|err| AccessConfigError::Parse(err.to_string()))?;
310 if let Some(parent) = path.parent()
311 && !parent.as_os_str().is_empty()
312 {
313 std::fs::create_dir_all(parent).map_err(|err| AccessConfigError::Io(err.to_string()))?;
314 }
315 let header = "# MobKit access control. Managed by the console Access panel;\n# hand edits are preserved until the next console save.\n\n";
316 let mut tmp = path.to_path_buf();
321 let mut tmp_name = path
322 .file_name()
323 .map(std::ffi::OsString::from)
324 .ok_or_else(|| {
325 AccessConfigError::Io(format!(
326 "access config path has no file name: {}",
327 path.display()
328 ))
329 })?;
330 tmp_name.push(".tmp");
331 tmp.set_file_name(tmp_name);
332 std::fs::write(&tmp, format!("{header}{rendered}"))
333 .map_err(|err| AccessConfigError::Io(err.to_string()))?;
334 std::fs::rename(&tmp, path).map_err(|err| AccessConfigError::Io(err.to_string()))
335}
336
337struct LineageLink {
341 identity: String,
342 attributes: Option<Arc<AgentResourceAttributes>>,
343}
344
345#[derive(Clone)]
351pub struct AccessView {
352 inner: Arc<AccessControllerInner>,
353 config: Arc<AccessControlConfig>,
354 principal: AccessPrincipal,
355 is_admin: bool,
356}
357
358impl AccessView {
359 pub fn enforced(&self) -> bool {
361 self.config.enabled
362 }
363
364 pub fn subject(&self) -> Option<&str> {
365 self.principal.subject.as_deref()
366 }
367
368 pub fn groups(&self) -> &BTreeSet<String> {
369 &self.principal.groups
370 }
371
372 pub fn is_admin(&self) -> bool {
373 self.is_admin
374 }
375
376 pub fn decide(&self, action: &str, resource: &AccessResource<'_>) -> AccessDecision {
378 evaluate_access(&self.config, &self.principal, action, resource)
379 }
380
381 pub fn allows(&self, action: &str) -> bool {
383 self.decide(action, &AccessResource::none()).is_allow()
384 }
385
386 pub fn may_perform_anywhere(&self, action: &str) -> bool {
391 self.is_admin || principal_may_perform(&self.config, &self.principal, action)
392 }
393
394 pub fn allows_agent(&self, action: &str, identity: &str) -> bool {
397 self.decide_agent(action, identity).is_allow()
398 }
399
400 pub fn decide_agent(&self, action: &str, identity: &str) -> AccessDecision {
410 if !self.config.enabled {
411 return AccessDecision::Allow;
412 }
413 let lineage = self.lineage_for(identity);
414 if lineage.is_empty() {
415 return self.decide(action, &AccessResource::for_identity(identity));
416 }
417 self.decide_agent_lineage(action, identity, &lineage)
418 }
419
420 pub(crate) fn decide_agent_with_attributes(
430 &self,
431 action: &str,
432 attributes: &AgentResourceAttributes,
433 ) -> AccessDecision {
434 if !self.config.enabled {
435 return AccessDecision::Allow;
436 }
437 let lineage = self.lineage_for_attributes(attributes);
438 self.decide_agent_lineage(action, attributes.identity.as_str(), &lineage)
439 }
440
441 fn decide_agent_lineage(
442 &self,
443 action: &str,
444 fallback_identity: &str,
445 lineage: &[LineageLink],
446 ) -> AccessDecision {
447 let resources = lineage
448 .iter()
449 .enumerate()
450 .map(|(index, link)| match link.attributes.as_deref() {
451 Some(attributes) => AccessResource {
452 identity: Some(attributes.identity.as_str()),
453 agent_id: attributes
454 .agent_id
455 .as_deref()
456 .or((index == 0).then_some(fallback_identity)),
457 role: attributes.role.as_deref(),
458 labels: Some(&attributes.labels),
459 },
460 None => AccessResource::for_identity(link.identity.as_str()),
461 })
462 .collect::<Vec<_>>();
463 super::engine::evaluate_access_lineage(&self.config, &self.principal, action, &resources)
464 }
465
466 fn lineage_for(&self, identity: &str) -> Vec<LineageLink> {
471 let cache = self
472 .inner
473 .attributes
474 .read()
475 .unwrap_or_else(std::sync::PoisonError::into_inner);
476 let resolve = |key: &str| {
477 cache.get(key).cloned().or_else(|| {
478 cache
479 .values()
480 .find(|attributes| attributes.agent_id.as_deref() == Some(key))
481 .cloned()
482 })
483 };
484 let Some(own) = resolve(identity) else {
485 return Vec::new();
486 };
487 Self::lineage_from_attributes(own, &resolve)
488 }
489
490 fn lineage_for_attributes(&self, attributes: &AgentResourceAttributes) -> Vec<LineageLink> {
494 let cache = self
495 .inner
496 .attributes
497 .read()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 let resolve = |key: &str| {
500 cache.get(key).cloned().or_else(|| {
501 cache
502 .values()
503 .find(|candidate| candidate.agent_id.as_deref() == Some(key))
504 .cloned()
505 })
506 };
507 Self::lineage_from_attributes(Arc::new(attributes.clone()), &resolve)
508 }
509
510 fn lineage_from_attributes(
511 own: Arc<AgentResourceAttributes>,
512 resolve: &impl Fn(&str) -> Option<Arc<AgentResourceAttributes>>,
513 ) -> Vec<LineageLink> {
514 const MAX_LINEAGE_DEPTH: usize = 8;
515 let mut visited = BTreeSet::from([own.identity.clone()]);
516 let mut lineage = vec![LineageLink {
517 identity: own.identity.clone(),
518 attributes: Some(own),
519 }];
520 while lineage.len() < MAX_LINEAGE_DEPTH {
521 let Some(parent) = lineage
522 .last()
523 .and_then(|link| link.attributes.as_deref())
524 .and_then(|attributes| attributes.labels.get("spawned_by"))
525 .map(|parent| parent.trim().to_string())
526 .filter(|parent| !parent.is_empty())
527 else {
528 break;
529 };
530 let attributes = resolve(&parent);
531 let parent_identity = attributes
532 .as_deref()
533 .map(|attributes| attributes.identity.clone())
534 .unwrap_or(parent);
535 if !visited.insert(parent_identity.clone()) {
536 break;
537 }
538 lineage.push(LineageLink {
539 identity: parent_identity,
540 attributes,
541 });
542 }
543 lineage
544 }
545
546 pub fn can_view_agent(&self, identity: &str) -> bool {
548 self.allows_agent(ACTION_AGENT_VIEW, identity)
549 }
550
551 pub fn knows_agent(&self, identity: &str) -> bool {
561 let cache = self
562 .inner
563 .attributes
564 .read()
565 .unwrap_or_else(std::sync::PoisonError::into_inner);
566 cache.contains_key(identity)
567 || cache
568 .values()
569 .any(|attributes| attributes.agent_id.as_deref() == Some(identity))
570 }
571
572 pub fn can_administer(&self) -> bool {
580 if self.is_admin {
581 return true;
582 }
583 if !self.config.enabled {
584 return self.config.admins.is_empty();
585 }
586 self.decide(super::model::ACTION_ACCESS_ADMIN, &AccessResource::none())
587 .is_allow()
588 }
589}
590
591impl std::fmt::Debug for AccessView {
592 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593 f.debug_struct("AccessView")
594 .field("subject", &self.principal.subject)
595 .field("groups", &self.principal.groups)
596 .field("is_admin", &self.is_admin)
597 .field("enforced", &self.config.enabled)
598 .finish()
599 }
600}
601
602#[cfg(test)]
603#[allow(clippy::expect_used, clippy::unwrap_used)]
604mod tests {
605 use super::*;
606 use crate::access::model::AccessEffect;
607
608 fn enabled_config() -> AccessControlConfig {
609 AccessControlConfig {
610 enabled: true,
611 admins: vec!["root@example.test".to_string()],
612 groups: BTreeMap::from([(
613 "ops".to_string(),
614 AccessGroup {
615 description: None,
616 members: vec!["alice@example.test".to_string()],
617 },
618 )]),
619 rules: vec![AccessRule {
620 id: "ops-view-all".to_string(),
621 groups: vec!["ops".to_string()],
622 actions: vec!["agent.view".to_string()],
623 ..AccessRule::default()
624 }],
625 }
626 }
627
628 #[test]
629 fn view_resolves_groups_and_admin_flag() {
630 let controller = AccessController::new(enabled_config()).expect("controller");
631 let alice = controller.view_for_subject(Some("alice@example.test"));
632 assert!(alice.groups().contains("ops"));
633 assert!(!alice.is_admin());
634 assert!(alice.can_view_agent("identity:scout-1"));
635 assert!(!alice.allows_agent("agent.send", "identity:scout-1"));
636
637 let root = controller.view_for_subject(Some("root@example.test"));
638 assert!(root.is_admin());
639 assert!(root.allows("access.admin"));
640 }
641
642 #[test]
643 fn live_mutations_bump_revision_and_apply() {
644 let controller = AccessController::new(enabled_config()).expect("controller");
645 let bob = controller.view_for_subject(Some("bob@example.test"));
646 assert!(!bob.can_view_agent("identity:scout-1"));
647
648 let revision = controller
649 .set_group(
650 "ops",
651 AccessGroup {
652 description: None,
653 members: vec![
654 "alice@example.test".to_string(),
655 "bob@example.test".to_string(),
656 ],
657 },
658 )
659 .expect("set group");
660 assert_eq!(revision, 1);
661
662 let bob_after = controller.view_for_subject(Some("bob@example.test"));
665 assert!(bob_after.can_view_agent("identity:scout-1"));
666 assert!(!bob.can_view_agent("identity:scout-1"));
667 }
668
669 #[test]
670 fn delete_rule_unknown_id_errors() {
671 let controller = AccessController::new(enabled_config()).expect("controller");
672 assert_eq!(
673 controller.delete_rule("missing"),
674 Err(AccessConfigError::UnknownRule("missing".to_string()))
675 );
676 controller.delete_rule("ops-view-all").expect("delete");
677 let (config, revision) = controller.snapshot();
678 assert!(config.rules.is_empty());
679 assert_eq!(revision, 1);
680 }
681
682 #[test]
683 fn attribute_cache_feeds_label_selectors() {
684 let mut config = enabled_config();
685 config.rules.push(AccessRule {
686 id: "bob-payments".to_string(),
687 subjects: vec!["bob@example.test".to_string()],
688 actions: vec!["agent.view".to_string()],
689 match_labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
690 ..AccessRule::default()
691 });
692 let controller = AccessController::new(config).expect("controller");
693 let bob = controller.view_for_subject(Some("bob@example.test"));
694 assert!(!bob.can_view_agent("identity:pay-1"));
695
696 controller.record_agent_attributes(AgentResourceAttributes {
697 identity: "identity:pay-1".to_string(),
698 agent_id: Some("pay-1".to_string()),
699 role: Some("analyst".to_string()),
700 labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
701 });
702 assert!(bob.can_view_agent("identity:pay-1"));
703 assert!(!bob.can_view_agent("identity:other"));
704 }
705
706 #[test]
707 fn exact_event_attributes_override_newer_alias_cache_entry() {
708 let controller = AccessController::new(AccessControlConfig {
709 enabled: true,
710 admins: vec!["root@example.test".to_string()],
711 rules: vec![
712 AccessRule {
713 id: "view-all".to_string(),
714 actions: vec!["agent.view".to_string()],
715 agents: vec!["*".to_string()],
716 ..AccessRule::default()
717 },
718 AccessRule {
719 id: "deny-secret".to_string(),
720 effect: AccessEffect::Deny,
721 actions: vec!["agent.view".to_string()],
722 match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
723 ..AccessRule::default()
724 },
725 ],
726 ..AccessControlConfig::default()
727 })
728 .expect("controller");
729 controller.record_agent_attributes(AgentResourceAttributes {
730 identity: "reused-alias".to_string(),
731 agent_id: Some("reused-alias".to_string()),
732 role: Some("lead".to_string()),
733 labels: BTreeMap::from([("org".to_string(), "public".to_string())]),
734 });
735 let historical_secret = AgentResourceAttributes {
736 identity: "reused-alias".to_string(),
737 agent_id: Some("reused-alias".to_string()),
738 role: Some("lead".to_string()),
739 labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
740 };
741 let view = controller.view_for_subject(None);
742
743 assert!(view.can_view_agent("reused-alias"));
744 assert!(
745 !view
746 .decide_agent_with_attributes(ACTION_AGENT_VIEW, &historical_secret)
747 .is_allow(),
748 "the newer public cache entry must not authorize the historical secret event"
749 );
750 }
751
752 #[test]
753 fn knows_agent_detects_cold_cache_so_label_deny_can_be_made_fail_closed() {
754 let mut config = enabled_config();
759 config.rules.push(AccessRule {
761 id: "anon-view-all".to_string(),
762 actions: vec!["agent.view".to_string()],
763 agents: vec!["*".to_string()],
764 ..AccessRule::default()
765 });
766 config.rules.push(AccessRule {
768 id: "deny-secret".to_string(),
769 effect: AccessEffect::Deny,
770 actions: vec!["agent.view".to_string()],
771 match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
772 ..AccessRule::default()
773 });
774 let controller = AccessController::new(config).expect("controller");
775 let view = controller.view_for_subject(None);
776
777 assert!(!view.knows_agent("identity:secret-1"));
780 assert!(
781 view.can_view_agent("identity:secret-1"),
782 "cold cache currently fails OPEN — this is what knows_agent() detects"
783 );
784
785 controller.record_agent_attributes(AgentResourceAttributes {
788 identity: "identity:secret-1".to_string(),
789 agent_id: Some("secret-1".to_string()),
790 role: Some("worker".to_string()),
791 labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
792 });
793
794 assert!(view.knows_agent("identity:secret-1"));
796 assert!(
797 !view.can_view_agent("identity:secret-1"),
798 "after re-prime the label-scoped deny must hide the member"
799 );
800 }
801
802 #[test]
803 fn persistence_round_trips() {
804 let dir = tempfile::tempdir().expect("tempdir");
805 let path = dir.path().join("config").join("access.toml");
806 let controller = AccessController::load_or_default(&path).expect("load default");
807 assert!(!controller.enabled());
808
809 let mut config = enabled_config();
810 config.rules.push(AccessRule {
811 id: "deny-secret".to_string(),
812 effect: AccessEffect::Deny,
813 actions: vec!["agent.*".to_string()],
814 agents: vec!["identity:secret".to_string()],
815 ..AccessRule::default()
816 });
817 controller.replace_config(config.clone()).expect("replace");
818
819 super::super::model::normalize_access_config_for_memory_actions(&mut config);
822 let reloaded = AccessController::load_or_default(&path).expect("reload");
823 let (reloaded_config, _) = reloaded.snapshot();
824 assert_eq!(*reloaded_config, config);
825 }
826
827 #[test]
828 fn lockout_protected_on_live_surface() {
829 let controller = AccessController::new(enabled_config()).expect("controller");
830 let mut config = (*controller.snapshot().0).clone();
831 config.admins.clear();
832 assert_eq!(
833 controller.replace_config(config),
834 Err(AccessConfigError::EnabledWithoutAdmins)
835 );
836 }
837
838 #[test]
839 fn concurrent_rule_upserts_do_not_lose_updates() {
840 let controller = AccessController::new(enabled_config()).expect("controller");
845 let base_rules = controller.snapshot().0.rules.len();
846 let threads: usize = 16;
847 let handles: Vec<_> = (0..threads)
848 .map(|i| {
849 let controller = controller.clone();
850 std::thread::spawn(move || {
851 controller
852 .upsert_rule(AccessRule {
853 id: format!("rule-{i}"),
854 actions: vec!["agent.view".to_string()],
855 agents: vec![format!("identity:agent-{i}")],
856 ..AccessRule::default()
857 })
858 .expect("upsert");
859 })
860 })
861 .collect();
862 for handle in handles {
863 handle.join().expect("thread");
864 }
865 let (config, revision) = controller.snapshot();
866 assert_eq!(
867 config.rules.len(),
868 base_rules + threads,
869 "every rule survived: {config:#?}"
870 );
871 assert_eq!(revision, threads as u64, "revision counts every commit");
872 for i in 0..threads {
873 assert!(
874 config
875 .rules
876 .iter()
877 .any(|rule| rule.id == format!("rule-{i}")),
878 "rule-{i} missing"
879 );
880 }
881 }
882
883 #[test]
884 fn spawn_lineage_inherits_parent_permissions() {
885 let mut config = enabled_config();
886 config.rules.push(AccessRule {
887 id: "bob-ops-lead".to_string(),
888 subjects: vec!["bob@example.test".to_string()],
889 actions: vec!["agent.view".to_string(), "agent.send".to_string()],
890 agents: vec!["ops-lead".to_string()],
891 ..AccessRule::default()
892 });
893 let controller = AccessController::new(config).expect("controller");
894 controller.record_agent_attributes(AgentResourceAttributes {
895 identity: "ops-lead".to_string(),
896 agent_id: Some("ops-lead".to_string()),
897 role: Some("orchestrator".to_string()),
898 labels: BTreeMap::new(),
899 });
900 controller.record_agent_attributes(AgentResourceAttributes {
901 identity: "worker-3".to_string(),
902 agent_id: Some("worker-3".to_string()),
903 role: Some("person-worker".to_string()),
904 labels: BTreeMap::from([("spawned_by".to_string(), "ops-lead".to_string())]),
905 });
906 controller.record_agent_attributes(AgentResourceAttributes {
907 identity: "worker-3-sub".to_string(),
908 agent_id: Some("worker-3-sub".to_string()),
909 role: Some("helper".to_string()),
910 labels: BTreeMap::from([("spawned_by".to_string(), "worker-3".to_string())]),
911 });
912 controller.record_agent_attributes(AgentResourceAttributes {
913 identity: "scout-1".to_string(),
914 agent_id: Some("scout-1".to_string()),
915 role: Some("scout".to_string()),
916 labels: BTreeMap::new(),
917 });
918
919 let bob = controller.view_for_subject(Some("bob@example.test"));
920 assert!(bob.can_view_agent("ops-lead"));
921 assert!(
922 bob.can_view_agent("worker-3"),
923 "a member spawned by ops-lead inherits ops-lead's visibility"
924 );
925 assert!(
926 bob.allows_agent("agent.send", "worker-3"),
927 "permission inheritance covers every agent action, not just view"
928 );
929 assert!(
930 bob.can_view_agent("worker-3-sub"),
931 "spawn lineage inheritance is transitive"
932 );
933 assert!(
934 !bob.can_view_agent("scout-1"),
935 "agents outside the spawn lineage stay denied"
936 );
937 }
938
939 #[test]
940 fn spawn_lineage_deny_on_parent_overrides_descendants() {
941 let mut config = enabled_config();
942 config.rules.push(AccessRule {
943 id: "bob-view-all".to_string(),
944 subjects: vec!["bob@example.test".to_string()],
945 actions: vec!["agent.view".to_string()],
946 agents: vec!["*".to_string()],
947 ..AccessRule::default()
948 });
949 config.rules.push(AccessRule {
950 id: "hide-secret-lead".to_string(),
951 effect: AccessEffect::Deny,
952 actions: vec!["agent.*".to_string()],
953 agents: vec!["secret-lead".to_string()],
954 ..AccessRule::default()
955 });
956 let controller = AccessController::new(config).expect("controller");
957 controller.record_agent_attributes(AgentResourceAttributes {
958 identity: "secret-lead".to_string(),
959 agent_id: Some("secret-lead".to_string()),
960 role: None,
961 labels: BTreeMap::new(),
962 });
963 controller.record_agent_attributes(AgentResourceAttributes {
964 identity: "covert-worker".to_string(),
965 agent_id: Some("covert-worker".to_string()),
966 role: None,
967 labels: BTreeMap::from([("spawned_by".to_string(), "secret-lead".to_string())]),
968 });
969
970 let bob = controller.view_for_subject(Some("bob@example.test"));
971 assert!(!bob.can_view_agent("secret-lead"));
972 assert!(
973 !bob.can_view_agent("covert-worker"),
974 "a deny on the spawning parent must propagate to its descendants"
975 );
976 }
977
978 #[test]
979 fn spawn_lineage_cycles_terminate_and_fail_closed() {
980 let controller = AccessController::new(enabled_config()).expect("controller");
981 controller.record_agent_attributes(AgentResourceAttributes {
982 identity: "loop-a".to_string(),
983 agent_id: Some("loop-a".to_string()),
984 role: None,
985 labels: BTreeMap::from([("spawned_by".to_string(), "loop-b".to_string())]),
986 });
987 controller.record_agent_attributes(AgentResourceAttributes {
988 identity: "loop-b".to_string(),
989 agent_id: Some("loop-b".to_string()),
990 role: None,
991 labels: BTreeMap::from([("spawned_by".to_string(), "loop-a".to_string())]),
992 });
993
994 let bob = controller.view_for_subject(Some("bob@example.test"));
995 assert!(
996 !bob.can_view_agent("loop-a"),
997 "lineage cycles must terminate and deny by default"
998 );
999 }
1000
1001 #[test]
1002 fn persist_is_atomic_via_temp_rename() {
1003 let dir = tempfile::tempdir().expect("tempdir");
1006 let path = dir.path().join("access.toml");
1007 let controller = AccessController::load_or_default(&path).expect("load");
1008 controller
1009 .replace_config(enabled_config())
1010 .expect("replace");
1011 assert!(path.is_file(), "target written");
1012 assert!(
1013 !dir.path().join("access.toml.tmp").exists(),
1014 "temp file cleaned up by rename"
1015 );
1016 let reloaded = AccessController::load_or_default(&path).expect("reload");
1017 assert!(reloaded.enabled());
1018 }
1019}