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(config: AccessControlConfig) -> Result<Self, AccessConfigError> {
70 validate_access_config(&config)?;
71 Ok(Self {
72 inner: Arc::new(AccessControllerInner {
73 state: RwLock::new(AccessState {
74 config: Arc::new(config),
75 revision: 0,
76 }),
77 persist_path: RwLock::new(None),
78 attributes: RwLock::new(BTreeMap::new()),
79 mutation: Mutex::new(()),
80 }),
81 })
82 }
83
84 pub fn disabled() -> Self {
86 Self::new(AccessControlConfig::default()).unwrap_or_else(|_| unreachable!())
87 }
88
89 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, AccessConfigError> {
93 let path = path.into();
94 let config = if path.is_file() {
95 let raw = std::fs::read_to_string(&path)
96 .map_err(|err| AccessConfigError::Io(err.to_string()))?;
97 toml::from_str::<AccessControlConfig>(&raw)
98 .map_err(|err| AccessConfigError::Parse(err.to_string()))?
99 } else {
100 AccessControlConfig::default()
101 };
102 let controller = Self::new(config)?;
103 *controller
104 .inner
105 .persist_path
106 .write()
107 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path);
108 Ok(controller)
109 }
110
111 pub fn with_persist_path(self, path: impl Into<PathBuf>) -> Self {
113 *self
114 .inner
115 .persist_path
116 .write()
117 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path.into());
118 self
119 }
120
121 pub fn snapshot(&self) -> (Arc<AccessControlConfig>, u64) {
123 let state = self
124 .inner
125 .state
126 .read()
127 .unwrap_or_else(std::sync::PoisonError::into_inner);
128 (Arc::clone(&state.config), state.revision)
129 }
130
131 pub fn enabled(&self) -> bool {
133 self.snapshot().0.enabled
134 }
135
136 pub fn replace_config(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
138 self.mutate(move |current| {
139 *current = config;
140 Ok(())
141 })
142 }
143
144 pub fn upsert_rule(&self, rule: AccessRule) -> Result<u64, AccessConfigError> {
146 self.mutate(move |config| {
147 match config
148 .rules
149 .iter_mut()
150 .find(|existing| existing.id == rule.id)
151 {
152 Some(existing) => *existing = rule,
153 None => config.rules.push(rule),
154 }
155 Ok(())
156 })
157 }
158
159 pub fn delete_rule(&self, rule_id: &str) -> Result<u64, AccessConfigError> {
161 self.mutate(|config| {
162 let before = config.rules.len();
163 config.rules.retain(|rule| rule.id != rule_id);
164 if config.rules.len() == before {
165 return Err(AccessConfigError::UnknownRule(rule_id.to_string()));
166 }
167 Ok(())
168 })
169 }
170
171 pub fn set_group(&self, name: &str, group: AccessGroup) -> Result<u64, AccessConfigError> {
173 self.mutate(move |config| {
174 config.groups.insert(name.to_string(), group);
175 Ok(())
176 })
177 }
178
179 pub fn delete_group(&self, name: &str) -> Result<u64, AccessConfigError> {
181 self.mutate(|config| {
182 config.groups.remove(name);
183 Ok(())
184 })
185 }
186
187 pub fn set_enabled(&self, enabled: bool) -> Result<u64, AccessConfigError> {
189 self.mutate(move |config| {
190 config.enabled = enabled;
191 Ok(())
192 })
193 }
194
195 fn mutate<F>(&self, mutator: F) -> Result<u64, AccessConfigError>
200 where
201 F: FnOnce(&mut AccessControlConfig) -> Result<(), AccessConfigError>,
202 {
203 let _mutation = self
204 .inner
205 .mutation
206 .lock()
207 .unwrap_or_else(std::sync::PoisonError::into_inner);
208 let mut config = (*self.snapshot().0).clone();
209 mutator(&mut config)?;
210 validate_access_config(&config)?;
211 self.commit(config)
212 }
213
214 fn commit(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
215 let persist_path = self
216 .inner
217 .persist_path
218 .read()
219 .unwrap_or_else(std::sync::PoisonError::into_inner)
220 .clone();
221 if let Some(path) = persist_path {
222 persist_config(&path, &config)?;
223 }
224 let mut state = self
225 .inner
226 .state
227 .write()
228 .unwrap_or_else(std::sync::PoisonError::into_inner);
229 state.config = Arc::new(config);
230 state.revision += 1;
231 Ok(state.revision)
232 }
233
234 pub fn view_for_subject(&self, subject: Option<&str>) -> AccessView {
237 let (config, _) = self.snapshot();
238 let principal = match subject {
239 Some(subject) => AccessPrincipal {
240 subject: Some(subject.to_string()),
241 groups: groups_for_subject(&config, subject),
242 },
243 None => AccessPrincipal::anonymous(),
244 };
245 let is_admin = principal
246 .subject
247 .as_deref()
248 .is_some_and(|subject| config.admins.iter().any(|admin| admin == subject));
249 AccessView {
250 inner: Arc::clone(&self.inner),
251 config,
252 principal,
253 is_admin,
254 }
255 }
256
257 pub fn record_agent_attributes(&self, attributes: AgentResourceAttributes) {
259 if attributes.identity.is_empty() {
260 return;
261 }
262 let mut cache = self
263 .inner
264 .attributes
265 .write()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 cache.insert(attributes.identity.clone(), Arc::new(attributes));
268 }
269
270 pub fn replace_agent_attributes(
279 &self,
280 attributes: impl IntoIterator<Item = AgentResourceAttributes>,
281 ) {
282 let next: BTreeMap<String, Arc<AgentResourceAttributes>> = attributes
283 .into_iter()
284 .filter(|entry| !entry.identity.is_empty())
285 .map(|entry| (entry.identity.clone(), Arc::new(entry)))
286 .collect();
287 if next.is_empty() {
288 return;
289 }
290 *self
291 .inner
292 .attributes
293 .write()
294 .unwrap_or_else(std::sync::PoisonError::into_inner) = next;
295 }
296}
297
298fn persist_config(path: &Path, config: &AccessControlConfig) -> Result<(), AccessConfigError> {
299 let rendered =
300 toml::to_string_pretty(config).map_err(|err| AccessConfigError::Parse(err.to_string()))?;
301 if let Some(parent) = path.parent()
302 && !parent.as_os_str().is_empty()
303 {
304 std::fs::create_dir_all(parent).map_err(|err| AccessConfigError::Io(err.to_string()))?;
305 }
306 let header = "# MobKit access control. Managed by the console Access panel;\n# hand edits are preserved until the next console save.\n\n";
307 let mut tmp = path.to_path_buf();
312 let mut tmp_name = path
313 .file_name()
314 .map(std::ffi::OsString::from)
315 .ok_or_else(|| {
316 AccessConfigError::Io(format!(
317 "access config path has no file name: {}",
318 path.display()
319 ))
320 })?;
321 tmp_name.push(".tmp");
322 tmp.set_file_name(tmp_name);
323 std::fs::write(&tmp, format!("{header}{rendered}"))
324 .map_err(|err| AccessConfigError::Io(err.to_string()))?;
325 std::fs::rename(&tmp, path).map_err(|err| AccessConfigError::Io(err.to_string()))
326}
327
328struct LineageLink {
332 identity: String,
333 attributes: Option<Arc<AgentResourceAttributes>>,
334}
335
336#[derive(Clone)]
342pub struct AccessView {
343 inner: Arc<AccessControllerInner>,
344 config: Arc<AccessControlConfig>,
345 principal: AccessPrincipal,
346 is_admin: bool,
347}
348
349impl AccessView {
350 pub fn enforced(&self) -> bool {
352 self.config.enabled
353 }
354
355 pub fn subject(&self) -> Option<&str> {
356 self.principal.subject.as_deref()
357 }
358
359 pub fn groups(&self) -> &BTreeSet<String> {
360 &self.principal.groups
361 }
362
363 pub fn is_admin(&self) -> bool {
364 self.is_admin
365 }
366
367 pub fn decide(&self, action: &str, resource: &AccessResource<'_>) -> AccessDecision {
369 evaluate_access(&self.config, &self.principal, action, resource)
370 }
371
372 pub fn allows(&self, action: &str) -> bool {
374 self.decide(action, &AccessResource::none()).is_allow()
375 }
376
377 pub fn may_perform_anywhere(&self, action: &str) -> bool {
382 self.is_admin || principal_may_perform(&self.config, &self.principal, action)
383 }
384
385 pub fn allows_agent(&self, action: &str, identity: &str) -> bool {
388 self.decide_agent(action, identity).is_allow()
389 }
390
391 pub fn decide_agent(&self, action: &str, identity: &str) -> AccessDecision {
401 if !self.config.enabled {
402 return AccessDecision::Allow;
403 }
404 let lineage = self.lineage_for(identity);
405 if lineage.is_empty() {
406 return self.decide(action, &AccessResource::for_identity(identity));
407 }
408 let resources = lineage
409 .iter()
410 .enumerate()
411 .map(|(index, link)| match link.attributes.as_deref() {
412 Some(attributes) => AccessResource {
413 identity: Some(attributes.identity.as_str()),
414 agent_id: attributes
415 .agent_id
416 .as_deref()
417 .or((index == 0).then_some(identity)),
418 role: attributes.role.as_deref(),
419 labels: Some(&attributes.labels),
420 },
421 None => AccessResource::for_identity(link.identity.as_str()),
422 })
423 .collect::<Vec<_>>();
424 super::engine::evaluate_access_lineage(&self.config, &self.principal, action, &resources)
425 }
426
427 fn lineage_for(&self, identity: &str) -> Vec<LineageLink> {
432 const MAX_LINEAGE_DEPTH: usize = 8;
433 let cache = self
434 .inner
435 .attributes
436 .read()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 let resolve = |key: &str| {
439 cache.get(key).cloned().or_else(|| {
440 cache
441 .values()
442 .find(|attributes| attributes.agent_id.as_deref() == Some(key))
443 .cloned()
444 })
445 };
446 let Some(own) = resolve(identity) else {
447 return Vec::new();
448 };
449 let mut visited = BTreeSet::from([own.identity.clone()]);
450 let mut lineage = vec![LineageLink {
451 identity: own.identity.clone(),
452 attributes: Some(own),
453 }];
454 while lineage.len() < MAX_LINEAGE_DEPTH {
455 let Some(parent) = lineage
456 .last()
457 .and_then(|link| link.attributes.as_deref())
458 .and_then(|attributes| attributes.labels.get("spawned_by"))
459 .map(|parent| parent.trim().to_string())
460 .filter(|parent| !parent.is_empty())
461 else {
462 break;
463 };
464 let attributes = resolve(&parent);
465 let parent_identity = attributes
466 .as_deref()
467 .map(|attributes| attributes.identity.clone())
468 .unwrap_or(parent);
469 if !visited.insert(parent_identity.clone()) {
470 break;
471 }
472 lineage.push(LineageLink {
473 identity: parent_identity,
474 attributes,
475 });
476 }
477 lineage
478 }
479
480 pub fn can_view_agent(&self, identity: &str) -> bool {
482 self.allows_agent(ACTION_AGENT_VIEW, identity)
483 }
484
485 pub fn knows_agent(&self, identity: &str) -> bool {
495 let cache = self
496 .inner
497 .attributes
498 .read()
499 .unwrap_or_else(std::sync::PoisonError::into_inner);
500 cache.contains_key(identity)
501 || cache
502 .values()
503 .any(|attributes| attributes.agent_id.as_deref() == Some(identity))
504 }
505
506 pub fn can_administer(&self) -> bool {
514 if self.is_admin {
515 return true;
516 }
517 if !self.config.enabled {
518 return self.config.admins.is_empty();
519 }
520 self.decide(super::model::ACTION_ACCESS_ADMIN, &AccessResource::none())
521 .is_allow()
522 }
523}
524
525impl std::fmt::Debug for AccessView {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 f.debug_struct("AccessView")
528 .field("subject", &self.principal.subject)
529 .field("groups", &self.principal.groups)
530 .field("is_admin", &self.is_admin)
531 .field("enforced", &self.config.enabled)
532 .finish()
533 }
534}
535
536#[cfg(test)]
537#[allow(clippy::expect_used, clippy::unwrap_used)]
538mod tests {
539 use super::*;
540 use crate::access::model::AccessEffect;
541
542 fn enabled_config() -> AccessControlConfig {
543 AccessControlConfig {
544 enabled: true,
545 admins: vec!["root@example.test".to_string()],
546 groups: BTreeMap::from([(
547 "ops".to_string(),
548 AccessGroup {
549 description: None,
550 members: vec!["alice@example.test".to_string()],
551 },
552 )]),
553 rules: vec![AccessRule {
554 id: "ops-view-all".to_string(),
555 groups: vec!["ops".to_string()],
556 actions: vec!["agent.view".to_string()],
557 ..AccessRule::default()
558 }],
559 }
560 }
561
562 #[test]
563 fn view_resolves_groups_and_admin_flag() {
564 let controller = AccessController::new(enabled_config()).expect("controller");
565 let alice = controller.view_for_subject(Some("alice@example.test"));
566 assert!(alice.groups().contains("ops"));
567 assert!(!alice.is_admin());
568 assert!(alice.can_view_agent("identity:scout-1"));
569 assert!(!alice.allows_agent("agent.send", "identity:scout-1"));
570
571 let root = controller.view_for_subject(Some("root@example.test"));
572 assert!(root.is_admin());
573 assert!(root.allows("access.admin"));
574 }
575
576 #[test]
577 fn live_mutations_bump_revision_and_apply() {
578 let controller = AccessController::new(enabled_config()).expect("controller");
579 let bob = controller.view_for_subject(Some("bob@example.test"));
580 assert!(!bob.can_view_agent("identity:scout-1"));
581
582 let revision = controller
583 .set_group(
584 "ops",
585 AccessGroup {
586 description: None,
587 members: vec![
588 "alice@example.test".to_string(),
589 "bob@example.test".to_string(),
590 ],
591 },
592 )
593 .expect("set group");
594 assert_eq!(revision, 1);
595
596 let bob_after = controller.view_for_subject(Some("bob@example.test"));
599 assert!(bob_after.can_view_agent("identity:scout-1"));
600 assert!(!bob.can_view_agent("identity:scout-1"));
601 }
602
603 #[test]
604 fn delete_rule_unknown_id_errors() {
605 let controller = AccessController::new(enabled_config()).expect("controller");
606 assert_eq!(
607 controller.delete_rule("missing"),
608 Err(AccessConfigError::UnknownRule("missing".to_string()))
609 );
610 controller.delete_rule("ops-view-all").expect("delete");
611 let (config, revision) = controller.snapshot();
612 assert!(config.rules.is_empty());
613 assert_eq!(revision, 1);
614 }
615
616 #[test]
617 fn attribute_cache_feeds_label_selectors() {
618 let mut config = enabled_config();
619 config.rules.push(AccessRule {
620 id: "bob-payments".to_string(),
621 subjects: vec!["bob@example.test".to_string()],
622 actions: vec!["agent.view".to_string()],
623 match_labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
624 ..AccessRule::default()
625 });
626 let controller = AccessController::new(config).expect("controller");
627 let bob = controller.view_for_subject(Some("bob@example.test"));
628 assert!(!bob.can_view_agent("identity:pay-1"));
629
630 controller.record_agent_attributes(AgentResourceAttributes {
631 identity: "identity:pay-1".to_string(),
632 agent_id: Some("pay-1".to_string()),
633 role: Some("analyst".to_string()),
634 labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
635 });
636 assert!(bob.can_view_agent("identity:pay-1"));
637 assert!(!bob.can_view_agent("identity:other"));
638 }
639
640 #[test]
641 fn knows_agent_detects_cold_cache_so_label_deny_can_be_made_fail_closed() {
642 let mut config = enabled_config();
647 config.rules.push(AccessRule {
649 id: "anon-view-all".to_string(),
650 actions: vec!["agent.view".to_string()],
651 agents: vec!["*".to_string()],
652 ..AccessRule::default()
653 });
654 config.rules.push(AccessRule {
656 id: "deny-secret".to_string(),
657 effect: AccessEffect::Deny,
658 actions: vec!["agent.view".to_string()],
659 match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
660 ..AccessRule::default()
661 });
662 let controller = AccessController::new(config).expect("controller");
663 let view = controller.view_for_subject(None);
664
665 assert!(!view.knows_agent("identity:secret-1"));
668 assert!(
669 view.can_view_agent("identity:secret-1"),
670 "cold cache currently fails OPEN — this is what knows_agent() detects"
671 );
672
673 controller.record_agent_attributes(AgentResourceAttributes {
676 identity: "identity:secret-1".to_string(),
677 agent_id: Some("secret-1".to_string()),
678 role: Some("worker".to_string()),
679 labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
680 });
681
682 assert!(view.knows_agent("identity:secret-1"));
684 assert!(
685 !view.can_view_agent("identity:secret-1"),
686 "after re-prime the label-scoped deny must hide the member"
687 );
688 }
689
690 #[test]
691 fn persistence_round_trips() {
692 let dir = tempfile::tempdir().expect("tempdir");
693 let path = dir.path().join("config").join("access.toml");
694 let controller = AccessController::load_or_default(&path).expect("load default");
695 assert!(!controller.enabled());
696
697 let mut config = enabled_config();
698 config.rules.push(AccessRule {
699 id: "deny-secret".to_string(),
700 effect: AccessEffect::Deny,
701 actions: vec!["agent.*".to_string()],
702 agents: vec!["identity:secret".to_string()],
703 ..AccessRule::default()
704 });
705 controller.replace_config(config.clone()).expect("replace");
706
707 let reloaded = AccessController::load_or_default(&path).expect("reload");
708 let (reloaded_config, _) = reloaded.snapshot();
709 assert_eq!(*reloaded_config, config);
710 }
711
712 #[test]
713 fn lockout_protected_on_live_surface() {
714 let controller = AccessController::new(enabled_config()).expect("controller");
715 let mut config = (*controller.snapshot().0).clone();
716 config.admins.clear();
717 assert_eq!(
718 controller.replace_config(config),
719 Err(AccessConfigError::EnabledWithoutAdmins)
720 );
721 }
722
723 #[test]
724 fn concurrent_rule_upserts_do_not_lose_updates() {
725 let controller = AccessController::new(enabled_config()).expect("controller");
730 let base_rules = controller.snapshot().0.rules.len();
731 let threads: usize = 16;
732 let handles: Vec<_> = (0..threads)
733 .map(|i| {
734 let controller = controller.clone();
735 std::thread::spawn(move || {
736 controller
737 .upsert_rule(AccessRule {
738 id: format!("rule-{i}"),
739 actions: vec!["agent.view".to_string()],
740 agents: vec![format!("identity:agent-{i}")],
741 ..AccessRule::default()
742 })
743 .expect("upsert");
744 })
745 })
746 .collect();
747 for handle in handles {
748 handle.join().expect("thread");
749 }
750 let (config, revision) = controller.snapshot();
751 assert_eq!(
752 config.rules.len(),
753 base_rules + threads,
754 "every rule survived: {config:#?}"
755 );
756 assert_eq!(revision, threads as u64, "revision counts every commit");
757 for i in 0..threads {
758 assert!(
759 config
760 .rules
761 .iter()
762 .any(|rule| rule.id == format!("rule-{i}")),
763 "rule-{i} missing"
764 );
765 }
766 }
767
768 #[test]
769 fn spawn_lineage_inherits_parent_permissions() {
770 let mut config = enabled_config();
771 config.rules.push(AccessRule {
772 id: "bob-ops-lead".to_string(),
773 subjects: vec!["bob@example.test".to_string()],
774 actions: vec!["agent.view".to_string(), "agent.send".to_string()],
775 agents: vec!["ops-lead".to_string()],
776 ..AccessRule::default()
777 });
778 let controller = AccessController::new(config).expect("controller");
779 controller.record_agent_attributes(AgentResourceAttributes {
780 identity: "ops-lead".to_string(),
781 agent_id: Some("ops-lead".to_string()),
782 role: Some("orchestrator".to_string()),
783 labels: BTreeMap::new(),
784 });
785 controller.record_agent_attributes(AgentResourceAttributes {
786 identity: "worker-3".to_string(),
787 agent_id: Some("worker-3".to_string()),
788 role: Some("person-worker".to_string()),
789 labels: BTreeMap::from([("spawned_by".to_string(), "ops-lead".to_string())]),
790 });
791 controller.record_agent_attributes(AgentResourceAttributes {
792 identity: "worker-3-sub".to_string(),
793 agent_id: Some("worker-3-sub".to_string()),
794 role: Some("helper".to_string()),
795 labels: BTreeMap::from([("spawned_by".to_string(), "worker-3".to_string())]),
796 });
797 controller.record_agent_attributes(AgentResourceAttributes {
798 identity: "scout-1".to_string(),
799 agent_id: Some("scout-1".to_string()),
800 role: Some("scout".to_string()),
801 labels: BTreeMap::new(),
802 });
803
804 let bob = controller.view_for_subject(Some("bob@example.test"));
805 assert!(bob.can_view_agent("ops-lead"));
806 assert!(
807 bob.can_view_agent("worker-3"),
808 "a member spawned by ops-lead inherits ops-lead's visibility"
809 );
810 assert!(
811 bob.allows_agent("agent.send", "worker-3"),
812 "permission inheritance covers every agent action, not just view"
813 );
814 assert!(
815 bob.can_view_agent("worker-3-sub"),
816 "spawn lineage inheritance is transitive"
817 );
818 assert!(
819 !bob.can_view_agent("scout-1"),
820 "agents outside the spawn lineage stay denied"
821 );
822 }
823
824 #[test]
825 fn spawn_lineage_deny_on_parent_overrides_descendants() {
826 let mut config = enabled_config();
827 config.rules.push(AccessRule {
828 id: "bob-view-all".to_string(),
829 subjects: vec!["bob@example.test".to_string()],
830 actions: vec!["agent.view".to_string()],
831 agents: vec!["*".to_string()],
832 ..AccessRule::default()
833 });
834 config.rules.push(AccessRule {
835 id: "hide-secret-lead".to_string(),
836 effect: AccessEffect::Deny,
837 actions: vec!["agent.*".to_string()],
838 agents: vec!["secret-lead".to_string()],
839 ..AccessRule::default()
840 });
841 let controller = AccessController::new(config).expect("controller");
842 controller.record_agent_attributes(AgentResourceAttributes {
843 identity: "secret-lead".to_string(),
844 agent_id: Some("secret-lead".to_string()),
845 role: None,
846 labels: BTreeMap::new(),
847 });
848 controller.record_agent_attributes(AgentResourceAttributes {
849 identity: "covert-worker".to_string(),
850 agent_id: Some("covert-worker".to_string()),
851 role: None,
852 labels: BTreeMap::from([("spawned_by".to_string(), "secret-lead".to_string())]),
853 });
854
855 let bob = controller.view_for_subject(Some("bob@example.test"));
856 assert!(!bob.can_view_agent("secret-lead"));
857 assert!(
858 !bob.can_view_agent("covert-worker"),
859 "a deny on the spawning parent must propagate to its descendants"
860 );
861 }
862
863 #[test]
864 fn spawn_lineage_cycles_terminate_and_fail_closed() {
865 let controller = AccessController::new(enabled_config()).expect("controller");
866 controller.record_agent_attributes(AgentResourceAttributes {
867 identity: "loop-a".to_string(),
868 agent_id: Some("loop-a".to_string()),
869 role: None,
870 labels: BTreeMap::from([("spawned_by".to_string(), "loop-b".to_string())]),
871 });
872 controller.record_agent_attributes(AgentResourceAttributes {
873 identity: "loop-b".to_string(),
874 agent_id: Some("loop-b".to_string()),
875 role: None,
876 labels: BTreeMap::from([("spawned_by".to_string(), "loop-a".to_string())]),
877 });
878
879 let bob = controller.view_for_subject(Some("bob@example.test"));
880 assert!(
881 !bob.can_view_agent("loop-a"),
882 "lineage cycles must terminate and deny by default"
883 );
884 }
885
886 #[test]
887 fn persist_is_atomic_via_temp_rename() {
888 let dir = tempfile::tempdir().expect("tempdir");
891 let path = dir.path().join("access.toml");
892 let controller = AccessController::load_or_default(&path).expect("load");
893 controller
894 .replace_config(enabled_config())
895 .expect("replace");
896 assert!(path.is_file(), "target written");
897 assert!(
898 !dir.path().join("access.toml.tmp").exists(),
899 "temp file cleaned up by rename"
900 );
901 let reloaded = AccessController::load_or_default(&path).expect("reload");
902 assert!(reloaded.enabled());
903 }
904}