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_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_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_SPAWN,
ACTION_AGENT_RESPAWN,
ACTION_AGENT_RETIRE,
ACTION_AGENT_RESET,
ACTION_GATING_VIEW,
ACTION_GATING_DECIDE,
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
.rsplit_once('.')
.is_some_and(|(action_prefix, _)| action_prefix == prefix)
});
}
ACCESS_ACTIONS.contains(&pattern)
}
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.*", "*"])],
..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 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);
}
}