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        self.decide_agent_lineage(action, identity, &lineage)
418    }
419
420    /// Full decision for an action against an exact, caller-supplied agent
421    /// attribute snapshot.
422    ///
423    /// Event authorization uses this after binding an event's runtime id and
424    /// fence token to one concrete roster entry. The event's own role and
425    /// labels therefore never come from the alias-keyed shared cache, where a
426    /// later incarnation of the same alias could otherwise replace the
427    /// authority being evaluated. Trusted cached ancestors still participate
428    /// in spawn-lineage inheritance.
429    pub(crate) fn decide_agent_with_attributes(
430        &self,
431        action: &str,
432        attributes: &AgentResourceAttributes,
433    ) -> AccessDecision {
434        if !self.config.enabled {
435            return AccessDecision::Allow;
436        }
437        let lineage = self.lineage_for_attributes(attributes);
438        self.decide_agent_lineage(action, attributes.identity.as_str(), &lineage)
439    }
440
441    fn decide_agent_lineage(
442        &self,
443        action: &str,
444        fallback_identity: &str,
445        lineage: &[LineageLink],
446    ) -> AccessDecision {
447        let resources = lineage
448            .iter()
449            .enumerate()
450            .map(|(index, link)| match link.attributes.as_deref() {
451                Some(attributes) => AccessResource {
452                    identity: Some(attributes.identity.as_str()),
453                    agent_id: attributes
454                        .agent_id
455                        .as_deref()
456                        .or((index == 0).then_some(fallback_identity)),
457                    role: attributes.role.as_deref(),
458                    labels: Some(&attributes.labels),
459                },
460                None => AccessResource::for_identity(link.identity.as_str()),
461            })
462            .collect::<Vec<_>>();
463        super::engine::evaluate_access_lineage(&self.config, &self.principal, action, &resources)
464    }
465
466    /// Resolve the agent's cached attributes followed by its spawn ancestors
467    /// (`spawned_by` chain). Bounded and cycle-safe. An ancestor without
468    /// cached attributes still contributes an identity-only resource so
469    /// identity-selector rules naming the parent apply to its descendants.
470    fn lineage_for(&self, identity: &str) -> Vec<LineageLink> {
471        let cache = self
472            .inner
473            .attributes
474            .read()
475            .unwrap_or_else(std::sync::PoisonError::into_inner);
476        let resolve = |key: &str| {
477            cache.get(key).cloned().or_else(|| {
478                cache
479                    .values()
480                    .find(|attributes| attributes.agent_id.as_deref() == Some(key))
481                    .cloned()
482            })
483        };
484        let Some(own) = resolve(identity) else {
485            return Vec::new();
486        };
487        Self::lineage_from_attributes(own, &resolve)
488    }
489
490    /// Resolve spawn ancestors while pinning the first lineage link to the
491    /// supplied exact snapshot. In particular, do not resolve the first link
492    /// by identity or agent id from the shared cache.
493    fn lineage_for_attributes(&self, attributes: &AgentResourceAttributes) -> Vec<LineageLink> {
494        let cache = self
495            .inner
496            .attributes
497            .read()
498            .unwrap_or_else(std::sync::PoisonError::into_inner);
499        let resolve = |key: &str| {
500            cache.get(key).cloned().or_else(|| {
501                cache
502                    .values()
503                    .find(|candidate| candidate.agent_id.as_deref() == Some(key))
504                    .cloned()
505            })
506        };
507        Self::lineage_from_attributes(Arc::new(attributes.clone()), &resolve)
508    }
509
510    fn lineage_from_attributes(
511        own: Arc<AgentResourceAttributes>,
512        resolve: &impl Fn(&str) -> Option<Arc<AgentResourceAttributes>>,
513    ) -> Vec<LineageLink> {
514        const MAX_LINEAGE_DEPTH: usize = 8;
515        let mut visited = BTreeSet::from([own.identity.clone()]);
516        let mut lineage = vec![LineageLink {
517            identity: own.identity.clone(),
518            attributes: Some(own),
519        }];
520        while lineage.len() < MAX_LINEAGE_DEPTH {
521            let Some(parent) = lineage
522                .last()
523                .and_then(|link| link.attributes.as_deref())
524                .and_then(|attributes| attributes.labels.get("spawned_by"))
525                .map(|parent| parent.trim().to_string())
526                .filter(|parent| !parent.is_empty())
527            else {
528                break;
529            };
530            let attributes = resolve(&parent);
531            let parent_identity = attributes
532                .as_deref()
533                .map(|attributes| attributes.identity.clone())
534                .unwrap_or(parent);
535            if !visited.insert(parent_identity.clone()) {
536                break;
537            }
538            lineage.push(LineageLink {
539                identity: parent_identity,
540                attributes,
541            });
542        }
543        lineage
544    }
545
546    /// Convenience: can this principal see the given agent at all?
547    pub fn can_view_agent(&self, identity: &str) -> bool {
548        self.allows_agent(ACTION_AGENT_VIEW, identity)
549    }
550
551    /// True when the agent's resource attributes (role/labels) are present in
552    /// the shared attribute cache, keyed by identity or projected `agent_id`.
553    ///
554    /// A cache-MISS means `decide_agent` falls back to a bare-identity resource
555    /// with `role: None, labels: None`, so a label/role-scoped deny rule fails
556    /// the rule closed and DOES NOT match — i.e. the agent is not actually
557    /// hidden. Long-lived SSE streams use this to detect a member spawned after
558    /// the one-time subscribe prime and re-prime the cache before deciding, so
559    /// the deny resolves against real attributes instead of failing open.
560    pub fn knows_agent(&self, identity: &str) -> bool {
561        let cache = self
562            .inner
563            .attributes
564            .read()
565            .unwrap_or_else(std::sync::PoisonError::into_inner);
566        cache.contains_key(identity)
567            || cache
568                .values()
569                .any(|attributes| attributes.agent_id.as_deref() == Some(identity))
570    }
571
572    /// Can this principal read and edit the access configuration?
573    ///
574    /// Admins always can. While enforcement is enabled, subjects granted
575    /// `access.admin` by rule also can. While the feature is *disabled* and
576    /// no admins are configured yet, any caller can — this is the bootstrap
577    /// path that lets a fresh deployment configure itself from the console
578    /// before flipping enforcement on (enabling requires naming admins).
579    pub fn can_administer(&self) -> bool {
580        if self.is_admin {
581            return true;
582        }
583        if !self.config.enabled {
584            return self.config.admins.is_empty();
585        }
586        self.decide(super::model::ACTION_ACCESS_ADMIN, &AccessResource::none())
587            .is_allow()
588    }
589}
590
591impl std::fmt::Debug for AccessView {
592    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593        f.debug_struct("AccessView")
594            .field("subject", &self.principal.subject)
595            .field("groups", &self.principal.groups)
596            .field("is_admin", &self.is_admin)
597            .field("enforced", &self.config.enabled)
598            .finish()
599    }
600}
601
602#[cfg(test)]
603#[allow(clippy::expect_used, clippy::unwrap_used)]
604mod tests {
605    use super::*;
606    use crate::access::model::AccessEffect;
607
608    fn enabled_config() -> AccessControlConfig {
609        AccessControlConfig {
610            enabled: true,
611            admins: vec!["root@example.test".to_string()],
612            groups: BTreeMap::from([(
613                "ops".to_string(),
614                AccessGroup {
615                    description: None,
616                    members: vec!["alice@example.test".to_string()],
617                },
618            )]),
619            rules: vec![AccessRule {
620                id: "ops-view-all".to_string(),
621                groups: vec!["ops".to_string()],
622                actions: vec!["agent.view".to_string()],
623                ..AccessRule::default()
624            }],
625        }
626    }
627
628    #[test]
629    fn view_resolves_groups_and_admin_flag() {
630        let controller = AccessController::new(enabled_config()).expect("controller");
631        let alice = controller.view_for_subject(Some("alice@example.test"));
632        assert!(alice.groups().contains("ops"));
633        assert!(!alice.is_admin());
634        assert!(alice.can_view_agent("identity:scout-1"));
635        assert!(!alice.allows_agent("agent.send", "identity:scout-1"));
636
637        let root = controller.view_for_subject(Some("root@example.test"));
638        assert!(root.is_admin());
639        assert!(root.allows("access.admin"));
640    }
641
642    #[test]
643    fn live_mutations_bump_revision_and_apply() {
644        let controller = AccessController::new(enabled_config()).expect("controller");
645        let bob = controller.view_for_subject(Some("bob@example.test"));
646        assert!(!bob.can_view_agent("identity:scout-1"));
647
648        let revision = controller
649            .set_group(
650                "ops",
651                AccessGroup {
652                    description: None,
653                    members: vec![
654                        "alice@example.test".to_string(),
655                        "bob@example.test".to_string(),
656                    ],
657                },
658            )
659            .expect("set group");
660        assert_eq!(revision, 1);
661
662        // New views pick up the change immediately; the old snapshot stays
663        // consistent for the request it was created for.
664        let bob_after = controller.view_for_subject(Some("bob@example.test"));
665        assert!(bob_after.can_view_agent("identity:scout-1"));
666        assert!(!bob.can_view_agent("identity:scout-1"));
667    }
668
669    #[test]
670    fn delete_rule_unknown_id_errors() {
671        let controller = AccessController::new(enabled_config()).expect("controller");
672        assert_eq!(
673            controller.delete_rule("missing"),
674            Err(AccessConfigError::UnknownRule("missing".to_string()))
675        );
676        controller.delete_rule("ops-view-all").expect("delete");
677        let (config, revision) = controller.snapshot();
678        assert!(config.rules.is_empty());
679        assert_eq!(revision, 1);
680    }
681
682    #[test]
683    fn attribute_cache_feeds_label_selectors() {
684        let mut config = enabled_config();
685        config.rules.push(AccessRule {
686            id: "bob-payments".to_string(),
687            subjects: vec!["bob@example.test".to_string()],
688            actions: vec!["agent.view".to_string()],
689            match_labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
690            ..AccessRule::default()
691        });
692        let controller = AccessController::new(config).expect("controller");
693        let bob = controller.view_for_subject(Some("bob@example.test"));
694        assert!(!bob.can_view_agent("identity:pay-1"));
695
696        controller.record_agent_attributes(AgentResourceAttributes {
697            identity: "identity:pay-1".to_string(),
698            agent_id: Some("pay-1".to_string()),
699            role: Some("analyst".to_string()),
700            labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
701        });
702        assert!(bob.can_view_agent("identity:pay-1"));
703        assert!(!bob.can_view_agent("identity:other"));
704    }
705
706    #[test]
707    fn exact_event_attributes_override_newer_alias_cache_entry() {
708        let controller = AccessController::new(AccessControlConfig {
709            enabled: true,
710            admins: vec!["root@example.test".to_string()],
711            rules: vec![
712                AccessRule {
713                    id: "view-all".to_string(),
714                    actions: vec!["agent.view".to_string()],
715                    agents: vec!["*".to_string()],
716                    ..AccessRule::default()
717                },
718                AccessRule {
719                    id: "deny-secret".to_string(),
720                    effect: AccessEffect::Deny,
721                    actions: vec!["agent.view".to_string()],
722                    match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
723                    ..AccessRule::default()
724                },
725            ],
726            ..AccessControlConfig::default()
727        })
728        .expect("controller");
729        controller.record_agent_attributes(AgentResourceAttributes {
730            identity: "reused-alias".to_string(),
731            agent_id: Some("reused-alias".to_string()),
732            role: Some("lead".to_string()),
733            labels: BTreeMap::from([("org".to_string(), "public".to_string())]),
734        });
735        let historical_secret = AgentResourceAttributes {
736            identity: "reused-alias".to_string(),
737            agent_id: Some("reused-alias".to_string()),
738            role: Some("lead".to_string()),
739            labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
740        };
741        let view = controller.view_for_subject(None);
742
743        assert!(view.can_view_agent("reused-alias"));
744        assert!(
745            !view
746                .decide_agent_with_attributes(ACTION_AGENT_VIEW, &historical_secret)
747                .is_allow(),
748            "the newer public cache entry must not authorize the historical secret event"
749        );
750    }
751
752    #[test]
753    fn knows_agent_detects_cold_cache_so_label_deny_can_be_made_fail_closed() {
754        // A broad allow + a label-scoped DENY. On a cold cache the deny cannot
755        // match (no labels), so the member would FAIL OPEN (visible). The
756        // long-lived SSE streams use `knows_agent` to detect this cold state
757        // and re-prime before deciding so the deny resolves fail-closed.
758        let mut config = enabled_config();
759        // Everyone-views-all (subject-only allow).
760        config.rules.push(AccessRule {
761            id: "anon-view-all".to_string(),
762            actions: vec!["agent.view".to_string()],
763            agents: vec!["*".to_string()],
764            ..AccessRule::default()
765        });
766        // Deny view of any member labeled org=secret.
767        config.rules.push(AccessRule {
768            id: "deny-secret".to_string(),
769            effect: AccessEffect::Deny,
770            actions: vec!["agent.view".to_string()],
771            match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
772            ..AccessRule::default()
773        });
774        let controller = AccessController::new(config).expect("controller");
775        let view = controller.view_for_subject(None);
776
777        // Cold cache: the secret member is unknown, so the label-scoped deny
778        // does NOT match and the broad allow leaks it (the fail-open bug).
779        assert!(!view.knows_agent("identity:secret-1"));
780        assert!(
781            view.can_view_agent("identity:secret-1"),
782            "cold cache currently fails OPEN — this is what knows_agent() detects"
783        );
784
785        // The SSE re-prime path records the member's real attributes (here via
786        // record_agent_attributes, which the prime ultimately calls).
787        controller.record_agent_attributes(AgentResourceAttributes {
788            identity: "identity:secret-1".to_string(),
789            agent_id: Some("secret-1".to_string()),
790            role: Some("worker".to_string()),
791            labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
792        });
793
794        // Now the agent is known and the deny resolves fail-closed.
795        assert!(view.knows_agent("identity:secret-1"));
796        assert!(
797            !view.can_view_agent("identity:secret-1"),
798            "after re-prime the label-scoped deny must hide the member"
799        );
800    }
801
802    #[test]
803    fn persistence_round_trips() {
804        let dir = tempfile::tempdir().expect("tempdir");
805        let path = dir.path().join("config").join("access.toml");
806        let controller = AccessController::load_or_default(&path).expect("load default");
807        assert!(!controller.enabled());
808
809        let mut config = enabled_config();
810        config.rules.push(AccessRule {
811            id: "deny-secret".to_string(),
812            effect: AccessEffect::Deny,
813            actions: vec!["agent.*".to_string()],
814            agents: vec!["identity:secret".to_string()],
815            ..AccessRule::default()
816        });
817        controller.replace_config(config.clone()).expect("replace");
818
819        // The accepted config is the §10.3-normalized one (this fixture is
820        // memory-naive, so `agent.memory.read` rides its view rule).
821        super::super::model::normalize_access_config_for_memory_actions(&mut config);
822        let reloaded = AccessController::load_or_default(&path).expect("reload");
823        let (reloaded_config, _) = reloaded.snapshot();
824        assert_eq!(*reloaded_config, config);
825    }
826
827    #[test]
828    fn lockout_protected_on_live_surface() {
829        let controller = AccessController::new(enabled_config()).expect("controller");
830        let mut config = (*controller.snapshot().0).clone();
831        config.admins.clear();
832        assert_eq!(
833            controller.replace_config(config),
834            Err(AccessConfigError::EnabledWithoutAdmins)
835        );
836    }
837
838    #[test]
839    fn concurrent_rule_upserts_do_not_lose_updates() {
840        // Each thread upserts a distinct rule. Without serialized
841        // read-modify-write, the clone-under-read + unconditional swap would
842        // drop deltas; with the mutation lock every committed rule survives
843        // and the revision equals the number of successful commits.
844        let controller = AccessController::new(enabled_config()).expect("controller");
845        let base_rules = controller.snapshot().0.rules.len();
846        let threads: usize = 16;
847        let handles: Vec<_> = (0..threads)
848            .map(|i| {
849                let controller = controller.clone();
850                std::thread::spawn(move || {
851                    controller
852                        .upsert_rule(AccessRule {
853                            id: format!("rule-{i}"),
854                            actions: vec!["agent.view".to_string()],
855                            agents: vec![format!("identity:agent-{i}")],
856                            ..AccessRule::default()
857                        })
858                        .expect("upsert");
859                })
860            })
861            .collect();
862        for handle in handles {
863            handle.join().expect("thread");
864        }
865        let (config, revision) = controller.snapshot();
866        assert_eq!(
867            config.rules.len(),
868            base_rules + threads,
869            "every rule survived: {config:#?}"
870        );
871        assert_eq!(revision, threads as u64, "revision counts every commit");
872        for i in 0..threads {
873            assert!(
874                config
875                    .rules
876                    .iter()
877                    .any(|rule| rule.id == format!("rule-{i}")),
878                "rule-{i} missing"
879            );
880        }
881    }
882
883    #[test]
884    fn spawn_lineage_inherits_parent_permissions() {
885        let mut config = enabled_config();
886        config.rules.push(AccessRule {
887            id: "bob-ops-lead".to_string(),
888            subjects: vec!["bob@example.test".to_string()],
889            actions: vec!["agent.view".to_string(), "agent.send".to_string()],
890            agents: vec!["ops-lead".to_string()],
891            ..AccessRule::default()
892        });
893        let controller = AccessController::new(config).expect("controller");
894        controller.record_agent_attributes(AgentResourceAttributes {
895            identity: "ops-lead".to_string(),
896            agent_id: Some("ops-lead".to_string()),
897            role: Some("orchestrator".to_string()),
898            labels: BTreeMap::new(),
899        });
900        controller.record_agent_attributes(AgentResourceAttributes {
901            identity: "worker-3".to_string(),
902            agent_id: Some("worker-3".to_string()),
903            role: Some("person-worker".to_string()),
904            labels: BTreeMap::from([("spawned_by".to_string(), "ops-lead".to_string())]),
905        });
906        controller.record_agent_attributes(AgentResourceAttributes {
907            identity: "worker-3-sub".to_string(),
908            agent_id: Some("worker-3-sub".to_string()),
909            role: Some("helper".to_string()),
910            labels: BTreeMap::from([("spawned_by".to_string(), "worker-3".to_string())]),
911        });
912        controller.record_agent_attributes(AgentResourceAttributes {
913            identity: "scout-1".to_string(),
914            agent_id: Some("scout-1".to_string()),
915            role: Some("scout".to_string()),
916            labels: BTreeMap::new(),
917        });
918
919        let bob = controller.view_for_subject(Some("bob@example.test"));
920        assert!(bob.can_view_agent("ops-lead"));
921        assert!(
922            bob.can_view_agent("worker-3"),
923            "a member spawned by ops-lead inherits ops-lead's visibility"
924        );
925        assert!(
926            bob.allows_agent("agent.send", "worker-3"),
927            "permission inheritance covers every agent action, not just view"
928        );
929        assert!(
930            bob.can_view_agent("worker-3-sub"),
931            "spawn lineage inheritance is transitive"
932        );
933        assert!(
934            !bob.can_view_agent("scout-1"),
935            "agents outside the spawn lineage stay denied"
936        );
937    }
938
939    #[test]
940    fn spawn_lineage_deny_on_parent_overrides_descendants() {
941        let mut config = enabled_config();
942        config.rules.push(AccessRule {
943            id: "bob-view-all".to_string(),
944            subjects: vec!["bob@example.test".to_string()],
945            actions: vec!["agent.view".to_string()],
946            agents: vec!["*".to_string()],
947            ..AccessRule::default()
948        });
949        config.rules.push(AccessRule {
950            id: "hide-secret-lead".to_string(),
951            effect: AccessEffect::Deny,
952            actions: vec!["agent.*".to_string()],
953            agents: vec!["secret-lead".to_string()],
954            ..AccessRule::default()
955        });
956        let controller = AccessController::new(config).expect("controller");
957        controller.record_agent_attributes(AgentResourceAttributes {
958            identity: "secret-lead".to_string(),
959            agent_id: Some("secret-lead".to_string()),
960            role: None,
961            labels: BTreeMap::new(),
962        });
963        controller.record_agent_attributes(AgentResourceAttributes {
964            identity: "covert-worker".to_string(),
965            agent_id: Some("covert-worker".to_string()),
966            role: None,
967            labels: BTreeMap::from([("spawned_by".to_string(), "secret-lead".to_string())]),
968        });
969
970        let bob = controller.view_for_subject(Some("bob@example.test"));
971        assert!(!bob.can_view_agent("secret-lead"));
972        assert!(
973            !bob.can_view_agent("covert-worker"),
974            "a deny on the spawning parent must propagate to its descendants"
975        );
976    }
977
978    #[test]
979    fn spawn_lineage_cycles_terminate_and_fail_closed() {
980        let controller = AccessController::new(enabled_config()).expect("controller");
981        controller.record_agent_attributes(AgentResourceAttributes {
982            identity: "loop-a".to_string(),
983            agent_id: Some("loop-a".to_string()),
984            role: None,
985            labels: BTreeMap::from([("spawned_by".to_string(), "loop-b".to_string())]),
986        });
987        controller.record_agent_attributes(AgentResourceAttributes {
988            identity: "loop-b".to_string(),
989            agent_id: Some("loop-b".to_string()),
990            role: None,
991            labels: BTreeMap::from([("spawned_by".to_string(), "loop-a".to_string())]),
992        });
993
994        let bob = controller.view_for_subject(Some("bob@example.test"));
995        assert!(
996            !bob.can_view_agent("loop-a"),
997            "lineage cycles must terminate and deny by default"
998        );
999    }
1000
1001    #[test]
1002    fn persist_is_atomic_via_temp_rename() {
1003        // A successful persist leaves no temp file behind and the target is
1004        // a complete, parseable config (temp+rename, not truncate-in-place).
1005        let dir = tempfile::tempdir().expect("tempdir");
1006        let path = dir.path().join("access.toml");
1007        let controller = AccessController::load_or_default(&path).expect("load");
1008        controller
1009            .replace_config(enabled_config())
1010            .expect("replace");
1011        assert!(path.is_file(), "target written");
1012        assert!(
1013            !dir.path().join("access.toml.tmp").exists(),
1014            "temp file cleaned up by rename"
1015        );
1016        let reloaded = AccessController::load_or_default(&path).expect("reload");
1017        assert!(reloaded.enabled());
1018    }
1019}