Skip to main content

fraiseql_core/graphql/
require_permission_directive.rs

1//! @require_permission directive handler for field-level RBAC
2//!
3//! Implements field-level authorization via GraphQL directives.
4//! Users must have explicit permission to access protected fields.
5
6use std::collections::HashMap;
7
8use serde_json::Value as JsonValue;
9
10use crate::graphql::directive_evaluator::{
11    DirectiveError, DirectiveHandler, DirectiveResult, EvaluationContext,
12};
13
14/// Handles @`require_permission` directives for field-level access control.
15///
16/// # Directive Syntax
17///
18/// ```graphql
19/// type Query {
20///   users: [User!]! @require_permission(permission: "query:users:read")
21///   adminPanel: String! @require_permission(permission: "admin:*")
22/// }
23/// ```
24///
25/// # Permission Format
26///
27/// Permissions use a colon-separated format: `resource:action:scope`
28///
29/// - `*:*` - Full wildcard (admin access)
30/// - `query:*` - All query operations
31/// - `query:users:read` - Specific query permission
32/// - `admin:*` - Admin operations
33///
34/// # Field Masking
35///
36/// Optional `maskValue` argument masks sensitive field values:
37///
38/// ```graphql
39/// email: String! @require_permission(
40///   permission: "read:User.email",
41///   maskValue: "[REDACTED]"
42/// )
43/// ```
44pub struct RequirePermissionDirective;
45
46impl RequirePermissionDirective {
47    /// Create a new `require_permission` directive handler.
48    #[must_use]
49    pub const fn new() -> Self {
50        Self
51    }
52
53    /// Check if a user's permission matches a required permission.
54    ///
55    /// Supports wildcard matching:
56    /// - `*:*` matches everything
57    /// - `query:*` matches any query permission
58    /// - `query:users:*` matches any user query permission
59    /// - `query:users:read` matches exact permission
60    pub(crate) fn permission_matches(user_permission: &str, required_permission: &str) -> bool {
61        // Exact match
62        if user_permission == required_permission {
63            return true;
64        }
65
66        // Wildcard: user has full admin access
67        if user_permission == "*:*" {
68            return true;
69        }
70
71        // Wildcard matching (e.g., "query:*" matches "query:users:read")
72        let user_parts: Vec<&str> = user_permission.split(':').collect();
73        let required_parts: Vec<&str> = required_permission.split(':').collect();
74
75        // If last part is a wildcard, check if prefix matches
76        if let Some(&last_part) = user_parts.last() {
77            if last_part == "*" {
78                let user_prefix_len = user_parts.len() - 1;
79                if user_prefix_len <= required_parts.len() {
80                    return user_parts[..user_prefix_len] == required_parts[..user_prefix_len];
81                }
82            }
83        }
84
85        false
86    }
87
88    /// Extract user permissions from the evaluation context.
89    ///
90    /// Permissions are expected to be stored in the "permissions" key as a JSON array of strings.
91    fn get_user_permissions(context: &EvaluationContext) -> Vec<String> {
92        context
93            .get_user_context("permissions")
94            .and_then(|v| v.as_array())
95            .map(|perms| perms.iter().filter_map(|p| p.as_str().map(String::from)).collect())
96            .unwrap_or_default()
97    }
98
99    /// Check if user has required permission.
100    ///
101    /// Returns true if any user permission matches the required permission.
102    fn user_has_permission(required_permission: &str, user_permissions: &[String]) -> bool {
103        user_permissions
104            .iter()
105            .any(|perm| Self::permission_matches(perm, required_permission))
106    }
107}
108
109impl Default for RequirePermissionDirective {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl DirectiveHandler for RequirePermissionDirective {
116    fn name(&self) -> &'static str {
117        "require_permission"
118    }
119
120    fn evaluate(
121        &self,
122        args: &HashMap<String, JsonValue>,
123        context: &EvaluationContext,
124    ) -> Result<DirectiveResult, DirectiveError> {
125        // Get required permission from arguments
126        let required_permission = args
127            .get("permission")
128            .and_then(|v| v.as_str())
129            .ok_or_else(|| DirectiveError::MissingDirectiveArgument("permission".to_string()))?;
130
131        // Get user permissions from context
132        let user_permissions = Self::get_user_permissions(context);
133
134        // Check if user has required permission
135        if Self::user_has_permission(required_permission, &user_permissions) {
136            // User is authorized - include field
137            return Ok(DirectiveResult::Include);
138        }
139
140        // User lacks permission - check if masking is requested
141        if let Some(mask_value) = args.get("maskValue") {
142            return Ok(DirectiveResult::Transform(mask_value.clone()));
143        }
144
145        // Deny access
146        Ok(DirectiveResult::Error(format!(
147            "User lacks required permission: {}",
148            required_permission
149        )))
150    }
151
152    fn validate_args(&self, args: &HashMap<String, JsonValue>) -> Result<(), DirectiveError> {
153        // Check that permission argument is present and is a string
154        if !args.contains_key("permission") {
155            return Err(DirectiveError::MissingDirectiveArgument("permission".to_string()));
156        }
157
158        let permission = args
159            .get("permission")
160            .and_then(|v| v.as_str())
161            .ok_or(DirectiveError::InvalidDirectiveArgument)?;
162
163        // Validate permission format (basic check)
164        if permission.is_empty() {
165            return Err(DirectiveError::InvalidDirectiveArgument);
166        }
167
168        // Validate maskValue if present
169        if let Some(mask) = args.get("maskValue") {
170            if !mask.is_string() && !mask.is_number() && !mask.is_null() {
171                return Err(DirectiveError::InvalidDirectiveArgument);
172            }
173        }
174
175        Ok(())
176    }
177}