use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::RwLock;
use crate::agent_config::AgentRoleConfig;
use crate::agent_registry::AgentRegistry;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Permission {
Tool(String),
#[allow(dead_code)]
Agent(String),
AllTools,
AllAgents,
}
#[derive(Debug, Clone)]
pub struct Role {
#[allow(dead_code)]
pub name: String,
pub allow: HashSet<Permission>,
pub deny: HashSet<Permission>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessDenied {
pub agent_id: String,
pub permission: String,
pub reason: String,
}
impl fmt::Display for AccessDenied {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"access denied for agent '{}': {} — {}",
self.agent_id, self.permission, self.reason
)
}
}
impl std::error::Error for AccessDenied {}
#[derive(Debug, Default)]
pub struct AccessControl {
roles: HashMap<String, Role>,
}
impl AccessControl {
pub fn new() -> Self {
Self {
roles: HashMap::new(),
}
}
pub fn add_role(&mut self, agent_id: &str, role: Role) {
self.roles.insert(agent_id.to_string(), role);
}
pub fn remove_role(&mut self, agent_id: &str) {
self.roles.remove(agent_id);
}
pub fn check(&self, agent_id: &str, permission: &Permission) -> Result<(), AccessDenied> {
let role = self.roles.get(agent_id).ok_or_else(|| AccessDenied {
agent_id: agent_id.to_string(),
permission: format!("{:?}", permission),
reason: "no role registered".to_string(),
})?;
if role.deny.contains(permission) {
return Err(AccessDenied {
agent_id: agent_id.to_string(),
permission: format!("{:?}", permission),
reason: "explicitly denied".to_string(),
});
}
if role.allow.contains(permission) {
return Ok(());
}
match permission {
Permission::Tool(_) if role.allow.contains(&Permission::AllTools) => Ok(()),
Permission::Agent(_) if role.allow.contains(&Permission::AllAgents) => Ok(()),
_ => Err(AccessDenied {
agent_id: agent_id.to_string(),
permission: format!("{:?}", permission),
reason: "not in allow list".to_string(),
}),
}
}
}
pub const SYSTEM_TOOLS: &[&str] = &[
"agent_create",
"agent_start",
"agent_stop",
"agent_delete",
"agent_list",
"agent_configure",
"task_list",
"task_create",
"task_cancel",
"task_delete",
"fs_list",
"fs_read",
"fs_search",
"fs_pwd",
"fs_tree",
"send_photo",
];
pub struct RbacBridge {
access_control: RwLock<AccessControl>,
}
impl Default for RbacBridge {
fn default() -> Self {
Self::new()
}
}
impl RbacBridge {
pub fn new() -> Self {
Self {
access_control: RwLock::new(AccessControl::new()),
}
}
pub fn register_system_agent(&self, agent_id: &str) {
let role = Role {
name: format!("{}-admin", agent_id),
allow: [Permission::AllTools, Permission::AllAgents]
.into_iter()
.collect(),
deny: HashSet::new(),
};
let mut ac = self.access_control.write().expect("lock poisoned");
ac.add_role(agent_id, role);
}
pub fn register_agent(&self, agent_id: &str, role_config: &AgentRoleConfig) -> Vec<String> {
let mut stripped = Vec::new();
let mut allow = HashSet::new();
for entry in &role_config.allow {
if SYSTEM_TOOLS.contains(&entry.as_str()) {
tracing::warn!(
agent_id = agent_id,
tool = entry.as_str(),
"stripped system permission from user agent role"
);
stripped.push(entry.clone());
} else {
allow.insert(Permission::Tool(entry.clone()));
}
}
let deny: HashSet<Permission> = role_config
.deny
.iter()
.map(|d| Permission::Tool(d.clone()))
.collect();
let role = Role {
name: format!("{}-role", agent_id),
allow,
deny,
};
let mut ac = self.access_control.write().expect("lock poisoned");
ac.add_role(agent_id, role);
stripped
}
pub fn check_tool(&self, agent_id: &str, tool_name: &str) -> Result<(), AccessDenied> {
let ac = self.access_control.read().expect("lock poisoned");
ac.check(agent_id, &Permission::Tool(tool_name.to_string()))
}
#[allow(dead_code)]
pub fn check_delegation(&self, caller_id: &str, target_id: &str) -> Result<(), AccessDenied> {
let ac = self.access_control.read().expect("lock poisoned");
ac.check(caller_id, &Permission::Agent(target_id.to_string()))
}
pub fn remove_agent(&self, agent_id: &str) {
let mut ac = self.access_control.write().expect("lock poisoned");
ac.remove_role(agent_id);
}
pub fn rebuild_from_registry(&self, registry: &AgentRegistry) {
let mut ac = self.access_control.write().expect("lock poisoned");
*ac = AccessControl::new();
for (id, record) in registry.list() {
if registry.is_system_agent(&id) {
let role = Role {
name: format!("{}-admin", id),
allow: [Permission::AllTools, Permission::AllAgents]
.into_iter()
.collect(),
deny: HashSet::new(),
};
ac.add_role(&id, role);
} else {
let mut allow = HashSet::new();
for entry in &record.config.role.allow {
if !SYSTEM_TOOLS.contains(&entry.as_str()) {
allow.insert(Permission::Tool(entry.clone()));
}
}
let deny: HashSet<Permission> = record
.config
.role
.deny
.iter()
.map(|d| Permission::Tool(d.clone()))
.collect();
let role = Role {
name: format!("{}-role", id),
allow,
deny,
};
ac.add_role(&id, role);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_config::{AgentConfig, AgentRoleConfig, AgentType};
fn make_config(id: &str) -> AgentConfig {
AgentConfig {
id: id.to_string(),
name: format!("Agent {}", id),
description: "test agent".to_string(),
agent_type: AgentType::Llm,
model: "test/model".to_string(),
api_key_env: "TEST_KEY".to_string(),
instruction: "do stuff".to_string(),
tools: vec![],
action_nodes: vec![],
workflow_edges: vec![],
sub_agents: vec![],
role: AgentRoleConfig {
allow: vec![],
deny: vec![],
},
channel_bindings: vec![],
auto_start: false,
temperature: None,
max_output_tokens: None,
model_override: None,
}
}
#[test]
fn system_agent_has_all_tools_access() {
let bridge = RbacBridge::new();
bridge.register_system_agent("system");
for tool in SYSTEM_TOOLS {
assert!(
bridge.check_tool("system", tool).is_ok(),
"system agent should have access to system tool '{}'",
tool
);
}
assert!(bridge.check_tool("system", "web_search").is_ok());
assert!(bridge.check_tool("system", "code_exec").is_ok());
assert!(bridge.check_delegation("system", "research").is_ok());
assert!(bridge.check_delegation("system", "writer").is_ok());
}
#[test]
fn stripped_system_permissions_are_logged() {
let bridge = RbacBridge::new();
let role_config = AgentRoleConfig {
allow: vec![
"web_search".to_string(),
"agent_create".to_string(),
"agent_delete".to_string(),
"code_exec".to_string(),
],
deny: vec![],
};
let stripped = bridge.register_agent("research", &role_config);
assert_eq!(stripped.len(), 2);
assert!(stripped.contains(&"agent_create".to_string()));
assert!(stripped.contains(&"agent_delete".to_string()));
assert!(bridge.check_tool("research", "agent_create").is_err());
assert!(bridge.check_tool("research", "agent_delete").is_err());
assert!(bridge.check_tool("research", "web_search").is_ok());
assert!(bridge.check_tool("research", "code_exec").is_ok());
}
#[test]
fn remove_agent_removes_role() {
let bridge = RbacBridge::new();
let role_config = AgentRoleConfig {
allow: vec!["web_search".to_string()],
deny: vec![],
};
bridge.register_agent("research", &role_config);
assert!(bridge.check_tool("research", "web_search").is_ok());
bridge.remove_agent("research");
assert!(bridge.check_tool("research", "web_search").is_err());
}
#[test]
fn check_delegation_works() {
let bridge = RbacBridge::new();
let role_config = AgentRoleConfig {
allow: vec!["web_search".to_string()],
deny: vec![],
};
bridge.register_agent("research", &role_config);
assert!(bridge.check_delegation("research", "writer").is_err());
}
#[test]
fn rebuild_from_registry_restores_roles() {
let tmp = tempfile::TempDir::new().unwrap();
let registry = AgentRegistry::new(tmp.path().join("registry"));
let mut sys_config = make_config("system");
sys_config.role.allow = vec!["*".to_string()];
registry.register_system_agent(sys_config).unwrap();
let mut user_config = make_config("research");
user_config.role.allow = vec!["web_search".to_string(), "code_exec".to_string()];
registry.create_agent(user_config).unwrap();
let bridge = RbacBridge::new();
bridge.rebuild_from_registry(®istry);
assert!(bridge.check_tool("system", "agent_create").is_ok());
assert!(bridge.check_tool("system", "web_search").is_ok());
assert!(bridge.check_tool("research", "web_search").is_ok());
assert!(bridge.check_tool("research", "code_exec").is_ok());
assert!(bridge.check_tool("research", "agent_create").is_err());
}
#[test]
fn deny_list_takes_precedence() {
let bridge = RbacBridge::new();
let role_config = AgentRoleConfig {
allow: vec!["web_search".to_string()],
deny: vec!["web_search".to_string()],
};
bridge.register_agent("research", &role_config);
assert!(bridge.check_tool("research", "web_search").is_err());
}
}