use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use super::engine::{
AccessDecision, AccessPrincipal, AccessResource, evaluate_access, groups_for_subject,
principal_may_perform,
};
use super::model::{
ACTION_AGENT_VIEW, AccessConfigError, AccessControlConfig, AccessGroup, AccessRule,
validate_access_config,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentResourceAttributes {
pub identity: String,
pub agent_id: Option<String>,
pub role: Option<String>,
pub labels: BTreeMap<String, String>,
}
struct AccessState {
config: Arc<AccessControlConfig>,
revision: u64,
}
struct AccessControllerInner {
state: RwLock<AccessState>,
persist_path: RwLock<Option<PathBuf>>,
attributes: RwLock<BTreeMap<String, Arc<AgentResourceAttributes>>>,
mutation: Mutex<()>,
}
#[derive(Clone)]
pub struct AccessController {
inner: Arc<AccessControllerInner>,
}
impl std::fmt::Debug for AccessController {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (config, revision) = self.snapshot();
f.debug_struct("AccessController")
.field("enabled", &config.enabled)
.field("revision", &revision)
.field("rules", &config.rules.len())
.finish()
}
}
impl AccessController {
pub fn new(mut config: AccessControlConfig) -> Result<Self, AccessConfigError> {
super::model::normalize_access_config_for_memory_actions(&mut config);
validate_access_config(&config)?;
Ok(Self {
inner: Arc::new(AccessControllerInner {
state: RwLock::new(AccessState {
config: Arc::new(config),
revision: 0,
}),
persist_path: RwLock::new(None),
attributes: RwLock::new(BTreeMap::new()),
mutation: Mutex::new(()),
}),
})
}
pub fn disabled() -> Self {
Self::new(AccessControlConfig::default()).unwrap_or_else(|_| unreachable!())
}
pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, AccessConfigError> {
let path = path.into();
let config = if path.is_file() {
let raw = std::fs::read_to_string(&path)
.map_err(|err| AccessConfigError::Io(err.to_string()))?;
toml::from_str::<AccessControlConfig>(&raw)
.map_err(|err| AccessConfigError::Parse(err.to_string()))?
} else {
AccessControlConfig::default()
};
let controller = Self::new(config)?;
*controller
.inner
.persist_path
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path);
Ok(controller)
}
pub fn with_persist_path(self, path: impl Into<PathBuf>) -> Self {
*self
.inner
.persist_path
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path.into());
self
}
pub fn snapshot(&self) -> (Arc<AccessControlConfig>, u64) {
let state = self
.inner
.state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(Arc::clone(&state.config), state.revision)
}
pub fn enabled(&self) -> bool {
self.snapshot().0.enabled
}
pub fn replace_config(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
self.mutate(move |current| {
*current = config;
Ok(())
})
}
pub fn upsert_rule(&self, rule: AccessRule) -> Result<u64, AccessConfigError> {
self.mutate(move |config| {
match config
.rules
.iter_mut()
.find(|existing| existing.id == rule.id)
{
Some(existing) => *existing = rule,
None => config.rules.push(rule),
}
Ok(())
})
}
pub fn delete_rule(&self, rule_id: &str) -> Result<u64, AccessConfigError> {
self.mutate(|config| {
let before = config.rules.len();
config.rules.retain(|rule| rule.id != rule_id);
if config.rules.len() == before {
return Err(AccessConfigError::UnknownRule(rule_id.to_string()));
}
Ok(())
})
}
pub fn set_group(&self, name: &str, group: AccessGroup) -> Result<u64, AccessConfigError> {
self.mutate(move |config| {
config.groups.insert(name.to_string(), group);
Ok(())
})
}
pub fn delete_group(&self, name: &str) -> Result<u64, AccessConfigError> {
self.mutate(|config| {
config.groups.remove(name);
Ok(())
})
}
pub fn set_enabled(&self, enabled: bool) -> Result<u64, AccessConfigError> {
self.mutate(move |config| {
config.enabled = enabled;
Ok(())
})
}
fn mutate<F>(&self, mutator: F) -> Result<u64, AccessConfigError>
where
F: FnOnce(&mut AccessControlConfig) -> Result<(), AccessConfigError>,
{
let _mutation = self
.inner
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut config = (*self.snapshot().0).clone();
mutator(&mut config)?;
super::model::normalize_access_config_for_memory_actions(&mut config);
validate_access_config(&config)?;
self.commit(config)
}
fn commit(&self, config: AccessControlConfig) -> Result<u64, AccessConfigError> {
let persist_path = self
.inner
.persist_path
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if let Some(path) = persist_path {
persist_config(&path, &config)?;
}
let mut state = self
.inner
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.config = Arc::new(config);
state.revision += 1;
Ok(state.revision)
}
pub fn view_for_subject(&self, subject: Option<&str>) -> AccessView {
let (config, _) = self.snapshot();
let principal = match subject {
Some(subject) => AccessPrincipal {
subject: Some(subject.to_string()),
groups: groups_for_subject(&config, subject),
},
None => AccessPrincipal::anonymous(),
};
let is_admin = principal
.subject
.as_deref()
.is_some_and(|subject| config.admins.iter().any(|admin| admin == subject));
AccessView {
inner: Arc::clone(&self.inner),
config,
principal,
is_admin,
}
}
pub fn record_agent_attributes(&self, attributes: AgentResourceAttributes) {
if attributes.identity.is_empty() {
return;
}
let mut cache = self
.inner
.attributes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(attributes.identity.clone(), Arc::new(attributes));
}
pub fn replace_agent_attributes(
&self,
attributes: impl IntoIterator<Item = AgentResourceAttributes>,
) {
let next: BTreeMap<String, Arc<AgentResourceAttributes>> = attributes
.into_iter()
.filter(|entry| !entry.identity.is_empty())
.map(|entry| (entry.identity.clone(), Arc::new(entry)))
.collect();
if next.is_empty() {
return;
}
*self
.inner
.attributes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = next;
}
}
fn persist_config(path: &Path, config: &AccessControlConfig) -> Result<(), AccessConfigError> {
let rendered =
toml::to_string_pretty(config).map_err(|err| AccessConfigError::Parse(err.to_string()))?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|err| AccessConfigError::Io(err.to_string()))?;
}
let header = "# MobKit access control. Managed by the console Access panel;\n# hand edits are preserved until the next console save.\n\n";
let mut tmp = path.to_path_buf();
let mut tmp_name = path
.file_name()
.map(std::ffi::OsString::from)
.ok_or_else(|| {
AccessConfigError::Io(format!(
"access config path has no file name: {}",
path.display()
))
})?;
tmp_name.push(".tmp");
tmp.set_file_name(tmp_name);
std::fs::write(&tmp, format!("{header}{rendered}"))
.map_err(|err| AccessConfigError::Io(err.to_string()))?;
std::fs::rename(&tmp, path).map_err(|err| AccessConfigError::Io(err.to_string()))
}
struct LineageLink {
identity: String,
attributes: Option<Arc<AgentResourceAttributes>>,
}
#[derive(Clone)]
pub struct AccessView {
inner: Arc<AccessControllerInner>,
config: Arc<AccessControlConfig>,
principal: AccessPrincipal,
is_admin: bool,
}
impl AccessView {
pub fn enforced(&self) -> bool {
self.config.enabled
}
pub fn subject(&self) -> Option<&str> {
self.principal.subject.as_deref()
}
pub fn groups(&self) -> &BTreeSet<String> {
&self.principal.groups
}
pub fn is_admin(&self) -> bool {
self.is_admin
}
pub fn decide(&self, action: &str, resource: &AccessResource<'_>) -> AccessDecision {
evaluate_access(&self.config, &self.principal, action, resource)
}
pub fn allows(&self, action: &str) -> bool {
self.decide(action, &AccessResource::none()).is_allow()
}
pub fn may_perform_anywhere(&self, action: &str) -> bool {
self.is_admin || principal_may_perform(&self.config, &self.principal, action)
}
pub fn allows_agent(&self, action: &str, identity: &str) -> bool {
self.decide_agent(action, identity).is_allow()
}
pub fn decide_agent(&self, action: &str, identity: &str) -> AccessDecision {
if !self.config.enabled {
return AccessDecision::Allow;
}
let lineage = self.lineage_for(identity);
if lineage.is_empty() {
return self.decide(action, &AccessResource::for_identity(identity));
}
self.decide_agent_lineage(action, identity, &lineage)
}
pub(crate) fn decide_agent_with_attributes(
&self,
action: &str,
attributes: &AgentResourceAttributes,
) -> AccessDecision {
if !self.config.enabled {
return AccessDecision::Allow;
}
let lineage = self.lineage_for_attributes(attributes);
self.decide_agent_lineage(action, attributes.identity.as_str(), &lineage)
}
fn decide_agent_lineage(
&self,
action: &str,
fallback_identity: &str,
lineage: &[LineageLink],
) -> AccessDecision {
let resources = lineage
.iter()
.enumerate()
.map(|(index, link)| match link.attributes.as_deref() {
Some(attributes) => AccessResource {
identity: Some(attributes.identity.as_str()),
agent_id: attributes
.agent_id
.as_deref()
.or((index == 0).then_some(fallback_identity)),
role: attributes.role.as_deref(),
labels: Some(&attributes.labels),
},
None => AccessResource::for_identity(link.identity.as_str()),
})
.collect::<Vec<_>>();
super::engine::evaluate_access_lineage(&self.config, &self.principal, action, &resources)
}
fn lineage_for(&self, identity: &str) -> Vec<LineageLink> {
let cache = self
.inner
.attributes
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let resolve = |key: &str| {
cache.get(key).cloned().or_else(|| {
cache
.values()
.find(|attributes| attributes.agent_id.as_deref() == Some(key))
.cloned()
})
};
let Some(own) = resolve(identity) else {
return Vec::new();
};
Self::lineage_from_attributes(own, &resolve)
}
fn lineage_for_attributes(&self, attributes: &AgentResourceAttributes) -> Vec<LineageLink> {
let cache = self
.inner
.attributes
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let resolve = |key: &str| {
cache.get(key).cloned().or_else(|| {
cache
.values()
.find(|candidate| candidate.agent_id.as_deref() == Some(key))
.cloned()
})
};
Self::lineage_from_attributes(Arc::new(attributes.clone()), &resolve)
}
fn lineage_from_attributes(
own: Arc<AgentResourceAttributes>,
resolve: &impl Fn(&str) -> Option<Arc<AgentResourceAttributes>>,
) -> Vec<LineageLink> {
const MAX_LINEAGE_DEPTH: usize = 8;
let mut visited = BTreeSet::from([own.identity.clone()]);
let mut lineage = vec![LineageLink {
identity: own.identity.clone(),
attributes: Some(own),
}];
while lineage.len() < MAX_LINEAGE_DEPTH {
let Some(parent) = lineage
.last()
.and_then(|link| link.attributes.as_deref())
.and_then(|attributes| attributes.labels.get("spawned_by"))
.map(|parent| parent.trim().to_string())
.filter(|parent| !parent.is_empty())
else {
break;
};
let attributes = resolve(&parent);
let parent_identity = attributes
.as_deref()
.map(|attributes| attributes.identity.clone())
.unwrap_or(parent);
if !visited.insert(parent_identity.clone()) {
break;
}
lineage.push(LineageLink {
identity: parent_identity,
attributes,
});
}
lineage
}
pub fn can_view_agent(&self, identity: &str) -> bool {
self.allows_agent(ACTION_AGENT_VIEW, identity)
}
pub fn knows_agent(&self, identity: &str) -> bool {
let cache = self
.inner
.attributes
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.contains_key(identity)
|| cache
.values()
.any(|attributes| attributes.agent_id.as_deref() == Some(identity))
}
pub fn can_administer(&self) -> bool {
if self.is_admin {
return true;
}
if !self.config.enabled {
return self.config.admins.is_empty();
}
self.decide(super::model::ACTION_ACCESS_ADMIN, &AccessResource::none())
.is_allow()
}
}
impl std::fmt::Debug for AccessView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccessView")
.field("subject", &self.principal.subject)
.field("groups", &self.principal.groups)
.field("is_admin", &self.is_admin)
.field("enforced", &self.config.enabled)
.finish()
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::access::model::AccessEffect;
fn enabled_config() -> AccessControlConfig {
AccessControlConfig {
enabled: true,
admins: vec!["root@example.test".to_string()],
groups: BTreeMap::from([(
"ops".to_string(),
AccessGroup {
description: None,
members: vec!["alice@example.test".to_string()],
},
)]),
rules: vec![AccessRule {
id: "ops-view-all".to_string(),
groups: vec!["ops".to_string()],
actions: vec!["agent.view".to_string()],
..AccessRule::default()
}],
}
}
#[test]
fn view_resolves_groups_and_admin_flag() {
let controller = AccessController::new(enabled_config()).expect("controller");
let alice = controller.view_for_subject(Some("alice@example.test"));
assert!(alice.groups().contains("ops"));
assert!(!alice.is_admin());
assert!(alice.can_view_agent("identity:scout-1"));
assert!(!alice.allows_agent("agent.send", "identity:scout-1"));
let root = controller.view_for_subject(Some("root@example.test"));
assert!(root.is_admin());
assert!(root.allows("access.admin"));
}
#[test]
fn live_mutations_bump_revision_and_apply() {
let controller = AccessController::new(enabled_config()).expect("controller");
let bob = controller.view_for_subject(Some("bob@example.test"));
assert!(!bob.can_view_agent("identity:scout-1"));
let revision = controller
.set_group(
"ops",
AccessGroup {
description: None,
members: vec![
"alice@example.test".to_string(),
"bob@example.test".to_string(),
],
},
)
.expect("set group");
assert_eq!(revision, 1);
let bob_after = controller.view_for_subject(Some("bob@example.test"));
assert!(bob_after.can_view_agent("identity:scout-1"));
assert!(!bob.can_view_agent("identity:scout-1"));
}
#[test]
fn delete_rule_unknown_id_errors() {
let controller = AccessController::new(enabled_config()).expect("controller");
assert_eq!(
controller.delete_rule("missing"),
Err(AccessConfigError::UnknownRule("missing".to_string()))
);
controller.delete_rule("ops-view-all").expect("delete");
let (config, revision) = controller.snapshot();
assert!(config.rules.is_empty());
assert_eq!(revision, 1);
}
#[test]
fn attribute_cache_feeds_label_selectors() {
let mut config = enabled_config();
config.rules.push(AccessRule {
id: "bob-payments".to_string(),
subjects: vec!["bob@example.test".to_string()],
actions: vec!["agent.view".to_string()],
match_labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
..AccessRule::default()
});
let controller = AccessController::new(config).expect("controller");
let bob = controller.view_for_subject(Some("bob@example.test"));
assert!(!bob.can_view_agent("identity:pay-1"));
controller.record_agent_attributes(AgentResourceAttributes {
identity: "identity:pay-1".to_string(),
agent_id: Some("pay-1".to_string()),
role: Some("analyst".to_string()),
labels: BTreeMap::from([("org".to_string(), "payments".to_string())]),
});
assert!(bob.can_view_agent("identity:pay-1"));
assert!(!bob.can_view_agent("identity:other"));
}
#[test]
fn exact_event_attributes_override_newer_alias_cache_entry() {
let controller = AccessController::new(AccessControlConfig {
enabled: true,
admins: vec!["root@example.test".to_string()],
rules: vec![
AccessRule {
id: "view-all".to_string(),
actions: vec!["agent.view".to_string()],
agents: vec!["*".to_string()],
..AccessRule::default()
},
AccessRule {
id: "deny-secret".to_string(),
effect: AccessEffect::Deny,
actions: vec!["agent.view".to_string()],
match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
..AccessRule::default()
},
],
..AccessControlConfig::default()
})
.expect("controller");
controller.record_agent_attributes(AgentResourceAttributes {
identity: "reused-alias".to_string(),
agent_id: Some("reused-alias".to_string()),
role: Some("lead".to_string()),
labels: BTreeMap::from([("org".to_string(), "public".to_string())]),
});
let historical_secret = AgentResourceAttributes {
identity: "reused-alias".to_string(),
agent_id: Some("reused-alias".to_string()),
role: Some("lead".to_string()),
labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
};
let view = controller.view_for_subject(None);
assert!(view.can_view_agent("reused-alias"));
assert!(
!view
.decide_agent_with_attributes(ACTION_AGENT_VIEW, &historical_secret)
.is_allow(),
"the newer public cache entry must not authorize the historical secret event"
);
}
#[test]
fn knows_agent_detects_cold_cache_so_label_deny_can_be_made_fail_closed() {
let mut config = enabled_config();
config.rules.push(AccessRule {
id: "anon-view-all".to_string(),
actions: vec!["agent.view".to_string()],
agents: vec!["*".to_string()],
..AccessRule::default()
});
config.rules.push(AccessRule {
id: "deny-secret".to_string(),
effect: AccessEffect::Deny,
actions: vec!["agent.view".to_string()],
match_labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
..AccessRule::default()
});
let controller = AccessController::new(config).expect("controller");
let view = controller.view_for_subject(None);
assert!(!view.knows_agent("identity:secret-1"));
assert!(
view.can_view_agent("identity:secret-1"),
"cold cache currently fails OPEN — this is what knows_agent() detects"
);
controller.record_agent_attributes(AgentResourceAttributes {
identity: "identity:secret-1".to_string(),
agent_id: Some("secret-1".to_string()),
role: Some("worker".to_string()),
labels: BTreeMap::from([("org".to_string(), "secret".to_string())]),
});
assert!(view.knows_agent("identity:secret-1"));
assert!(
!view.can_view_agent("identity:secret-1"),
"after re-prime the label-scoped deny must hide the member"
);
}
#[test]
fn persistence_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config").join("access.toml");
let controller = AccessController::load_or_default(&path).expect("load default");
assert!(!controller.enabled());
let mut config = enabled_config();
config.rules.push(AccessRule {
id: "deny-secret".to_string(),
effect: AccessEffect::Deny,
actions: vec!["agent.*".to_string()],
agents: vec!["identity:secret".to_string()],
..AccessRule::default()
});
controller.replace_config(config.clone()).expect("replace");
super::super::model::normalize_access_config_for_memory_actions(&mut config);
let reloaded = AccessController::load_or_default(&path).expect("reload");
let (reloaded_config, _) = reloaded.snapshot();
assert_eq!(*reloaded_config, config);
}
#[test]
fn lockout_protected_on_live_surface() {
let controller = AccessController::new(enabled_config()).expect("controller");
let mut config = (*controller.snapshot().0).clone();
config.admins.clear();
assert_eq!(
controller.replace_config(config),
Err(AccessConfigError::EnabledWithoutAdmins)
);
}
#[test]
fn concurrent_rule_upserts_do_not_lose_updates() {
let controller = AccessController::new(enabled_config()).expect("controller");
let base_rules = controller.snapshot().0.rules.len();
let threads: usize = 16;
let handles: Vec<_> = (0..threads)
.map(|i| {
let controller = controller.clone();
std::thread::spawn(move || {
controller
.upsert_rule(AccessRule {
id: format!("rule-{i}"),
actions: vec!["agent.view".to_string()],
agents: vec![format!("identity:agent-{i}")],
..AccessRule::default()
})
.expect("upsert");
})
})
.collect();
for handle in handles {
handle.join().expect("thread");
}
let (config, revision) = controller.snapshot();
assert_eq!(
config.rules.len(),
base_rules + threads,
"every rule survived: {config:#?}"
);
assert_eq!(revision, threads as u64, "revision counts every commit");
for i in 0..threads {
assert!(
config
.rules
.iter()
.any(|rule| rule.id == format!("rule-{i}")),
"rule-{i} missing"
);
}
}
#[test]
fn spawn_lineage_inherits_parent_permissions() {
let mut config = enabled_config();
config.rules.push(AccessRule {
id: "bob-ops-lead".to_string(),
subjects: vec!["bob@example.test".to_string()],
actions: vec!["agent.view".to_string(), "agent.send".to_string()],
agents: vec!["ops-lead".to_string()],
..AccessRule::default()
});
let controller = AccessController::new(config).expect("controller");
controller.record_agent_attributes(AgentResourceAttributes {
identity: "ops-lead".to_string(),
agent_id: Some("ops-lead".to_string()),
role: Some("orchestrator".to_string()),
labels: BTreeMap::new(),
});
controller.record_agent_attributes(AgentResourceAttributes {
identity: "worker-3".to_string(),
agent_id: Some("worker-3".to_string()),
role: Some("person-worker".to_string()),
labels: BTreeMap::from([("spawned_by".to_string(), "ops-lead".to_string())]),
});
controller.record_agent_attributes(AgentResourceAttributes {
identity: "worker-3-sub".to_string(),
agent_id: Some("worker-3-sub".to_string()),
role: Some("helper".to_string()),
labels: BTreeMap::from([("spawned_by".to_string(), "worker-3".to_string())]),
});
controller.record_agent_attributes(AgentResourceAttributes {
identity: "scout-1".to_string(),
agent_id: Some("scout-1".to_string()),
role: Some("scout".to_string()),
labels: BTreeMap::new(),
});
let bob = controller.view_for_subject(Some("bob@example.test"));
assert!(bob.can_view_agent("ops-lead"));
assert!(
bob.can_view_agent("worker-3"),
"a member spawned by ops-lead inherits ops-lead's visibility"
);
assert!(
bob.allows_agent("agent.send", "worker-3"),
"permission inheritance covers every agent action, not just view"
);
assert!(
bob.can_view_agent("worker-3-sub"),
"spawn lineage inheritance is transitive"
);
assert!(
!bob.can_view_agent("scout-1"),
"agents outside the spawn lineage stay denied"
);
}
#[test]
fn spawn_lineage_deny_on_parent_overrides_descendants() {
let mut config = enabled_config();
config.rules.push(AccessRule {
id: "bob-view-all".to_string(),
subjects: vec!["bob@example.test".to_string()],
actions: vec!["agent.view".to_string()],
agents: vec!["*".to_string()],
..AccessRule::default()
});
config.rules.push(AccessRule {
id: "hide-secret-lead".to_string(),
effect: AccessEffect::Deny,
actions: vec!["agent.*".to_string()],
agents: vec!["secret-lead".to_string()],
..AccessRule::default()
});
let controller = AccessController::new(config).expect("controller");
controller.record_agent_attributes(AgentResourceAttributes {
identity: "secret-lead".to_string(),
agent_id: Some("secret-lead".to_string()),
role: None,
labels: BTreeMap::new(),
});
controller.record_agent_attributes(AgentResourceAttributes {
identity: "covert-worker".to_string(),
agent_id: Some("covert-worker".to_string()),
role: None,
labels: BTreeMap::from([("spawned_by".to_string(), "secret-lead".to_string())]),
});
let bob = controller.view_for_subject(Some("bob@example.test"));
assert!(!bob.can_view_agent("secret-lead"));
assert!(
!bob.can_view_agent("covert-worker"),
"a deny on the spawning parent must propagate to its descendants"
);
}
#[test]
fn spawn_lineage_cycles_terminate_and_fail_closed() {
let controller = AccessController::new(enabled_config()).expect("controller");
controller.record_agent_attributes(AgentResourceAttributes {
identity: "loop-a".to_string(),
agent_id: Some("loop-a".to_string()),
role: None,
labels: BTreeMap::from([("spawned_by".to_string(), "loop-b".to_string())]),
});
controller.record_agent_attributes(AgentResourceAttributes {
identity: "loop-b".to_string(),
agent_id: Some("loop-b".to_string()),
role: None,
labels: BTreeMap::from([("spawned_by".to_string(), "loop-a".to_string())]),
});
let bob = controller.view_for_subject(Some("bob@example.test"));
assert!(
!bob.can_view_agent("loop-a"),
"lineage cycles must terminate and deny by default"
);
}
#[test]
fn persist_is_atomic_via_temp_rename() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("access.toml");
let controller = AccessController::load_or_default(&path).expect("load");
controller
.replace_config(enabled_config())
.expect("replace");
assert!(path.is_file(), "target written");
assert!(
!dir.path().join("access.toml.tmp").exists(),
"temp file cleaned up by rename"
);
let reloaded = AccessController::load_or_default(&path).expect("reload");
assert!(reloaded.enabled());
}
}