1use std::collections::{HashMap, HashSet};
4
5use actionqueue_core::ids::{ActorId, TenantId};
6use actionqueue_core::platform::{Capability, Role};
7use tracing;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum RbacError {
12 NoRoleAssigned { actor_id: ActorId, tenant_id: TenantId },
14 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#[derive(Default)]
38pub struct RbacEnforcer {
39 role_assignments: HashMap<(ActorId, TenantId), Role>,
41 capability_grants: HashMap<(ActorId, TenantId), HashSet<String>>,
43}
44
45impl RbacEnforcer {
46 pub fn new() -> Self {
48 Self::default()
49 }
50
51 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 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 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 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 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 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 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}