use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ACTION_AGENT_VIEW: &str = "agent.view";
pub const ACTION_AGENT_SEND: &str = "agent.send";
pub const ACTION_AGENT_MEMORY_WRITE: &str = "agent.memory.write";
pub const ACTION_AGENT_MEMORY_DELETE: &str = "agent.memory.delete";
pub const ACTION_AGENT_MEMORY_READ: &str = "agent.memory.read";
pub const ACTION_OPERATOR_MEMORY_READ: &str = "operator.memory.read";
pub const ACTION_AGENT_MEMORY_ADMIN: &str = "agent.memory.admin";
pub const ACTION_MOB_MEMORY_READ: &str = "mob.memory.read";
pub const ACTION_MOB_MEMORY_PROPOSE: &str = "mob.memory.propose";
pub const ACTION_MOB_MEMORY_COMMIT: &str = "mob.memory.commit";
pub const ACTION_MEMORY_QUARANTINE_REVIEW: &str = "memory.quarantine.review";
pub const ACTION_AGENT_SPAWN: &str = "agent.spawn";
pub const ACTION_AGENT_RESPAWN: &str = "agent.respawn";
pub const ACTION_AGENT_RETIRE: &str = "agent.retire";
pub const ACTION_AGENT_RESET: &str = "agent.reset";
pub const ACTION_GATING_VIEW: &str = "gating.view";
pub const ACTION_GATING_DECIDE: &str = "gating.decide";
pub const ACTION_WORKGRAPH_VIEW: &str = "workgraph.view";
pub const ACTION_WORKGRAPH_MANAGE: &str = "workgraph.manage";
pub const ACTION_MOB_OBSERVE: &str = "mob.observe";
pub const ACTION_RUNTIME_ADMIN: &str = "runtime.admin";
pub const ACTION_MOBPACK_AUTHOR: &str = "mobpack.author";
pub const ACTION_MOBPACK_DEPLOY: &str = "mobpack.deploy";
pub const ACTION_ACCESS_ADMIN: &str = "access.admin";
pub const ACCESS_ACTIONS: &[&str] = &[
ACTION_AGENT_VIEW,
ACTION_AGENT_SEND,
ACTION_AGENT_MEMORY_WRITE,
ACTION_AGENT_MEMORY_DELETE,
ACTION_AGENT_MEMORY_READ,
ACTION_AGENT_MEMORY_ADMIN,
ACTION_OPERATOR_MEMORY_READ,
ACTION_MOB_MEMORY_READ,
ACTION_MOB_MEMORY_PROPOSE,
ACTION_MOB_MEMORY_COMMIT,
ACTION_MEMORY_QUARANTINE_REVIEW,
ACTION_AGENT_SPAWN,
ACTION_AGENT_RESPAWN,
ACTION_AGENT_RETIRE,
ACTION_AGENT_RESET,
ACTION_GATING_VIEW,
ACTION_GATING_DECIDE,
ACTION_WORKGRAPH_VIEW,
ACTION_WORKGRAPH_MANAGE,
ACTION_MOB_OBSERVE,
ACTION_RUNTIME_ADMIN,
ACTION_MOBPACK_AUTHOR,
ACTION_MOBPACK_DEPLOY,
ACTION_ACCESS_ADMIN,
];
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessControlConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub admins: Vec<String>,
#[serde(default)]
pub groups: BTreeMap<String, AccessGroup>,
#[serde(default)]
pub rules: Vec<AccessRule>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessGroup {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub members: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccessEffect {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessRule {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub effect: AccessEffect,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subjects: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub match_labels: BTreeMap<String, String>,
}
impl AccessRule {
pub fn has_resource_selector(&self) -> bool {
!self.agents.is_empty() || !self.roles.is_empty() || !self.match_labels.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessConfigError {
EnabledWithoutAdmins,
EmptyRuleId,
DuplicateRuleId(String),
EmptyActions(String),
UnknownAction { rule: String, action: String },
UnknownGroup { rule: String, group: String },
UnknownRule(String),
Parse(String),
Io(String),
}
impl std::fmt::Display for AccessConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnabledWithoutAdmins => write!(
f,
"access control cannot be enabled without at least one admin subject"
),
Self::EmptyRuleId => write!(f, "access rule id must not be empty"),
Self::DuplicateRuleId(id) => write!(f, "duplicate access rule id: {id}"),
Self::EmptyActions(id) => write!(f, "access rule {id}: actions must not be empty"),
Self::UnknownAction { rule, action } => {
write!(f, "access rule {rule}: unknown action {action:?}")
}
Self::UnknownGroup { rule, group } => {
write!(f, "access rule {rule}: unknown group {group:?}")
}
Self::UnknownRule(id) => write!(f, "unknown access rule id: {id}"),
Self::Parse(message) => write!(f, "access config could not be parsed: {message}"),
Self::Io(message) => write!(f, "access config io error: {message}"),
}
}
}
impl std::error::Error for AccessConfigError {}
fn action_pattern_is_known(pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix(".*") {
return ACCESS_ACTIONS.iter().any(|action| {
action
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.'))
});
}
ACCESS_ACTIONS.contains(&pattern)
}
pub(crate) fn action_pattern_matches(pattern: &str, action: &str) -> bool {
if pattern == "*" || pattern == action {
return true;
}
pattern.strip_suffix(".*").is_some_and(|prefix| {
action
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('.'))
})
}
fn pattern_mentions_memory(pattern: &str) -> bool {
pattern
.strip_suffix(".*")
.unwrap_or(pattern)
.split('.')
.any(|segment| segment == "memory")
}
pub fn normalize_access_config_for_memory_actions(config: &mut AccessControlConfig) -> bool {
let mentions_memory = config
.rules
.iter()
.flat_map(|rule| rule.actions.iter())
.any(|pattern| pattern_mentions_memory(pattern));
if mentions_memory {
return false;
}
let mut changed = false;
for rule in &mut config.rules {
let matches_view = rule
.actions
.iter()
.any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_VIEW));
let matches_read = rule
.actions
.iter()
.any(|pattern| action_pattern_matches(pattern, ACTION_AGENT_MEMORY_READ));
if matches_view && !matches_read {
rule.actions.push(ACTION_AGENT_MEMORY_READ.to_string());
changed = true;
}
}
if changed {
tracing::warn!(
target: "mobkit::access",
"access config predates memory read actions; granting agent.memory.read wherever \
agent.view is granted (write explicit agent.memory.read / mob.memory.read / \
memory.quarantine.review rules to silence this)"
);
}
changed
}
pub fn validate_access_config(config: &AccessControlConfig) -> Result<(), AccessConfigError> {
if config.enabled && config.admins.iter().all(|admin| admin.trim().is_empty()) {
return Err(AccessConfigError::EnabledWithoutAdmins);
}
let mut seen_ids = std::collections::BTreeSet::new();
for rule in &config.rules {
if rule.id.trim().is_empty() {
return Err(AccessConfigError::EmptyRuleId);
}
if !seen_ids.insert(rule.id.as_str()) {
return Err(AccessConfigError::DuplicateRuleId(rule.id.clone()));
}
if rule.actions.is_empty() {
return Err(AccessConfigError::EmptyActions(rule.id.clone()));
}
for action in &rule.actions {
if !action_pattern_is_known(action) {
return Err(AccessConfigError::UnknownAction {
rule: rule.id.clone(),
action: action.clone(),
});
}
}
for group in &rule.groups {
if !config.groups.contains_key(group) {
return Err(AccessConfigError::UnknownGroup {
rule: rule.id.clone(),
group: group.clone(),
});
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn rule(id: &str, actions: &[&str]) -> AccessRule {
AccessRule {
id: id.to_string(),
actions: actions.iter().map(ToString::to_string).collect(),
..AccessRule::default()
}
}
#[test]
fn default_config_is_disabled_and_valid() {
let config = AccessControlConfig::default();
assert!(!config.enabled);
assert!(validate_access_config(&config).is_ok());
}
#[test]
fn enabling_requires_admins() {
let config = AccessControlConfig {
enabled: true,
..AccessControlConfig::default()
};
assert_eq!(
validate_access_config(&config),
Err(AccessConfigError::EnabledWithoutAdmins)
);
}
#[test]
fn rules_require_known_actions() {
let mut config = AccessControlConfig {
admins: vec!["root@example.test".to_string()],
rules: vec![rule(
"r1",
&["agent.view", "agent.memory.*", "agent.*", "*"],
)],
..AccessControlConfig::default()
};
assert!(validate_access_config(&config).is_ok());
config.rules.push(rule("r2", &["agent.fly"]));
assert!(matches!(
validate_access_config(&config),
Err(AccessConfigError::UnknownAction { .. })
));
}
#[test]
fn rules_reject_duplicate_ids_and_unknown_groups() {
let mut config = AccessControlConfig {
rules: vec![rule("r1", &["agent.view"]), rule("r1", &["agent.send"])],
..AccessControlConfig::default()
};
assert_eq!(
validate_access_config(&config),
Err(AccessConfigError::DuplicateRuleId("r1".to_string()))
);
config.rules.pop();
config.rules[0].groups = vec!["ops".to_string()];
assert!(matches!(
validate_access_config(&config),
Err(AccessConfigError::UnknownGroup { .. })
));
}
#[test]
fn memory_actions_validate() {
let config = AccessControlConfig {
admins: vec!["root@example.test".to_string()],
rules: vec![rule(
"r1",
&[
"agent.memory.read",
"agent.memory.admin",
"operator.memory.read",
"mob.memory.read",
"mob.memory.propose",
"mob.memory.commit",
"memory.quarantine.review",
"mob.memory.*",
"memory.*",
],
)],
..AccessControlConfig::default()
};
assert!(validate_access_config(&config).is_ok());
}
#[test]
fn memory_naive_config_grants_read_alongside_view() {
let mut deny_view = rule("deny-secret", &["agent.view"]);
deny_view.effect = AccessEffect::Deny;
deny_view.agents = vec!["identity:secret".to_string()];
let mut config = AccessControlConfig {
enabled: true,
admins: vec!["root@example.test".to_string()],
rules: vec![
rule("view-all", &["agent.view"]),
deny_view,
rule("send-one", &["agent.send"]),
],
..AccessControlConfig::default()
};
assert!(normalize_access_config_for_memory_actions(&mut config));
let actions_of = |id: &str| {
config
.rules
.iter()
.find(|rule| rule.id == id)
.expect("rule")
.actions
.clone()
};
assert!(actions_of("view-all").contains(&"agent.memory.read".to_string()));
assert!(
actions_of("deny-secret").contains(&"agent.memory.read".to_string()),
"denies mirror too, so read cannot outlive a view deny"
);
assert!(!actions_of("send-one").contains(&"agent.memory.read".to_string()));
assert!(!normalize_access_config_for_memory_actions(&mut config));
}
#[test]
fn config_mentioning_any_memory_action_is_taken_literally() {
let mut config = AccessControlConfig {
enabled: true,
admins: vec!["root@example.test".to_string()],
rules: vec![
rule("view-all", &["agent.view"]),
rule("writer", &["agent.memory.write"]),
],
..AccessControlConfig::default()
};
assert!(!normalize_access_config_for_memory_actions(&mut config));
assert!(
!config.rules[0]
.actions
.contains(&"agent.memory.read".to_string())
);
let mut config = AccessControlConfig {
rules: vec![
rule("view-all", &["agent.view"]),
rule("mem", &["agent.memory.*"]),
],
..AccessControlConfig::default()
};
assert!(!normalize_access_config_for_memory_actions(&mut config));
}
#[test]
fn broad_wildcards_do_not_trigger_or_need_compat() {
let mut config = AccessControlConfig {
rules: vec![rule("all-agent-verbs", &["agent.*"])],
..AccessControlConfig::default()
};
assert!(!normalize_access_config_for_memory_actions(&mut config));
assert!(action_pattern_matches("agent.*", "agent.memory.read"));
assert!(action_pattern_matches("*", "memory.quarantine.review"));
assert!(!action_pattern_matches("agent.*", "mob.memory.read"));
}
#[test]
fn config_round_trips_through_toml() {
let config = AccessControlConfig {
enabled: true,
admins: vec!["root@example.test".to_string()],
groups: BTreeMap::from([(
"ops".to_string(),
AccessGroup {
description: Some("Operations".to_string()),
members: vec!["alice@example.test".to_string()],
},
)]),
rules: vec![AccessRule {
id: "ops-see-all".to_string(),
description: Some("ops see everything".to_string()),
effect: AccessEffect::Allow,
groups: vec!["ops".to_string()],
actions: vec!["agent.view".to_string()],
..AccessRule::default()
}],
};
let toml = toml::to_string_pretty(&config).expect("serialize");
let parsed: AccessControlConfig = toml::from_str(&toml).expect("parse");
assert_eq!(parsed, config);
}
}