use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use crate::types::AgentId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Role {
User,
Superuser,
Admin,
}
impl Role {
pub fn default_policy(&self) -> RbacPolicy {
match self {
Role::Admin => RbacPolicy {
role: Role::Admin,
allowed_actions: vec![
Action::UseTool("*".into()),
Action::AccessPath("*".into()),
Action::ManageAgents,
Action::ManagePrograms,
Action::ManageWorkspaces,
Action::ManageRBAC,
Action::ViewAuditLog,
Action::SystemConfig,
]
.into_iter()
.collect(),
resource_patterns: vec!["*".into()],
max_concurrent_agents: usize::MAX,
},
Role::Superuser => RbacPolicy {
role: Role::Superuser,
allowed_actions: vec![
Action::UseTool("*".into()),
Action::AccessPath("/workspace/**".into()),
Action::ManageAgents,
Action::ManagePrograms,
Action::ManageWorkspaces,
Action::ViewAuditLog,
]
.into_iter()
.collect(),
resource_patterns: vec!["/workspace/**".into(), "/tmp/**".into()],
max_concurrent_agents: 10,
},
Role::User => RbacPolicy {
role: Role::User,
allowed_actions: vec![
Action::UseTool("read".into()),
Action::UseTool("write".into()),
Action::UseTool("edit".into()),
Action::UseTool("bash".into()),
Action::UseTool("grep".into()),
Action::UseTool("find".into()),
Action::AccessPath("/workspace/**".into()),
Action::ManageAgents,
]
.into_iter()
.collect(),
resource_patterns: vec!["/workspace/**".into()],
max_concurrent_agents: 2,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Subject {
User(String),
Agent(AgentId),
System,
}
impl std::fmt::Display for Subject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Subject::User(name) => write!(f, "user:{name}"),
Subject::Agent(id) => write!(f, "agent:{id}"),
Subject::System => write!(f, "system"),
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub enum Action {
UseTool(String),
AccessPath(String),
ManageAgents,
ManagePrograms,
ManageWorkspaces,
ManageRBAC,
ViewAuditLog,
SystemConfig,
}
impl Action {
pub fn requires_approval(&self) -> bool {
match self {
Action::ManageRBAC | Action::SystemConfig => true,
Action::UseTool(t) => t == "*" || t == "osascript" || t == "rm",
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RbacPolicy {
pub role: Role,
pub allowed_actions: HashSet<Action>,
pub resource_patterns: Vec<String>,
pub max_concurrent_agents: usize,
}
impl RbacPolicy {
pub fn allows(&self, action: &Action) -> bool {
if self.allowed_actions.contains(action) {
return true;
}
match action {
Action::UseTool(tool_name) => {
self.allowed_actions
.iter()
.any(|a| matches!(a, Action::UseTool(w) if w == "*"))
|| self.allowed_actions.contains(&Action::UseTool(tool_name.clone()))
}
Action::AccessPath(path) => {
self.allowed_actions
.iter()
.any(|a| matches!(a, Action::AccessPath(p) if p == "*"))
|| self
.allowed_actions
.contains(&Action::AccessPath(path.clone()))
}
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RbacAuditEntry {
pub timestamp: DateTime<Utc>,
pub subject: Subject,
pub action: Action,
pub resource: String,
pub allowed: bool,
pub reason: Option<String>,
}
impl RbacAuditEntry {
pub(crate) fn new(
subject: Subject,
action: Action,
resource: String,
allowed: bool,
reason: Option<String>,
) -> Self {
Self {
timestamp: Utc::now(),
subject,
action,
resource,
allowed,
reason,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingApproval {
pub id: uuid::Uuid,
pub subject: Subject,
pub action: Action,
pub resource: String,
pub reason: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApprovalStatus {
Pending,
Approved,
Rejected,
Expired,
}
#[derive(Debug, Clone)]
pub struct RbacManager {
policies: HashMap<Role, RbacPolicy>,
subject_roles: HashMap<Subject, Role>,
audit_log: Vec<RbacAuditEntry>,
pending_approvals: Vec<(PendingApproval, ApprovalStatus)>,
max_audit_entries: usize,
}
impl RbacManager {
pub fn new() -> Self {
let mut this = Self {
policies: HashMap::new(),
subject_roles: HashMap::new(),
audit_log: Vec::new(),
pending_approvals: Vec::new(),
max_audit_entries: 10_000,
};
for role in [Role::User, Role::Superuser, Role::Admin] {
this.policies.insert(role, role.default_policy());
}
this
}
pub fn assign_role(&mut self, subject: Subject, role: Role) {
self.subject_roles.insert(subject.clone(), role);
}
pub fn revoke_role(&mut self, subject: &Subject) {
self.subject_roles.remove(subject);
}
pub fn get_role(&self, subject: &Subject) -> Option<Role> {
self.subject_roles.get(subject).copied()
}
pub fn check_permission(&mut self, subject: &Subject, action: &Action, resource: &str) -> bool {
if matches!(subject, Subject::System) {
return true;
}
let role = match self.subject_roles.get(subject) {
Some(r) => *r,
None => return false,
};
let policy = match self.policies.get(&role) {
Some(p) => p,
None => return false,
};
let allowed = policy.allows(action);
self.audit_log.push(RbacAuditEntry::new(
subject.clone(),
action.clone(),
resource.to_string(),
allowed,
if allowed {
None
} else {
Some(format!("role {role:?} does not allow {action:?}"))
},
));
if self.audit_log.len() > self.max_audit_entries {
self.audit_log
.drain(0..self.audit_log.len() - self.max_audit_entries);
}
allowed
}
pub fn request_approval(
&mut self,
subject: Subject,
action: Action,
resource: String,
reason: String,
) -> uuid::Uuid {
let id = uuid::Uuid::new_v4();
self.pending_approvals.push((
PendingApproval {
id,
subject,
action,
resource,
reason,
created_at: Utc::now(),
},
ApprovalStatus::Pending,
));
id
}
pub fn approve(&mut self, id: uuid::Uuid) -> bool {
if let Some((_, s)) = self
.pending_approvals
.iter_mut()
.find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
{
*s = ApprovalStatus::Approved;
return true;
}
false
}
pub fn reject(&mut self, id: uuid::Uuid) -> bool {
if let Some((_, s)) = self
.pending_approvals
.iter_mut()
.find(|(p, s)| p.id == id && *s == ApprovalStatus::Pending)
{
*s = ApprovalStatus::Rejected;
return true;
}
false
}
pub fn pending_approvals(&self) -> Vec<&PendingApproval> {
self.pending_approvals
.iter()
.filter(|(_, s)| matches!(s, ApprovalStatus::Pending))
.map(|(p, _)| p)
.collect()
}
pub fn all_approvals(&self) -> &[(PendingApproval, ApprovalStatus)] {
&self.pending_approvals
}
pub fn audit_log(&self) -> &[RbacAuditEntry] {
&self.audit_log
}
}
impl Default for RbacManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_policies_exist() {
let mgr = RbacManager::new();
assert!(mgr.policies.contains_key(&Role::User));
assert!(mgr.policies.contains_key(&Role::Superuser));
assert!(mgr.policies.contains_key(&Role::Admin));
}
#[test]
fn test_role_assignment() {
let mut mgr = RbacManager::new();
let subject = Subject::User("alice".into());
mgr.assign_role(subject.clone(), Role::Admin);
assert_eq!(mgr.get_role(&subject), Some(Role::Admin));
mgr.revoke_role(&subject);
assert_eq!(mgr.get_role(&subject), None);
}
#[test]
fn test_system_bypasses_rbac() {
let mut mgr = RbacManager::new();
let subject = Subject::System;
assert!(mgr.check_permission(&subject, &Action::ManageRBAC, "test"));
}
#[test]
fn test_unknown_subject_denied() {
let mut mgr = RbacManager::new();
let subject = Subject::User("nobody".into());
assert!(!mgr.check_permission(&subject, &Action::UseTool("read".into()), "test"));
}
#[test]
fn test_user_allowed_specific_tools() {
let mut mgr = RbacManager::new();
let subject = Subject::User("bob".into());
mgr.assign_role(subject.clone(), Role::User);
assert!(mgr.check_permission(&subject, &Action::UseTool("read".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::UseTool("write".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::UseTool("bash".into()), "test"));
}
#[test]
fn test_user_denied_admin_tools() {
let mut mgr = RbacManager::new();
let subject = Subject::User("bob".into());
mgr.assign_role(subject.clone(), Role::User);
assert!(!mgr.check_permission(&subject, &Action::ManageRBAC, "test"));
assert!(!mgr.check_permission(&subject, &Action::SystemConfig, "test"));
}
#[test]
fn test_admin_wildcard_allows_all_tools() {
let mut mgr = RbacManager::new();
let subject = Subject::User("admin".into());
mgr.assign_role(subject.clone(), Role::Admin);
assert!(mgr.check_permission(&subject, &Action::UseTool("any_tool".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::UseTool("custom_thing".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::UseTool("dangerous".into()), "test"));
}
#[test]
fn test_superuser_wildcard_allows_all_tools() {
let mut mgr = RbacManager::new();
let subject = Subject::User("super".into());
mgr.assign_role(subject.clone(), Role::Superuser);
assert!(mgr.check_permission(&subject, &Action::UseTool("custom".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::UseTool("anything".into()), "test"));
}
#[test]
fn test_admin_all_paths_wildcard() {
let mut mgr = RbacManager::new();
let subject = Subject::User("admin".into());
mgr.assign_role(subject.clone(), Role::Admin);
assert!(mgr.check_permission(&subject, &Action::AccessPath("/any/path".into()), "test"));
assert!(mgr.check_permission(&subject, &Action::AccessPath("/secret/data".into()), "test"));
}
#[test]
fn test_policy_allows_exact_match() {
let policy = Role::User.default_policy();
assert!(policy.allows(&Action::UseTool("read".into())));
assert!(policy.allows(&Action::UseTool("bash".into())));
assert!(!policy.allows(&Action::UseTool("unknown_tool".into())));
}
#[test]
fn test_policy_allows_wildcard() {
let policy = Role::Admin.default_policy();
assert!(policy.allows(&Action::UseTool("literally_anything".into())));
assert!(policy.allows(&Action::AccessPath("/some/random/path".into())));
}
#[test]
fn test_approval_request_lifecycle() {
let mut mgr = RbacManager::new();
let id = mgr.request_approval(
Subject::User("alice".into()),
Action::ManageRBAC,
"rbac".into(),
"need admin".into(),
);
let pending = mgr.pending_approvals();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, id);
assert!(mgr.approve(id));
assert!(mgr.pending_approvals().is_empty());
assert!(!mgr.approve(id));
}
#[test]
fn test_approval_rejection() {
let mut mgr = RbacManager::new();
let id = mgr.request_approval(
Subject::User("alice".into()),
Action::SystemConfig,
"config".into(),
"need config".into(),
);
assert!(mgr.reject(id));
assert!(mgr.pending_approvals().is_empty());
}
#[test]
fn test_approval_nonexistent() {
let mut mgr = RbacManager::new();
assert!(!mgr.approve(uuid::Uuid::new_v4()));
assert!(!mgr.reject(uuid::Uuid::new_v4()));
}
#[test]
fn test_audit_log_recorded() {
let mut mgr = RbacManager::new();
let subject = Subject::User("alice".into());
mgr.assign_role(subject.clone(), Role::User);
mgr.check_permission(&subject, &Action::UseTool("read".into()), "test");
assert!(!mgr.audit_log().is_empty());
let entry = &mgr.audit_log()[0];
assert!(entry.allowed);
}
#[test]
fn test_audit_log_denied_recorded() {
let mut mgr = RbacManager::new();
let subject = Subject::User("alice".into());
mgr.assign_role(subject.clone(), Role::User);
mgr.check_permission(&subject, &Action::ManageRBAC, "test");
let denied_entries: Vec<_> = mgr.audit_log().iter().filter(|e| !e.allowed).collect();
assert_eq!(denied_entries.len(), 1);
}
}