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(mut config: AccessControlConfig) -> Result<Self, AccessConfigError> {
70        // §10.3 migration: memory-naive configs (written before the memory
71        // read actions existed) get `agent.memory.read` alongside
72        // `agent.view`; see `normalize_access_config_for_memory_actions`.
73        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    /// Create a disabled controller (feature off until an admin enables it).
89    pub fn disabled() -> Self {
90        Self::new(AccessControlConfig::default()).unwrap_or_else(|_| unreachable!())
91    }
92
93    /// Load a controller from a TOML file, remembering the path so future
94    /// admin mutations persist back to it. A missing file yields a default
95    /// (disabled) config that is written on first mutation.
96    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    /// Set (or replace) the persistence path.
116    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    /// Current config and revision.
126    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    /// True when checks are actually enforced.
136    pub fn enabled(&self) -> bool {
137        self.snapshot().0.enabled
138    }
139
140    /// Replace the whole configuration (admin surface).
141    pub fn replace_config(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
142        self.mutate(move |current| {
143            *current = config;
144            Ok(())
145        })
146    }
147
148    /// Insert or update one rule by id.
149    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    /// Delete one rule by id.
164    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    /// Create or replace a group (the live per-user assignment surface).
176    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    /// Delete a group. Fails while rules still reference it.
184    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    /// Toggle enforcement. Enabling validates the anti-lockout invariant.
192    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    /// Serialized read-modify-write. Holds the mutation lock across the
200    /// snapshot, the caller's edit, validation, persistence, and the
201    /// in-memory swap, so concurrent mutations can neither lose an update
202    /// nor diverge memory from disk.
203    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        // Same §10.3 compat rewrite as construction, so a memory-naive
215        // config replaced over the admin RPC behaves like one loaded from
216        // disk. Self-limiting: normalized configs mention memory actions
217        // and pass through untouched.
218        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    /// Build the per-request view for an authenticated subject (or `None`
244    /// for an open/unauthenticated console).
245    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    /// Refresh the cached resource attributes for one agent.
267    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    /// Replace the entire attribute cache from a fresh roster projection.
280    ///
281    /// Used by the per-request priming at the SSE/RPC/timeline seams: it
282    /// both fills attributes for label/role evaluation and evicts entries
283    /// for agents no longer in the roster, so a retired-then-reused identity
284    /// can't keep stale role/labels alive and the cache can't grow without
285    /// bound. A no-op when given an empty roster, so a transient empty
286    /// projection never blanks a populated cache.
287    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    // Write to a sibling temp file then rename over the target so a crash or
317    // concurrent reader never observes a half-written (truncated) config.
318    // Mutations are serialized by the mutation lock, so the fixed temp name
319    // has no racing writer.
320    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
337/// One link in an agent's spawn lineage: the agent itself first, then its
338/// spawn ancestors. Ancestors may be uncached (identity known only from a
339/// child's `spawned_by` label).
340struct LineageLink {
341    identity: String,
342    attributes: Option<Arc<AgentResourceAttributes>>,
343}
344
345/// An immutable per-request snapshot of one principal's access.
346///
347/// Holds the config `Arc` taken at request start so a single request
348/// evaluates against one consistent config, plus a handle to the shared
349/// attribute cache for label/role lookups by identity.
350#[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    /// True when this view actually enforces anything.
360    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    /// Full check against explicit resource attributes.
377    pub fn decide(&self, action: &str, resource: &AccessResource<'_>) -> AccessDecision {
378        evaluate_access(&self.config, &self.principal, action, resource)
379    }
380
381    /// Check an action with no resource (e.g. `gating.decide`).
382    pub fn allows(&self, action: &str) -> bool {
383        self.decide(action, &AccessResource::none()).is_allow()
384    }
385
386    /// Coarse capability check: could this principal perform `action` against
387    /// at least one resource? Used to intersect capability advertisements
388    /// (`mobkit/capabilities`) so the console doesn't surface affordances the
389    /// caller can never use; per-resource enforcement still applies per call.
390    pub fn may_perform_anywhere(&self, action: &str) -> bool {
391        self.is_admin || principal_may_perform(&self.config, &self.principal, action)
392    }
393
394    /// Check an action against an agent identity, resolving cached
395    /// attributes (role/labels) when available.
396    pub fn allows_agent(&self, action: &str, identity: &str) -> bool {
397        self.decide_agent(action, identity).is_allow()
398    }
399
400    /// Full decision for an action against an agent identity, resolving
401    /// cached attributes (role/labels) when available. The argument may
402    /// also be a runtime agent/member id; the cache resolves it back to
403    /// the identity it belongs to.
404    ///
405    /// Agents carry their spawn lineage as a `spawned_by` label (recorded by
406    /// the agent-tool spawn path). A spawned member inherits its spawning
407    /// parent's permissions: rules that match the parent — or any ancestor —
408    /// also match the member, with deny-overrides preserved across the chain.
409    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    /// Resolve the agent's cached attributes followed by its spawn ancestors
437    /// (`spawned_by` chain). Bounded and cycle-safe. An ancestor without
438    /// cached attributes still contributes an identity-only resource so
439    /// identity-selector rules naming the parent apply to its descendants.
440    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    /// Convenience: can this principal see the given agent at all?
490    pub fn can_view_agent(&self, identity: &str) -> bool {
491        self.allows_agent(ACTION_AGENT_VIEW, identity)
492    }
493
494    /// True when the agent's resource attributes (role/labels) are present in
495    /// the shared attribute cache, keyed by identity or projected `agent_id`.
496    ///
497    /// A cache-MISS means `decide_agent` falls back to a bare-identity resource
498    /// with `role: None, labels: None`, so a label/role-scoped deny rule fails
499    /// the rule closed and DOES NOT match — i.e. the agent is not actually
500    /// hidden. Long-lived SSE streams use this to detect a member spawned after
501    /// the one-time subscribe prime and re-prime the cache before deciding, so
502    /// the deny resolves against real attributes instead of failing open.
503    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    /// Can this principal read and edit the access configuration?
516    ///
517    /// Admins always can. While enforcement is enabled, subjects granted
518    /// `access.admin` by rule also can. While the feature is *disabled* and
519    /// no admins are configured yet, any caller can — this is the bootstrap
520    /// path that lets a fresh deployment configure itself from the console
521    /// before flipping enforcement on (enabling requires naming admins).
522    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        // New views pick up the change immediately; the old snapshot stays
606        // consistent for the request it was created for.
607        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        // A broad allow + a label-scoped DENY. On a cold cache the deny cannot
652        // match (no labels), so the member would FAIL OPEN (visible). The
653        // long-lived SSE streams use `knows_agent` to detect this cold state
654        // and re-prime before deciding so the deny resolves fail-closed.
655        let mut config = enabled_config();
656        // Everyone-views-all (subject-only allow).
657        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        // Deny view of any member labeled org=secret.
664        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        // Cold cache: the secret member is unknown, so the label-scoped deny
675        // does NOT match and the broad allow leaks it (the fail-open bug).
676        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        // The SSE re-prime path records the member's real attributes (here via
683        // record_agent_attributes, which the prime ultimately calls).
684        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        // Now the agent is known and the deny resolves fail-closed.
692        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        // The accepted config is the §10.3-normalized one (this fixture is
717        // memory-naive, so `agent.memory.read` rides its view rule).
718        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        // Each thread upserts a distinct rule. Without serialized
738        // read-modify-write, the clone-under-read + unconditional swap would
739        // drop deltas; with the mutation lock every committed rule survives
740        // and the revision equals the number of successful commits.
741        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        // A successful persist leaves no temp file behind and the target is
901        // a complete, parseable config (temp+rename, not truncate-in-place).
902        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}