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 let resources = lineage
418 .iter()
419 .enumerate()
420 .map(|(index, link)| match link.attributes.as_deref() {
421 Some(attributes) => AccessResource {
422 identity: Some(attributes.identity.as_str()),
423 agent_id: attributes
424 .agent_id
425 .as_deref()
426 .or((index == 0).then_some(identity)),
427 role: attributes.role.as_deref(),
428 labels: Some(&attributes.labels),
429 },
430 None => AccessResource::for_identity(link.identity.as_str()),
431 })
432 .collect::<Vec<_>>();
433 super::engine::evaluate_access_lineage(&self.config, &self.principal, action, &resources)
434 }
435
436 fn lineage_for(&self, identity: &str) -> Vec<LineageLink> {
441 const MAX_LINEAGE_DEPTH: usize = 8;
442 let cache = self
443 .inner
444 .attributes
445 .read()
446 .unwrap_or_else(std::sync::PoisonError::into_inner);
447 let resolve = |key: &str| {
448 cache.get(key).cloned().or_else(|| {
449 cache
450 .values()
451 .find(|attributes| attributes.agent_id.as_deref() == Some(key))
452 .cloned()
453 })
454 };
455 let Some(own) = resolve(identity) else {
456 return Vec::new();
457 };
458 let mut visited = BTreeSet::from([own.identity.clone()]);
459 let mut lineage = vec![LineageLink {
460 identity: own.identity.clone(),
461 attributes: Some(own),
462 }];
463 while lineage.len() < MAX_LINEAGE_DEPTH {
464 let Some(parent) = lineage
465 .last()
466 .and_then(|link| link.attributes.as_deref())
467 .and_then(|attributes| attributes.labels.get("spawned_by"))
468 .map(|parent| parent.trim().to_string())
469 .filter(|parent| !parent.is_empty())
470 else {
471 break;
472 };
473 let attributes = resolve(&parent);
474 let parent_identity = attributes
475 .as_deref()
476 .map(|attributes| attributes.identity.clone())
477 .unwrap_or(parent);
478 if !visited.insert(parent_identity.clone()) {
479 break;
480 }
481 lineage.push(LineageLink {
482 identity: parent_identity,
483 attributes,
484 });
485 }
486 lineage
487 }
488
489 pub fn can_view_agent(&self, identity: &str) -> bool {
491 self.allows_agent(ACTION_AGENT_VIEW, identity)
492 }
493
494 pub fn knows_agent(&self, identity: &str) -> bool {
504 let cache = self
505 .inner
506 .attributes
507 .read()
508 .unwrap_or_else(std::sync::PoisonError::into_inner);
509 cache.contains_key(identity)
510 || cache
511 .values()
512 .any(|attributes| attributes.agent_id.as_deref() == Some(identity))
513 }
514
515 pub fn can_administer(&self) -> bool {
523 if self.is_admin {
524 return true;
525 }
526 if !self.config.enabled {
527 return self.config.admins.is_empty();
528 }
529 self.decide(super::model::ACTION_ACCESS_ADMIN, &AccessResource::none())
530 .is_allow()
531 }
532}
533
534impl std::fmt::Debug for AccessView {
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 f.debug_struct("AccessView")
537 .field("subject", &self.principal.subject)
538 .field("groups", &self.principal.groups)
539 .field("is_admin", &self.is_admin)
540 .field("enforced", &self.config.enabled)
541 .finish()
542 }
543}
544
545#[cfg(test)]
546#[allow(clippy::expect_used, clippy::unwrap_used)]
547mod tests {
548 use super::*;
549 use crate::access::model::AccessEffect;
550
551 fn enabled_config() -> AccessControlConfig {
552 AccessControlConfig {
553 enabled: true,
554 admins: vec!["root@example.test".to_string()],
555 groups: BTreeMap::from([(
556 "ops".to_string(),
557 AccessGroup {
558 description: None,
559 members: vec!["alice@example.test".to_string()],
560 },
561 )]),
562 rules: vec![AccessRule {
563 id: "ops-view-all".to_string(),
564 groups: vec!["ops".to_string()],
565 actions: vec!["agent.view".to_string()],
566 ..AccessRule::default()
567 }],
568 }
569 }
570
571 #[test]
572 fn view_resolves_groups_and_admin_flag() {
573 let controller = AccessController::new(enabled_config()).expect("controller");
574 let alice = controller.view_for_subject(Some("alice@example.test"));
575 assert!(alice.groups().contains("ops"));
576 assert!(!alice.is_admin());
577 assert!(alice.can_view_agent("identity:scout-1"));
578 assert!(!alice.allows_agent("agent.send", "identity:scout-1"));
579
580 let root = controller.view_for_subject(Some("root@example.test"));
581 assert!(root.is_admin());
582 assert!(root.allows("access.admin"));
583 }
584
585 #[test]
586 fn live_mutations_bump_revision_and_apply() {
587 let controller = AccessController::new(enabled_config()).expect("controller");
588 let bob = controller.view_for_subject(Some("bob@example.test"));
589 assert!(!bob.can_view_agent("identity:scout-1"));
590
591 let revision = controller
592 .set_group(
593 "ops",
594 AccessGroup {
595 description: None,
596 members: vec![
597 "alice@example.test".to_string(),
598 "bob@example.test".to_string(),
599 ],
600 },
601 )
602 .expect("set group");
603 assert_eq!(revision, 1);
604
605 let bob_after = controller.view_for_subject(Some("bob@example.test"));
608 assert!(bob_after.can_view_agent("identity:scout-1"));
609 assert!(!bob.can_view_agent("identity:scout-1"));
610 }
611
612 #[test]
613 fn delete_rule_unknown_id_errors() {
614 let controller = AccessController::new(enabled_config()).expect("controller");
615 assert_eq!(
616 controller.delete_rule("missing"),
617 Err(AccessConfigError::UnknownRule("missing".to_string()))
618 );
619 controller.delete_rule("ops-view-all").expect("delete");
620 let (config, revision) = controller.snapshot();
621 assert!(config.rules.is_empty());
622 assert_eq!(revision, 1);
623 }
624
625 #[test]
626 fn attribute_cache_feeds_label_selectors() {
627 let mut config = enabled_config();
628 config.rules.push(AccessRule {
629 id: "bob-payments".to_string(),
630 subjects: vec!["bob@example.test".to_string()],
631 actions: vec!["agent.view".to_string()],
632 match_labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
633 ..AccessRule::default()
634 });
635 let controller = AccessController::new(config).expect("controller");
636 let bob = controller.view_for_subject(Some("bob@example.test"));
637 assert!(!bob.can_view_agent("identity:pay-1"));
638
639 controller.record_agent_attributes(AgentResourceAttributes {
640 identity: "identity:pay-1".to_string(),
641 agent_id: Some("pay-1".to_string()),
642 role: Some("analyst".to_string()),
643 labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
644 });
645 assert!(bob.can_view_agent("identity:pay-1"));
646 assert!(!bob.can_view_agent("identity:other"));
647 }
648
649 #[test]
650 fn knows_agent_detects_cold_cache_so_label_deny_can_be_made_fail_closed() {
651 let mut config = enabled_config();
656 config.rules.push(AccessRule {
658 id: "anon-view-all".to_string(),
659 actions: vec!["agent.view".to_string()],
660 agents: vec!["*".to_string()],
661 ..AccessRule::default()
662 });
663 config.rules.push(AccessRule {
665 id: "deny-secret".to_string(),
666 effect: AccessEffect::Deny,
667 actions: vec!["agent.view".to_string()],
668 match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
669 ..AccessRule::default()
670 });
671 let controller = AccessController::new(config).expect("controller");
672 let view = controller.view_for_subject(None);
673
674 assert!(!view.knows_agent("identity:secret-1"));
677 assert!(
678 view.can_view_agent("identity:secret-1"),
679 "cold cache currently fails OPEN — this is what knows_agent() detects"
680 );
681
682 controller.record_agent_attributes(AgentResourceAttributes {
685 identity: "identity:secret-1".to_string(),
686 agent_id: Some("secret-1".to_string()),
687 role: Some("worker".to_string()),
688 labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
689 });
690
691 assert!(view.knows_agent("identity:secret-1"));
693 assert!(
694 !view.can_view_agent("identity:secret-1"),
695 "after re-prime the label-scoped deny must hide the member"
696 );
697 }
698
699 #[test]
700 fn persistence_round_trips() {
701 let dir = tempfile::tempdir().expect("tempdir");
702 let path = dir.path().join("config").join("access.toml");
703 let controller = AccessController::load_or_default(&path).expect("load default");
704 assert!(!controller.enabled());
705
706 let mut config = enabled_config();
707 config.rules.push(AccessRule {
708 id: "deny-secret".to_string(),
709 effect: AccessEffect::Deny,
710 actions: vec!["agent.*".to_string()],
711 agents: vec!["identity:secret".to_string()],
712 ..AccessRule::default()
713 });
714 controller.replace_config(config.clone()).expect("replace");
715
716 super::super::model::normalize_access_config_for_memory_actions(&mut config);
719 let reloaded = AccessController::load_or_default(&path).expect("reload");
720 let (reloaded_config, _) = reloaded.snapshot();
721 assert_eq!(*reloaded_config, config);
722 }
723
724 #[test]
725 fn lockout_protected_on_live_surface() {
726 let controller = AccessController::new(enabled_config()).expect("controller");
727 let mut config = (*controller.snapshot().0).clone();
728 config.admins.clear();
729 assert_eq!(
730 controller.replace_config(config),
731 Err(AccessConfigError::EnabledWithoutAdmins)
732 );
733 }
734
735 #[test]
736 fn concurrent_rule_upserts_do_not_lose_updates() {
737 let controller = AccessController::new(enabled_config()).expect("controller");
742 let base_rules = controller.snapshot().0.rules.len();
743 let threads: usize = 16;
744 let handles: Vec<_> = (0..threads)
745 .map(|i| {
746 let controller = controller.clone();
747 std::thread::spawn(move || {
748 controller
749 .upsert_rule(AccessRule {
750 id: format!("rule-{i}"),
751 actions: vec!["agent.view".to_string()],
752 agents: vec![format!("identity:agent-{i}")],
753 ..AccessRule::default()
754 })
755 .expect("upsert");
756 })
757 })
758 .collect();
759 for handle in handles {
760 handle.join().expect("thread");
761 }
762 let (config, revision) = controller.snapshot();
763 assert_eq!(
764 config.rules.len(),
765 base_rules + threads,
766 "every rule survived: {config:#?}"
767 );
768 assert_eq!(revision, threads as u64, "revision counts every commit");
769 for i in 0..threads {
770 assert!(
771 config
772 .rules
773 .iter()
774 .any(|rule| rule.id == format!("rule-{i}")),
775 "rule-{i} missing"
776 );
777 }
778 }
779
780 #[test]
781 fn spawn_lineage_inherits_parent_permissions() {
782 let mut config = enabled_config();
783 config.rules.push(AccessRule {
784 id: "bob-ops-lead".to_string(),
785 subjects: vec!["bob@example.test".to_string()],
786 actions: vec!["agent.view".to_string(), "agent.send".to_string()],
787 agents: vec!["ops-lead".to_string()],
788 ..AccessRule::default()
789 });
790 let controller = AccessController::new(config).expect("controller");
791 controller.record_agent_attributes(AgentResourceAttributes {
792 identity: "ops-lead".to_string(),
793 agent_id: Some("ops-lead".to_string()),
794 role: Some("orchestrator".to_string()),
795 labels: BTreeMap::new(),
796 });
797 controller.record_agent_attributes(AgentResourceAttributes {
798 identity: "worker-3".to_string(),
799 agent_id: Some("worker-3".to_string()),
800 role: Some("person-worker".to_string()),
801 labels: BTreeMap::from([("spawned_by".to_string(), "ops-lead".to_string())]),
802 });
803 controller.record_agent_attributes(AgentResourceAttributes {
804 identity: "worker-3-sub".to_string(),
805 agent_id: Some("worker-3-sub".to_string()),
806 role: Some("helper".to_string()),
807 labels: BTreeMap::from([("spawned_by".to_string(), "worker-3".to_string())]),
808 });
809 controller.record_agent_attributes(AgentResourceAttributes {
810 identity: "scout-1".to_string(),
811 agent_id: Some("scout-1".to_string()),
812 role: Some("scout".to_string()),
813 labels: BTreeMap::new(),
814 });
815
816 let bob = controller.view_for_subject(Some("bob@example.test"));
817 assert!(bob.can_view_agent("ops-lead"));
818 assert!(
819 bob.can_view_agent("worker-3"),
820 "a member spawned by ops-lead inherits ops-lead's visibility"
821 );
822 assert!(
823 bob.allows_agent("agent.send", "worker-3"),
824 "permission inheritance covers every agent action, not just view"
825 );
826 assert!(
827 bob.can_view_agent("worker-3-sub"),
828 "spawn lineage inheritance is transitive"
829 );
830 assert!(
831 !bob.can_view_agent("scout-1"),
832 "agents outside the spawn lineage stay denied"
833 );
834 }
835
836 #[test]
837 fn spawn_lineage_deny_on_parent_overrides_descendants() {
838 let mut config = enabled_config();
839 config.rules.push(AccessRule {
840 id: "bob-view-all".to_string(),
841 subjects: vec!["bob@example.test".to_string()],
842 actions: vec!["agent.view".to_string()],
843 agents: vec!["*".to_string()],
844 ..AccessRule::default()
845 });
846 config.rules.push(AccessRule {
847 id: "hide-secret-lead".to_string(),
848 effect: AccessEffect::Deny,
849 actions: vec!["agent.*".to_string()],
850 agents: vec!["secret-lead".to_string()],
851 ..AccessRule::default()
852 });
853 let controller = AccessController::new(config).expect("controller");
854 controller.record_agent_attributes(AgentResourceAttributes {
855 identity: "secret-lead".to_string(),
856 agent_id: Some("secret-lead".to_string()),
857 role: None,
858 labels: BTreeMap::new(),
859 });
860 controller.record_agent_attributes(AgentResourceAttributes {
861 identity: "covert-worker".to_string(),
862 agent_id: Some("covert-worker".to_string()),
863 role: None,
864 labels: BTreeMap::from([("spawned_by".to_string(), "secret-lead".to_string())]),
865 });
866
867 let bob = controller.view_for_subject(Some("bob@example.test"));
868 assert!(!bob.can_view_agent("secret-lead"));
869 assert!(
870 !bob.can_view_agent("covert-worker"),
871 "a deny on the spawning parent must propagate to its descendants"
872 );
873 }
874
875 #[test]
876 fn spawn_lineage_cycles_terminate_and_fail_closed() {
877 let controller = AccessController::new(enabled_config()).expect("controller");
878 controller.record_agent_attributes(AgentResourceAttributes {
879 identity: "loop-a".to_string(),
880 agent_id: Some("loop-a".to_string()),
881 role: None,
882 labels: BTreeMap::from([("spawned_by".to_string(), "loop-b".to_string())]),
883 });
884 controller.record_agent_attributes(AgentResourceAttributes {
885 identity: "loop-b".to_string(),
886 agent_id: Some("loop-b".to_string()),
887 role: None,
888 labels: BTreeMap::from([("spawned_by".to_string(), "loop-a".to_string())]),
889 });
890
891 let bob = controller.view_for_subject(Some("bob@example.test"));
892 assert!(
893 !bob.can_view_agent("loop-a"),
894 "lineage cycles must terminate and deny by default"
895 );
896 }
897
898 #[test]
899 fn persist_is_atomic_via_temp_rename() {
900 let dir = tempfile::tempdir().expect("tempdir");
903 let path = dir.path().join("access.toml");
904 let controller = AccessController::load_or_default(&path).expect("load");
905 controller
906 .replace_config(enabled_config())
907 .expect("replace");
908 assert!(path.is_file(), "target written");
909 assert!(
910 !dir.path().join("access.toml.tmp").exists(),
911 "temp file cleaned up by rename"
912 );
913 let reloaded = AccessController::load_or_default(&path).expect("reload");
914 assert!(reloaded.enabled());
915 }
916}