Skip to main content

sz_orm_auth/
authorizer.rs

1//! Role-Based Access Control (RBAC) authorizer.
2//!
3//! This module provides an [`RbacAuthorizer`] that maps roles to a set of
4//! permissions and answers `can(action, resource)` queries. Permissions may
5//! be either `action:resource` pairs or a wildcard `*` that grants full access.
6
7use std::collections::{HashMap, HashSet};
8
9use crate::auth::User;
10use crate::error::AuthError;
11
12pub trait Authorizer: Send + Sync {
13    /// Returns Ok(true) if `user` is allowed to perform `action` on `resource`.
14    fn can(&self, user: &User, action: &str, resource: &str) -> Result<bool, AuthError>;
15}
16
17/// Role-based authorizer backed by a `role -> permissions` map.
18pub struct RbacAuthorizer {
19    role_permissions: HashMap<String, HashSet<String>>,
20}
21
22impl RbacAuthorizer {
23    /// Creates a new authorizer with the `admin` role granted the `*` wildcard.
24    pub fn new() -> Self {
25        let mut role_permissions = HashMap::new();
26        role_permissions.insert("admin".to_string(), HashSet::from(["*".to_string()]));
27        Self { role_permissions }
28    }
29
30    /// Grants `permission` to `role`. Returns `self` for chaining.
31    pub fn with_role_permission(mut self, role: &str, permission: &str) -> Self {
32        self.role_permissions
33            .entry(role.to_string())
34            .or_default()
35            .insert(permission.to_string());
36        self
37    }
38
39    /// Grants `permission` to `role` in place.
40    pub fn grant(&mut self, role: &str, permission: &str) {
41        self.role_permissions
42            .entry(role.to_string())
43            .or_default()
44            .insert(permission.to_string());
45    }
46
47    /// Revokes `permission` from `role` if present.
48    pub fn revoke(&mut self, role: &str, permission: &str) {
49        if let Some(perms) = self.role_permissions.get_mut(role) {
50            perms.remove(permission);
51        }
52    }
53
54    /// Returns true if `role` has been granted `permission` (or the wildcard).
55    pub fn role_has_permission(&self, role: &str, permission: &str) -> bool {
56        self.role_permissions
57            .get(role)
58            .map(|perms| perms.contains(permission) || perms.contains("*"))
59            .unwrap_or(false)
60    }
61
62    /// Returns all permissions currently attached to `role`.
63    pub fn permissions_for_role(&self, role: &str) -> Vec<String> {
64        self.role_permissions
65            .get(role)
66            .map(|perms| {
67                let mut v: Vec<String> = perms.iter().cloned().collect();
68                v.sort();
69                v
70            })
71            .unwrap_or_default()
72    }
73
74    fn check_permission(&self, user: &User, permission: &str) -> bool {
75        // 1. Direct user-level permission (or wildcard).
76        if user.permissions.iter().any(|p| p == permission || p == "*") {
77            return true;
78        }
79        // 2. Role-based permission (or wildcard).
80        for role in &user.roles {
81            if self.role_has_permission(role, permission) {
82                return true;
83            }
84        }
85        false
86    }
87}
88
89impl Default for RbacAuthorizer {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl Authorizer for RbacAuthorizer {
96    fn can(&self, user: &User, action: &str, resource: &str) -> Result<bool, AuthError> {
97        let specific = format!("{}:{}", action, resource);
98        if self.check_permission(user, &specific) {
99            return Ok(true);
100        }
101        // Fall back to action-level permission (e.g. "read" grants "read:foo").
102        if self.check_permission(user, action) {
103            return Ok(true);
104        }
105        Ok(false)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn make_user(id: i64, name: &str) -> User {
114        User::new(id, name)
115    }
116
117    #[test]
118    fn test_admin_role_has_all_permissions() {
119        let authz = RbacAuthorizer::new();
120        let user = make_user(1, "root").with_roles(vec!["admin".to_string()]);
121
122        assert!(authz.can(&user, "read", "posts").unwrap());
123        assert!(authz.can(&user, "delete", "users").unwrap());
124        assert!(authz.can(&user, "anything", "anything").unwrap());
125    }
126
127    #[test]
128    fn test_direct_user_permission_grants_action_resource() {
129        let authz = RbacAuthorizer::new();
130        let user = make_user(1, "alice").with_permissions(vec!["read:posts".to_string()]);
131
132        assert!(authz.can(&user, "read", "posts").unwrap());
133        // Different resource is not allowed
134        assert!(!authz.can(&user, "read", "users").unwrap());
135        assert!(!authz.can(&user, "delete", "posts").unwrap());
136    }
137
138    #[test]
139    fn test_action_only_permission_grants_all_resources() {
140        let authz = RbacAuthorizer::new();
141        let user = make_user(1, "bob").with_permissions(vec!["read".to_string()]);
142
143        assert!(authz.can(&user, "read", "posts").unwrap());
144        assert!(authz.can(&user, "read", "users").unwrap());
145        assert!(!authz.can(&user, "write", "posts").unwrap());
146    }
147
148    #[test]
149    fn test_role_grants_permission() {
150        let authz = RbacAuthorizer::new()
151            .with_role_permission("editor", "write:posts")
152            .with_role_permission("viewer", "read:posts");
153
154        let editor = make_user(1, "ed").with_roles(vec!["editor".to_string()]);
155        let viewer = make_user(2, "vi").with_roles(vec!["viewer".to_string()]);
156
157        assert!(authz.can(&editor, "write", "posts").unwrap());
158        assert!(!authz.can(&editor, "delete", "posts").unwrap());
159
160        assert!(authz.can(&viewer, "read", "posts").unwrap());
161        assert!(!authz.can(&viewer, "write", "posts").unwrap());
162    }
163
164    #[test]
165    fn test_grant_and_revoke() {
166        let mut authz = RbacAuthorizer::new();
167        authz.grant("editor", "write:posts");
168        let user = make_user(1, "ed").with_roles(vec!["editor".to_string()]);
169        assert!(authz.can(&user, "write", "posts").unwrap());
170
171        authz.revoke("editor", "write:posts");
172        assert!(!authz.can(&user, "write", "posts").unwrap());
173    }
174
175    #[test]
176    fn test_user_with_no_permissions_is_denied() {
177        let authz = RbacAuthorizer::new();
178        let user = make_user(1, "anon");
179        assert!(!authz.can(&user, "read", "anything").unwrap());
180    }
181
182    #[test]
183    fn test_permissions_for_role() {
184        let authz = RbacAuthorizer::new()
185            .with_role_permission("editor", "write:posts")
186            .with_role_permission("editor", "read:posts");
187
188        let perms = authz.permissions_for_role("editor");
189        assert_eq!(
190            perms,
191            vec!["read:posts".to_string(), "write:posts".to_string()]
192        );
193        assert!(authz.permissions_for_role("nonexistent").is_empty());
194    }
195
196    #[test]
197    fn test_user_permission_overrides_role() {
198        // Even if user has no roles, direct permission should grant access.
199        let authz = RbacAuthorizer::new();
200        let user = make_user(1, "lone")
201            .with_permissions(vec!["read:posts".to_string(), "write:posts".to_string()]);
202        assert!(authz.can(&user, "read", "posts").unwrap());
203        assert!(authz.can(&user, "write", "posts").unwrap());
204    }
205
206    #[test]
207    fn test_multiple_roles_combination() {
208        let authz = RbacAuthorizer::new()
209            .with_role_permission("reader", "read:posts")
210            .with_role_permission("writer", "write:posts");
211        let user =
212            make_user(1, "combo").with_roles(vec!["reader".to_string(), "writer".to_string()]);
213        assert!(authz.can(&user, "read", "posts").unwrap());
214        assert!(authz.can(&user, "write", "posts").unwrap());
215        assert!(!authz.can(&user, "delete", "posts").unwrap());
216    }
217
218    #[test]
219    fn test_default_admin_wildcard() {
220        let authz = RbacAuthorizer::new();
221        assert!(authz.role_has_permission("admin", "anything"));
222        assert!(authz.role_has_permission("admin", "*"));
223        assert!(!authz.role_has_permission("editor", "anything"));
224    }
225}