use std::collections::{HashMap, HashSet};
use actionqueue_core::ids::{ActorId, TenantId};
use actionqueue_core::platform::{Capability, Role};
use tracing;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RbacError {
NoRoleAssigned { actor_id: ActorId, tenant_id: TenantId },
MissingCapability { actor_id: ActorId, capability: Capability, tenant_id: TenantId },
}
impl std::fmt::Display for RbacError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RbacError::NoRoleAssigned { actor_id, tenant_id } => {
write!(f, "actor {actor_id} has no role in tenant {tenant_id}")
}
RbacError::MissingCapability { actor_id, capability, tenant_id } => {
write!(f, "actor {actor_id} lacks capability {capability:?} in tenant {tenant_id}")
}
}
}
}
impl std::error::Error for RbacError {}
#[derive(Default)]
pub struct RbacEnforcer {
role_assignments: HashMap<(ActorId, TenantId), Role>,
capability_grants: HashMap<(ActorId, TenantId), HashSet<String>>,
}
impl RbacEnforcer {
pub fn new() -> Self {
Self::default()
}
pub fn assign_role(&mut self, actor_id: ActorId, role: Role, tenant_id: TenantId) {
tracing::debug!(%actor_id, ?role, %tenant_id, "role assigned");
self.role_assignments.insert((actor_id, tenant_id), role);
}
pub fn grant_capability(
&mut self,
actor_id: ActorId,
capability: Capability,
tenant_id: TenantId,
) {
tracing::debug!(%actor_id, ?capability, %tenant_id, "capability granted");
self.capability_grants
.entry((actor_id, tenant_id))
.or_default()
.insert(capability_key(&capability));
}
pub fn revoke_capability(
&mut self,
actor_id: ActorId,
capability: &Capability,
tenant_id: TenantId,
) {
tracing::debug!(%actor_id, ?capability, %tenant_id, "capability revoked");
if let Some(caps) = self.capability_grants.get_mut(&(actor_id, tenant_id)) {
caps.remove(&capability_key(capability));
}
}
pub fn has_capability(
&self,
actor_id: ActorId,
capability: &Capability,
tenant_id: TenantId,
) -> bool {
self.capability_grants
.get(&(actor_id, tenant_id))
.is_some_and(|caps| caps.contains(&capability_key(capability)))
}
pub fn role_of(&self, actor_id: ActorId, tenant_id: TenantId) -> Option<&Role> {
self.role_assignments.get(&(actor_id, tenant_id))
}
pub fn check_permission(
&self,
actor_id: ActorId,
capability: &Capability,
tenant_id: TenantId,
) -> Result<(), RbacError> {
if self.role_of(actor_id, tenant_id).is_none() {
let err = RbacError::NoRoleAssigned { actor_id, tenant_id };
tracing::warn!(%actor_id, %tenant_id, "permission denied: no role assigned");
return Err(err);
}
if !self.has_capability(actor_id, capability, tenant_id) {
tracing::warn!(
%actor_id, ?capability, %tenant_id,
"permission denied: missing capability"
);
return Err(RbacError::MissingCapability {
actor_id,
capability: capability.clone(),
tenant_id,
});
}
Ok(())
}
}
fn capability_key(cap: &Capability) -> String {
match cap {
Capability::CanSubmit => "CanSubmit".to_string(),
Capability::CanExecute => "CanExecute".to_string(),
Capability::CanReview => "CanReview".to_string(),
Capability::CanApprove => "CanApprove".to_string(),
Capability::CanCancel => "CanCancel".to_string(),
Capability::Custom(s) => format!("Custom:{s}"),
}
}
#[cfg(test)]
mod tests {
use actionqueue_core::ids::{ActorId, TenantId};
use actionqueue_core::platform::{Capability, Role};
use super::RbacEnforcer;
#[test]
fn assign_role_and_grant_capability() {
let mut enforcer = RbacEnforcer::new();
let actor = ActorId::new();
let tenant = TenantId::new();
enforcer.assign_role(actor, Role::Operator, tenant);
enforcer.grant_capability(actor, Capability::CanSubmit, tenant);
assert_eq!(enforcer.role_of(actor, tenant), Some(&Role::Operator));
assert!(enforcer.has_capability(actor, &Capability::CanSubmit, tenant));
assert!(enforcer.check_permission(actor, &Capability::CanSubmit, tenant).is_ok());
}
#[test]
fn check_permission_rejects_missing_role() {
let enforcer = RbacEnforcer::new();
let actor = ActorId::new();
let tenant = TenantId::new();
let result = enforcer.check_permission(actor, &Capability::CanSubmit, tenant);
assert!(matches!(result, Err(super::RbacError::NoRoleAssigned { .. })));
}
#[test]
fn check_permission_rejects_missing_capability() {
let mut enforcer = RbacEnforcer::new();
let actor = ActorId::new();
let tenant = TenantId::new();
enforcer.assign_role(actor, Role::Operator, tenant);
let result = enforcer.check_permission(actor, &Capability::CanExecute, tenant);
assert!(matches!(result, Err(super::RbacError::MissingCapability { .. })));
}
#[test]
fn revoke_removes_capability() {
let mut enforcer = RbacEnforcer::new();
let actor = ActorId::new();
let tenant = TenantId::new();
enforcer.assign_role(actor, Role::Operator, tenant);
enforcer.grant_capability(actor, Capability::CanSubmit, tenant);
enforcer.revoke_capability(actor, &Capability::CanSubmit, tenant);
assert!(!enforcer.has_capability(actor, &Capability::CanSubmit, tenant));
}
}