Skip to main content

meerkat_mobkit/access/
controller.rs

1//! Shared access-control handle: live config, persistence, attribute cache.
2
3use 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/// Cached resource attributes for one agent, keyed by console identity.
17///
18/// The console surfaces refresh this cache opportunistically whenever they
19/// project a roster snapshot, so label/role selectors evaluate against the
20/// most recent known attributes even on surfaces that only carry an
21/// identity string (timeline frames, SSE streams, send requests).
22#[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    /// Serializes the read-modify-write of every config mutation so two
40    /// concurrent admin edits can't lose an update (clone-under-read then
41    /// unconditional swap would otherwise drop one writer's delta) and so
42    /// disk persistence and the in-memory swap stay ordered together.
43    mutation: Mutex<()>,
44}
45
46/// Shared, cheaply clonable handle to the live access-control state.
47///
48/// `None`/absent controller or a disabled config means the feature is off
49/// and every surface behaves exactly as before. All mutations validate,
50/// bump the revision, and persist to the configured TOML path (if any).
51#[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    /// Create a controller from a validated config.
69    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    /// Create a disabled controller (feature off until an admin enables it).
85    pub fn disabled() -> Self {
86        Self::new(AccessControlConfig::default()).unwrap_or_else(|_| unreachable!())
87    }
88
89    /// Load a controller from a TOML file, remembering the path so future
90    /// admin mutations persist back to it. A missing file yields a default
91    /// (disabled) config that is written on first mutation.
92    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    /// Set (or replace) the persistence path.
112    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    /// Current config and revision.
122    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    /// True when checks are actually enforced.
132    pub fn enabled(&self) -> bool {
133        self.snapshot().0.enabled
134    }
135
136    /// Replace the whole configuration (admin surface).
137    pub fn replace_config(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
138        self.mutate(move |current| {
139            *current = config;
140            Ok(())
141        })
142    }
143
144    /// Insert or update one rule by id.
145    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    /// Delete one rule by id.
160    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    /// Create or replace a group (the live per-user assignment surface).
172    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    /// Delete a group. Fails while rules still reference it.
180    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    /// Toggle enforcement. Enabling validates the anti-lockout invariant.
188    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    /// Serialized read-modify-write. Holds the mutation lock across the
196    /// snapshot, the caller's edit, validation, persistence, and the
197    /// in-memory swap, so concurrent mutations can neither lose an update
198    /// nor diverge memory from disk.
199    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    /// Build the per-request view for an authenticated subject (or `None`
235    /// for an open/unauthenticated console).
236    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    /// Refresh the cached resource attributes for one agent.
258    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    /// Replace the entire attribute cache from a fresh roster projection.
271    ///
272    /// Used by the per-request priming at the SSE/RPC/timeline seams: it
273    /// both fills attributes for label/role evaluation and evicts entries
274    /// for agents no longer in the roster, so a retired-then-reused identity
275    /// can't keep stale role/labels alive and the cache can't grow without
276    /// bound. A no-op when given an empty roster, so a transient empty
277    /// projection never blanks a populated cache.
278    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    // Write to a sibling temp file then rename over the target so a crash or
308    // concurrent reader never observes a half-written (truncated) config.
309    // Mutations are serialized by the mutation lock, so the fixed temp name
310    // has no racing writer.
311    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
328/// One link in an agent's spawn lineage: the agent itself first, then its
329/// spawn ancestors. Ancestors may be uncached (identity known only from a
330/// child's `spawned_by` label).
331struct LineageLink {
332    identity: String,
333    attributes: Option<Arc<AgentResourceAttributes>>,
334}
335
336/// An immutable per-request snapshot of one principal's access.
337///
338/// Holds the config `Arc` taken at request start so a single request
339/// evaluates against one consistent config, plus a handle to the shared
340/// attribute cache for label/role lookups by identity.
341#[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    /// True when this view actually enforces anything.
351    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    /// Full check against explicit resource attributes.
368    pub fn decide(&self, action: &str, resource: &AccessResource<'_>) -> AccessDecision {
369        evaluate_access(&self.config, &self.principal, action, resource)
370    }
371
372    /// Check an action with no resource (e.g. `gating.decide`).
373    pub fn allows(&self, action: &str) -> bool {
374        self.decide(action, &AccessResource::none()).is_allow()
375    }
376
377    /// Coarse capability check: could this principal perform `action` against
378    /// at least one resource? Used to intersect capability advertisements
379    /// (`mobkit/capabilities`) so the console doesn't surface affordances the
380    /// caller can never use; per-resource enforcement still applies per call.
381    pub fn may_perform_anywhere(&self, action: &str) -> bool {
382        self.is_admin || principal_may_perform(&self.config, &self.principal, action)
383    }
384
385    /// Check an action against an agent identity, resolving cached
386    /// attributes (role/labels) when available.
387    pub fn allows_agent(&self, action: &str, identity: &str) -> bool {
388        self.decide_agent(action, identity).is_allow()
389    }
390
391    /// Full decision for an action against an agent identity, resolving
392    /// cached attributes (role/labels) when available. The argument may
393    /// also be a runtime agent/member id; the cache resolves it back to
394    /// the identity it belongs to.
395    ///
396    /// Agents carry their spawn lineage as a `spawned_by` label (recorded by
397    /// the agent-tool spawn path). A spawned member inherits its spawning
398    /// parent's permissions: rules that match the parent — or any ancestor —
399    /// also match the member, with deny-overrides preserved across the chain.
400    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    /// Resolve the agent's cached attributes followed by its spawn ancestors
428    /// (`spawned_by` chain). Bounded and cycle-safe. An ancestor without
429    /// cached attributes still contributes an identity-only resource so
430    /// identity-selector rules naming the parent apply to its descendants.
431    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    /// Convenience: can this principal see the given agent at all?
481    pub fn can_view_agent(&self, identity: &str) -> bool {
482        self.allows_agent(ACTION_AGENT_VIEW, identity)
483    }
484
485    /// True when the agent's resource attributes (role/labels) are present in
486    /// the shared attribute cache, keyed by identity or projected `agent_id`.
487    ///
488    /// A cache-MISS means `decide_agent` falls back to a bare-identity resource
489    /// with `role: None, labels: None`, so a label/role-scoped deny rule fails
490    /// the rule closed and DOES NOT match — i.e. the agent is not actually
491    /// hidden. Long-lived SSE streams use this to detect a member spawned after
492    /// the one-time subscribe prime and re-prime the cache before deciding, so
493    /// the deny resolves against real attributes instead of failing open.
494    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    /// Can this principal read and edit the access configuration?
507    ///
508    /// Admins always can. While enforcement is enabled, subjects granted
509    /// `access.admin` by rule also can. While the feature is *disabled* and
510    /// no admins are configured yet, any caller can — this is the bootstrap
511    /// path that lets a fresh deployment configure itself from the console
512    /// before flipping enforcement on (enabling requires naming admins).
513    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        // New views pick up the change immediately; the old snapshot stays
597        // consistent for the request it was created for.
598        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        // A broad allow + a label-scoped DENY. On a cold cache the deny cannot
643        // match (no labels), so the member would FAIL OPEN (visible). The
644        // long-lived SSE streams use `knows_agent` to detect this cold state
645        // and re-prime before deciding so the deny resolves fail-closed.
646        let mut config = enabled_config();
647        // Everyone-views-all (subject-only allow).
648        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        // Deny view of any member labeled org=secret.
655        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        // Cold cache: the secret member is unknown, so the label-scoped deny
666        // does NOT match and the broad allow leaks it (the fail-open bug).
667        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        // The SSE re-prime path records the member's real attributes (here via
674        // record_agent_attributes, which the prime ultimately calls).
675        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        // Now the agent is known and the deny resolves fail-closed.
683        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        // Each thread upserts a distinct rule. Without serialized
726        // read-modify-write, the clone-under-read + unconditional swap would
727        // drop deltas; with the mutation lock every committed rule survives
728        // and the revision equals the number of successful commits.
729        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        // A successful persist leaves no temp file behind and the target is
889        // a complete, parseable config (temp+rename, not truncate-in-place).
890        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}