Skip to main content

actionqueue_platform/
rbac.rs

1//! Role-based access control enforcement.
2
3use std::collections::{HashMap, HashSet};
4
5use actionqueue_core::ids::{ActorId, TenantId};
6use actionqueue_core::platform::{Capability, Role};
7use tracing;
8
9/// Error returned when RBAC enforcement rejects an action.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum RbacError {
12    /// Actor has no role assigned in the given tenant.
13    NoRoleAssigned { actor_id: ActorId, tenant_id: TenantId },
14    /// Actor lacks the required capability in the given tenant.
15    MissingCapability { actor_id: ActorId, capability: Capability, tenant_id: TenantId },
16}
17
18impl std::fmt::Display for RbacError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            RbacError::NoRoleAssigned { actor_id, tenant_id } => {
22                write!(f, "actor {actor_id} has no role in tenant {tenant_id}")
23            }
24            RbacError::MissingCapability { actor_id, capability, tenant_id } => {
25                write!(f, "actor {actor_id} lacks capability {capability:?} in tenant {tenant_id}")
26            }
27        }
28    }
29}
30
31impl std::error::Error for RbacError {}
32
33/// In-memory RBAC enforcer.
34///
35/// Reconstructed from WAL events at bootstrap via role assignment and
36/// capability grant/revoke calls.
37#[derive(Default)]
38pub struct RbacEnforcer {
39    /// (actor_id, tenant_id) → Role
40    role_assignments: HashMap<(ActorId, TenantId), Role>,
41    /// (actor_id, tenant_id) → Set of granted capabilities
42    capability_grants: HashMap<(ActorId, TenantId), HashSet<String>>,
43}
44
45impl RbacEnforcer {
46    /// Creates an empty enforcer.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Assigns a role to an actor within a tenant.
52    pub fn assign_role(&mut self, actor_id: ActorId, role: Role, tenant_id: TenantId) {
53        tracing::debug!(%actor_id, ?role, %tenant_id, "role assigned");
54        self.role_assignments.insert((actor_id, tenant_id), role);
55    }
56
57    /// Grants a capability to an actor within a tenant.
58    pub fn grant_capability(
59        &mut self,
60        actor_id: ActorId,
61        capability: Capability,
62        tenant_id: TenantId,
63    ) {
64        tracing::debug!(%actor_id, ?capability, %tenant_id, "capability granted");
65        self.capability_grants
66            .entry((actor_id, tenant_id))
67            .or_default()
68            .insert(capability_key(&capability));
69    }
70
71    /// Revokes a capability from an actor within a tenant.
72    pub fn revoke_capability(
73        &mut self,
74        actor_id: ActorId,
75        capability: &Capability,
76        tenant_id: TenantId,
77    ) {
78        tracing::debug!(%actor_id, ?capability, %tenant_id, "capability revoked");
79        if let Some(caps) = self.capability_grants.get_mut(&(actor_id, tenant_id)) {
80            caps.remove(&capability_key(capability));
81        }
82    }
83
84    /// Returns `true` if the actor has the given capability in the tenant.
85    pub fn has_capability(
86        &self,
87        actor_id: ActorId,
88        capability: &Capability,
89        tenant_id: TenantId,
90    ) -> bool {
91        self.capability_grants
92            .get(&(actor_id, tenant_id))
93            .is_some_and(|caps| caps.contains(&capability_key(capability)))
94    }
95
96    /// Returns the role assigned to an actor in a tenant, if any.
97    pub fn role_of(&self, actor_id: ActorId, tenant_id: TenantId) -> Option<&Role> {
98        self.role_assignments.get(&(actor_id, tenant_id))
99    }
100
101    /// Returns `Ok(())` if the actor has the capability, `Err(RbacError)` otherwise.
102    pub fn check_permission(
103        &self,
104        actor_id: ActorId,
105        capability: &Capability,
106        tenant_id: TenantId,
107    ) -> Result<(), RbacError> {
108        if self.role_of(actor_id, tenant_id).is_none() {
109            let err = RbacError::NoRoleAssigned { actor_id, tenant_id };
110            tracing::warn!(%actor_id, %tenant_id, "permission denied: no role assigned");
111            return Err(err);
112        }
113        if !self.has_capability(actor_id, capability, tenant_id) {
114            tracing::warn!(
115                %actor_id, ?capability, %tenant_id,
116                "permission denied: missing capability"
117            );
118            return Err(RbacError::MissingCapability {
119                actor_id,
120                capability: capability.clone(),
121                tenant_id,
122            });
123        }
124        Ok(())
125    }
126}
127
128fn capability_key(cap: &Capability) -> String {
129    match cap {
130        Capability::CanSubmit => "CanSubmit".to_string(),
131        Capability::CanExecute => "CanExecute".to_string(),
132        Capability::CanReview => "CanReview".to_string(),
133        Capability::CanApprove => "CanApprove".to_string(),
134        Capability::CanCancel => "CanCancel".to_string(),
135        Capability::Custom(s) => format!("Custom:{s}"),
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use actionqueue_core::ids::{ActorId, TenantId};
142    use actionqueue_core::platform::{Capability, Role};
143
144    use super::RbacEnforcer;
145
146    #[test]
147    fn assign_role_and_grant_capability() {
148        let mut enforcer = RbacEnforcer::new();
149        let actor = ActorId::new();
150        let tenant = TenantId::new();
151
152        enforcer.assign_role(actor, Role::Operator, tenant);
153        enforcer.grant_capability(actor, Capability::CanSubmit, tenant);
154
155        assert_eq!(enforcer.role_of(actor, tenant), Some(&Role::Operator));
156        assert!(enforcer.has_capability(actor, &Capability::CanSubmit, tenant));
157        assert!(enforcer.check_permission(actor, &Capability::CanSubmit, tenant).is_ok());
158    }
159
160    #[test]
161    fn check_permission_rejects_missing_role() {
162        let enforcer = RbacEnforcer::new();
163        let actor = ActorId::new();
164        let tenant = TenantId::new();
165        let result = enforcer.check_permission(actor, &Capability::CanSubmit, tenant);
166        assert!(matches!(result, Err(super::RbacError::NoRoleAssigned { .. })));
167    }
168
169    #[test]
170    fn check_permission_rejects_missing_capability() {
171        let mut enforcer = RbacEnforcer::new();
172        let actor = ActorId::new();
173        let tenant = TenantId::new();
174        enforcer.assign_role(actor, Role::Operator, tenant);
175        // Role assigned but no capabilities granted.
176        let result = enforcer.check_permission(actor, &Capability::CanExecute, tenant);
177        assert!(matches!(result, Err(super::RbacError::MissingCapability { .. })));
178    }
179
180    #[test]
181    fn revoke_removes_capability() {
182        let mut enforcer = RbacEnforcer::new();
183        let actor = ActorId::new();
184        let tenant = TenantId::new();
185        enforcer.assign_role(actor, Role::Operator, tenant);
186        enforcer.grant_capability(actor, Capability::CanSubmit, tenant);
187        enforcer.revoke_capability(actor, &Capability::CanSubmit, tenant);
188        assert!(!enforcer.has_capability(actor, &Capability::CanSubmit, tenant));
189    }
190}