Skip to main content

fraiseql_auth/
operation_rbac.rs

1//! Operation-level Role-Based Access Control (RBAC).
2//!
3//! Defines the [`OperationPermission`] enum, the [`Role`] type that bundles a set
4//! of permissions, and the [`RBACPolicy`] engine that evaluates authorization
5//! decisions for authenticated users.
6
7use std::collections::HashMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{AuthError, error::Result, middleware::AuthenticatedUser};
12
13/// A discrete permission that can be granted to a [`Role`].
14///
15/// Each variant maps to one or more GraphQL mutations or system operations.
16/// The string representation returned by [`OperationPermission::as_str`] is used
17/// when storing role-permission mappings in configuration or databases.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[non_exhaustive]
20pub enum OperationPermission {
21    /// Create a new observer rule.
22    CreateRule,
23    /// Modify an existing observer rule.
24    UpdateRule,
25    /// Remove an observer rule.
26    DeleteRule,
27    /// Trigger immediate execution of an observer rule.
28    ExecuteRule,
29
30    /// Create a new action definition.
31    CreateAction,
32    /// Modify an existing action definition.
33    UpdateAction,
34    /// Remove an action definition.
35    DeleteAction,
36    /// Trigger execution of an action.
37    ExecuteAction,
38
39    /// Create, update, or remove webhook subscriptions.
40    ManageWebhooks,
41    /// Read or rotate application secrets.
42    ManageSecrets,
43    /// Create, modify, or disable user accounts.
44    ManageUsers,
45    /// Define and assign roles within the system.
46    ManageRoles,
47    /// Create or modify multi-tenant isolation boundaries.
48    ManageTenants,
49
50    /// Export data records from the system.
51    ExportData,
52    /// Import data records into the system.
53    ImportData,
54    /// Permanently delete data records.
55    DeleteData,
56
57    /// Read the security audit trail.
58    ViewAuditLogs,
59    /// Modify system-wide configuration settings.
60    ManageConfiguration,
61    /// Configure third-party integrations and connectors.
62    ManageIntegrations,
63}
64
65impl OperationPermission {
66    /// Human-readable name for the permission
67    pub const fn name(&self) -> &'static str {
68        match self {
69            Self::CreateRule => "Create Observer Rule",
70            Self::UpdateRule => "Update Observer Rule",
71            Self::DeleteRule => "Delete Observer Rule",
72            Self::ExecuteRule => "Execute Observer Rule",
73            Self::CreateAction => "Create Action",
74            Self::UpdateAction => "Update Action",
75            Self::DeleteAction => "Delete Action",
76            Self::ExecuteAction => "Execute Action",
77            Self::ManageWebhooks => "Manage Webhooks",
78            Self::ManageSecrets => "Manage Secrets",
79            Self::ManageUsers => "Manage Users",
80            Self::ManageRoles => "Manage Roles",
81            Self::ManageTenants => "Manage Tenants",
82            Self::ExportData => "Export Data",
83            Self::ImportData => "Import Data",
84            Self::DeleteData => "Delete Data",
85            Self::ViewAuditLogs => "View Audit Logs",
86            Self::ManageConfiguration => "Manage Configuration",
87            Self::ManageIntegrations => "Manage Integrations",
88        }
89    }
90
91    /// Convert to string for policy storage
92    pub const fn as_str(&self) -> &'static str {
93        match self {
94            Self::CreateRule => "create_rule",
95            Self::UpdateRule => "update_rule",
96            Self::DeleteRule => "delete_rule",
97            Self::ExecuteRule => "execute_rule",
98            Self::CreateAction => "create_action",
99            Self::UpdateAction => "update_action",
100            Self::DeleteAction => "delete_action",
101            Self::ExecuteAction => "execute_action",
102            Self::ManageWebhooks => "manage_webhooks",
103            Self::ManageSecrets => "manage_secrets",
104            Self::ManageUsers => "manage_users",
105            Self::ManageRoles => "manage_roles",
106            Self::ManageTenants => "manage_tenants",
107            Self::ExportData => "export_data",
108            Self::ImportData => "import_data",
109            Self::DeleteData => "delete_data",
110            Self::ViewAuditLogs => "view_audit_logs",
111            Self::ManageConfiguration => "manage_configuration",
112            Self::ManageIntegrations => "manage_integrations",
113        }
114    }
115}
116
117/// Predefined roles with their associated permissions
118#[derive(Debug, Clone)]
119pub struct Role {
120    /// Role name (e.g., `"admin"`, `"viewer"`)
121    pub name:        String,
122    /// Set of operations this role is allowed to perform
123    pub permissions: Vec<OperationPermission>,
124}
125
126impl Role {
127    /// Create a new role with specified permissions
128    pub const fn new(name: String, permissions: Vec<OperationPermission>) -> Self {
129        Self { name, permissions }
130    }
131
132    /// Check if role has a specific permission
133    pub fn has_permission(&self, permission: OperationPermission) -> bool {
134        self.permissions.contains(&permission)
135    }
136
137    /// Get all permissions for this role
138    pub fn get_permissions(&self) -> &[OperationPermission] {
139        &self.permissions
140    }
141}
142
143/// RBAC policy engine
144#[derive(Debug, Clone)]
145pub struct RBACPolicy {
146    roles: HashMap<String, Role>,
147}
148
149impl Default for RBACPolicy {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl RBACPolicy {
156    /// Create a new RBAC policy with default roles
157    pub fn new() -> Self {
158        let mut roles = HashMap::new();
159
160        // Admin role - full permissions
161        roles.insert(
162            "admin".to_string(),
163            Role::new(
164                "admin".to_string(),
165                vec![
166                    OperationPermission::CreateRule,
167                    OperationPermission::UpdateRule,
168                    OperationPermission::DeleteRule,
169                    OperationPermission::ExecuteRule,
170                    OperationPermission::CreateAction,
171                    OperationPermission::UpdateAction,
172                    OperationPermission::DeleteAction,
173                    OperationPermission::ExecuteAction,
174                    OperationPermission::ManageWebhooks,
175                    OperationPermission::ManageSecrets,
176                    OperationPermission::ManageUsers,
177                    OperationPermission::ManageRoles,
178                    OperationPermission::ManageTenants,
179                    OperationPermission::ExportData,
180                    OperationPermission::ImportData,
181                    OperationPermission::DeleteData,
182                    OperationPermission::ViewAuditLogs,
183                    OperationPermission::ManageConfiguration,
184                    OperationPermission::ManageIntegrations,
185                ],
186            ),
187        );
188
189        // Operator role - can modify rules and actions, view logs
190        roles.insert(
191            "operator".to_string(),
192            Role::new(
193                "operator".to_string(),
194                vec![
195                    OperationPermission::CreateRule,
196                    OperationPermission::UpdateRule,
197                    OperationPermission::DeleteRule,
198                    OperationPermission::ExecuteRule,
199                    OperationPermission::CreateAction,
200                    OperationPermission::UpdateAction,
201                    OperationPermission::DeleteAction,
202                    OperationPermission::ExecuteAction,
203                    OperationPermission::ManageWebhooks,
204                    OperationPermission::ExportData,
205                    OperationPermission::ViewAuditLogs,
206                ],
207            ),
208        );
209
210        // Viewer role - read-only access
211        roles.insert(
212            "viewer".to_string(),
213            Role::new(
214                "viewer".to_string(),
215                vec![
216                    OperationPermission::ExportData,
217                    OperationPermission::ViewAuditLogs,
218                ],
219            ),
220        );
221
222        Self { roles }
223    }
224
225    /// Register a custom role
226    pub fn register_role(&mut self, role: Role) {
227        self.roles.insert(role.name.clone(), role);
228    }
229
230    /// Check if a user has permission to perform an operation
231    ///
232    /// # Errors
233    ///
234    /// Returns `AuthError::Forbidden` if the user lacks the required permission.
235    pub fn authorize(
236        &self,
237        user: &AuthenticatedUser,
238        permission: OperationPermission,
239    ) -> Result<()> {
240        // Get user's roles (can be single role or array of roles)
241        let user_roles = self.extract_user_roles(user);
242
243        // Check if any of user's roles has the permission
244        for role_name in user_roles {
245            if let Some(role) = self.roles.get(&role_name) {
246                if role.has_permission(permission) {
247                    return Ok(());
248                }
249            }
250        }
251
252        Err(AuthError::Forbidden {
253            message: format!(
254                "User {} does not have permission to: {}",
255                user.user_id,
256                permission.name()
257            ),
258        })
259    }
260
261    /// Check multiple permissions at once
262    ///
263    /// # Errors
264    ///
265    /// Returns `AuthError::Forbidden` if the user lacks all of the given permissions.
266    pub fn authorize_any(
267        &self,
268        user: &AuthenticatedUser,
269        permissions: &[OperationPermission],
270    ) -> Result<()> {
271        for permission in permissions {
272            if self.authorize(user, *permission).is_ok() {
273                return Ok(());
274            }
275        }
276
277        Err(AuthError::Forbidden {
278            message: format!("User {} does not have any of the required permissions", user.user_id),
279        })
280    }
281
282    /// Check that user has all permissions
283    ///
284    /// # Errors
285    ///
286    /// Returns `AuthError::Forbidden` if the user lacks any of the required permissions.
287    pub fn authorize_all(
288        &self,
289        user: &AuthenticatedUser,
290        permissions: &[OperationPermission],
291    ) -> Result<()> {
292        for permission in permissions {
293            self.authorize(user, *permission)?;
294        }
295        Ok(())
296    }
297
298    /// Get all permissions for a user
299    pub fn get_user_permissions(&self, user: &AuthenticatedUser) -> Vec<OperationPermission> {
300        let user_roles = self.extract_user_roles(user);
301        let mut permissions = Vec::new();
302
303        for role_name in user_roles {
304            if let Some(role) = self.roles.get(&role_name) {
305                permissions.extend(role.get_permissions());
306            }
307        }
308
309        // Remove duplicates
310        permissions.sort_by_key(|p| *p as u32);
311        permissions.dedup();
312
313        permissions
314    }
315
316    /// Extract user's roles from their claims
317    fn extract_user_roles(&self, user: &AuthenticatedUser) -> Vec<String> {
318        let mut roles = Vec::new();
319
320        // Check for single role claim
321        if let Some(serde_json::Value::String(role)) = user.get_custom_claim("role") {
322            roles.push(role.clone());
323        }
324
325        // Check for roles array
326        if let Some(serde_json::Value::Array(role_array)) = user.get_custom_claim("roles") {
327            for role_val in role_array {
328                if let serde_json::Value::String(role_name) = role_val {
329                    roles.push(role_name.clone());
330                }
331            }
332        }
333
334        // Check for standard claim name variations
335        if let Some(serde_json::Value::Array(role_array)) = user.get_custom_claim("fraiseql_roles")
336        {
337            for role_val in role_array {
338                if let serde_json::Value::String(role_name) = role_val {
339                    roles.push(role_name.clone());
340                }
341            }
342        }
343
344        // Remove duplicates
345        roles.sort();
346        roles.dedup();
347
348        roles
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    #[allow(clippy::wildcard_imports)]
355    // Reason: test module — wildcard keeps test boilerplate minimal
356    use super::*;
357    use crate::jwt::Claims;
358
359    fn create_test_user(role: &str) -> AuthenticatedUser {
360        let mut extra = std::collections::HashMap::new();
361        extra.insert("role".to_string(), serde_json::json!(role));
362
363        AuthenticatedUser {
364            user_id: "test-user".to_string(),
365            claims:  Claims {
366                sub: "test-user".to_string(),
367                iat: 1_000_000,
368                exp: 2_000_000,
369                iss: "test-issuer".to_string(),
370                aud: vec!["fraiseql".to_string()],
371                extra,
372            },
373        }
374    }
375
376    fn create_test_user_with_roles(roles: Vec<&str>) -> AuthenticatedUser {
377        let mut extra = std::collections::HashMap::new();
378        extra.insert("roles".to_string(), serde_json::json!(roles));
379
380        AuthenticatedUser {
381            user_id: "test-user".to_string(),
382            claims:  Claims {
383                sub: "test-user".to_string(),
384                iat: 1_000_000,
385                exp: 2_000_000,
386                iss: "test-issuer".to_string(),
387                aud: vec!["fraiseql".to_string()],
388                extra,
389            },
390        }
391    }
392
393    #[test]
394    fn test_admin_has_all_permissions() {
395        let policy = RBACPolicy::new();
396        let user = create_test_user("admin");
397
398        let r = policy.authorize(&user, OperationPermission::CreateRule);
399        assert!(r.is_ok(), "admin should have CreateRule: {r:?}");
400        let r = policy.authorize(&user, OperationPermission::DeleteRule);
401        assert!(r.is_ok(), "admin should have DeleteRule: {r:?}");
402        let r = policy.authorize(&user, OperationPermission::ManageUsers);
403        assert!(r.is_ok(), "admin should have ManageUsers: {r:?}");
404        let r = policy.authorize(&user, OperationPermission::ManageTenants);
405        assert!(r.is_ok(), "admin should have ManageTenants: {r:?}");
406    }
407
408    #[test]
409    fn test_operator_has_limited_permissions() {
410        let policy = RBACPolicy::new();
411        let user = create_test_user("operator");
412
413        let r = policy.authorize(&user, OperationPermission::CreateRule);
414        assert!(r.is_ok(), "operator should have CreateRule: {r:?}");
415        let r = policy.authorize(&user, OperationPermission::ManageWebhooks);
416        assert!(r.is_ok(), "operator should have ManageWebhooks: {r:?}");
417        let r = policy.authorize(&user, OperationPermission::ManageUsers);
418        assert!(
419            matches!(r, Err(AuthError::Forbidden { .. })),
420            "operator should not have ManageUsers: {r:?}"
421        );
422        let r = policy.authorize(&user, OperationPermission::ManageTenants);
423        assert!(
424            matches!(r, Err(AuthError::Forbidden { .. })),
425            "operator should not have ManageTenants: {r:?}"
426        );
427    }
428
429    #[test]
430    fn test_viewer_has_minimal_permissions() {
431        let policy = RBACPolicy::new();
432        let user = create_test_user("viewer");
433
434        let r = policy.authorize(&user, OperationPermission::ExportData);
435        assert!(r.is_ok(), "viewer should have ExportData: {r:?}");
436        let r = policy.authorize(&user, OperationPermission::ViewAuditLogs);
437        assert!(r.is_ok(), "viewer should have ViewAuditLogs: {r:?}");
438        let r = policy.authorize(&user, OperationPermission::CreateRule);
439        assert!(
440            matches!(r, Err(AuthError::Forbidden { .. })),
441            "viewer should not have CreateRule: {r:?}"
442        );
443        let r = policy.authorize(&user, OperationPermission::ManageWebhooks);
444        assert!(
445            matches!(r, Err(AuthError::Forbidden { .. })),
446            "viewer should not have ManageWebhooks: {r:?}"
447        );
448    }
449
450    #[test]
451    fn test_multiple_roles() {
452        let policy = RBACPolicy::new();
453        let user = create_test_user_with_roles(vec!["viewer", "operator"]);
454
455        // Should have operator's permissions
456        let r = policy.authorize(&user, OperationPermission::CreateRule);
457        assert!(r.is_ok(), "viewer+operator should have CreateRule: {r:?}");
458        let r = policy.authorize(&user, OperationPermission::ExportData);
459        assert!(r.is_ok(), "viewer+operator should have ExportData: {r:?}");
460
461        // Should not have admin permissions
462        let r = policy.authorize(&user, OperationPermission::ManageTenants);
463        assert!(
464            matches!(r, Err(AuthError::Forbidden { .. })),
465            "viewer+operator should not have ManageTenants: {r:?}"
466        );
467    }
468
469    #[test]
470    fn test_authorize_any() {
471        let policy = RBACPolicy::new();
472        let user = create_test_user("viewer");
473
474        let permissions = vec![
475            OperationPermission::ManageTenants,
476            OperationPermission::ExportData,
477        ];
478
479        let r = policy.authorize_any(&user, &permissions);
480        assert!(r.is_ok(), "viewer should have at least one of the permissions: {r:?}");
481    }
482
483    #[test]
484    fn test_authorize_all() {
485        let policy = RBACPolicy::new();
486        let user = create_test_user("operator");
487
488        let permissions = vec![
489            OperationPermission::CreateRule,
490            OperationPermission::UpdateRule,
491        ];
492
493        let r = policy.authorize_all(&user, &permissions);
494        assert!(r.is_ok(), "operator should have all rule permissions: {r:?}");
495    }
496
497    #[test]
498    fn test_authorize_all_fails_if_missing_one() {
499        let policy = RBACPolicy::new();
500        let user = create_test_user("operator");
501
502        let permissions = vec![
503            OperationPermission::CreateRule,
504            OperationPermission::ManageTenants, // operator doesn't have this
505        ];
506
507        let r = policy.authorize_all(&user, &permissions);
508        assert!(
509            matches!(r, Err(AuthError::Forbidden { .. })),
510            "operator missing ManageTenants should fail authorize_all: {r:?}"
511        );
512    }
513
514    #[test]
515    fn test_get_user_permissions() {
516        let policy = RBACPolicy::new();
517        let user = create_test_user("viewer");
518
519        let permissions = policy.get_user_permissions(&user);
520        assert_eq!(permissions.len(), 2);
521        assert!(permissions.contains(&OperationPermission::ExportData));
522        assert!(permissions.contains(&OperationPermission::ViewAuditLogs));
523    }
524
525    #[test]
526    fn test_custom_role() {
527        let mut policy = RBACPolicy::new();
528
529        let custom_role = Role::new(
530            "auditor".to_string(),
531            vec![
532                OperationPermission::ViewAuditLogs,
533                OperationPermission::ExportData,
534            ],
535        );
536
537        policy.register_role(custom_role);
538        let user = create_test_user("auditor");
539
540        let r = policy.authorize(&user, OperationPermission::ViewAuditLogs);
541        assert!(r.is_ok(), "auditor should have ViewAuditLogs: {r:?}");
542        let r = policy.authorize(&user, OperationPermission::CreateRule);
543        assert!(
544            matches!(r, Err(AuthError::Forbidden { .. })),
545            "auditor should not have CreateRule: {r:?}"
546        );
547    }
548
549    #[test]
550    fn test_permission_string_format() {
551        assert_eq!(OperationPermission::CreateRule.as_str(), "create_rule");
552        assert_eq!(OperationPermission::ManageSecrets.as_str(), "manage_secrets");
553        assert_eq!(OperationPermission::ViewAuditLogs.as_str(), "view_audit_logs");
554    }
555}