Skip to main content

fraiseql_core/runtime/executor/
security.rs

1//! Security-aware execution — field access, RBAC filtering, JWT inject resolution,
2//! `execute_with_context()`, `execute_with_security()`, `execute_json()`.
3
4use std::time::Duration;
5
6use super::{Executor, QueryType};
7use crate::{
8    db::traits::DatabaseAdapter,
9    error::{FraiseQLError, Result},
10    runtime::{ExecutionContext, classify_field_access},
11    security::{FieldAccessError, SecurityContext},
12};
13
14impl<A: DatabaseAdapter> Executor<A> {
15    /// Validate that user has access to all requested fields.
16    pub(super) fn validate_field_access(
17        &self,
18        query: &str,
19        variables: Option<&serde_json::Value>,
20        user_scopes: &[String],
21        filter: &crate::security::FieldFilter,
22    ) -> Result<()> {
23        // Parse query to get field selections
24        let query_match = self.matcher.match_query(query, variables)?;
25
26        // Get the return type name from the query definition
27        let type_name = &query_match.query_def.return_type;
28
29        // Validate each requested field
30        let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
31        let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
32
33        if errors.is_empty() {
34            Ok(())
35        } else {
36            // Return the first error (could aggregate all errors if desired)
37            let first_error = &errors[0];
38            Err(FraiseQLError::Authorization {
39                message:  first_error.message.clone(),
40                action:   Some("read".to_string()),
41                resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
42            })
43        }
44    }
45
46    /// Execute a GraphQL query with cancellation support via `ExecutionContext`.
47    ///
48    /// This method allows graceful cancellation of long-running queries through a
49    /// cancellation token. If the token is cancelled during execution, the query
50    /// returns a `FraiseQLError::Cancelled` error.
51    ///
52    /// # Arguments
53    ///
54    /// * `query` - GraphQL query string
55    /// * `variables` - Query variables (optional)
56    /// * `ctx` - `ExecutionContext` with cancellation token
57    ///
58    /// # Returns
59    ///
60    /// GraphQL response as JSON string, or error if cancelled or execution fails
61    ///
62    /// # Errors
63    ///
64    /// * [`FraiseQLError::Cancelled`] — the cancellation token was triggered before or during
65    ///   execution.
66    /// * Propagates any error from the underlying [`execute`](Self::execute) call.
67    ///
68    /// # Example
69    ///
70    /// ```no_run
71    /// // Requires: a live database adapter and running tokio runtime.
72    /// // See: tests/integration/ for runnable examples.
73    /// use fraiseql_core::runtime::ExecutionContext;
74    /// use fraiseql_core::error::FraiseQLError;
75    /// use std::time::Duration;
76    ///
77    /// let ctx = ExecutionContext::new("user-query-123".to_string());
78    /// let cancel_token = ctx.cancellation_token().clone();
79    ///
80    /// // Spawn a task to cancel after 5 seconds
81    /// tokio::spawn(async move {
82    ///     tokio::time::sleep(Duration::from_secs(5)).await;
83    ///     cancel_token.cancel();
84    /// });
85    ///
86    /// // let result = executor.execute_with_context(query, None, &ctx).await;
87    /// ```
88    pub async fn execute_with_context(
89        &self,
90        query: &str,
91        variables: Option<&serde_json::Value>,
92        ctx: &ExecutionContext,
93    ) -> Result<String> {
94        // Check if already cancelled before starting
95        if ctx.is_cancelled() {
96            return Err(FraiseQLError::cancelled(
97                ctx.query_id().to_string(),
98                "Query cancelled before execution".to_string(),
99            ));
100        }
101
102        let token = ctx.cancellation_token().clone();
103
104        // Use tokio::select! to race between execution and cancellation
105        tokio::select! {
106            result = self.execute(query, variables) => {
107                result
108            }
109            () = token.cancelled() => {
110                Err(FraiseQLError::cancelled(
111                    ctx.query_id().to_string(),
112                    "Query cancelled during execution".to_string(),
113                ))
114            }
115        }
116    }
117
118    /// Execute a GraphQL query or mutation with a JWT [`SecurityContext`].
119    ///
120    /// This is the **main authenticated entry point** for the executor. It routes the
121    /// incoming request to the appropriate handler based on the query type:
122    ///
123    /// - **Regular queries**: RLS `WHERE` clauses are applied so each user only sees their own
124    ///   rows, as determined by the RLS policy in `RuntimeConfig`.
125    /// - **Mutations**: The security context is forwarded to `execute_mutation_query_with_security`
126    ///   so server-side `inject` parameters (e.g. `jwt:sub`) are resolved from the caller's JWT
127    ///   claims.
128    /// - **Aggregations, window queries, federation, introspection**: Delegated to their respective
129    ///   handlers (security context is not yet applied to these).
130    ///
131    /// If `query_timeout_ms` is non-zero in the `RuntimeConfig`, the entire
132    /// execution is raced against a Tokio deadline and returns
133    /// [`FraiseQLError::Timeout`] when the deadline is exceeded.
134    ///
135    /// # Arguments
136    ///
137    /// * `query` - GraphQL query string (e.g. `"query { posts { id title } }"`)
138    /// * `variables` - Optional JSON object of GraphQL variable values
139    /// * `security_context` - Authenticated user context extracted from a validated JWT
140    ///
141    /// # Returns
142    ///
143    /// A JSON-encoded GraphQL response string on success, conforming to the
144    /// [GraphQL over HTTP](https://graphql.github.io/graphql-over-http/) specification.
145    ///
146    /// # Errors
147    ///
148    /// * [`FraiseQLError::Parse`] — the query string is not valid GraphQL
149    /// * [`FraiseQLError::Validation`] — unknown mutation name, missing `sql_source`, or a mutation
150    ///   requires `inject` params but the security context is absent
151    /// * [`FraiseQLError::Database`] — the underlying adapter returns an error
152    /// * [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`
153    ///
154    /// # Example
155    ///
156    /// ```no_run
157    /// // Requires: a live database adapter and a SecurityContext from authentication.
158    /// // See: tests/integration/ for runnable examples.
159    /// use fraiseql_core::security::SecurityContext;
160    ///
161    /// // let query = r#"query { posts { id title } }"#;
162    /// // Returns a JSON string: {"data":{"posts":[...]}}
163    /// // let result = executor.execute_with_security(query, None, &context).await?;
164    /// ```
165    pub async fn execute_with_security(
166        &self,
167        query: &str,
168        variables: Option<&serde_json::Value>,
169        security_context: &SecurityContext,
170    ) -> Result<String> {
171        // Apply query timeout if configured
172        if self.config.query_timeout_ms > 0 {
173            let timeout_duration = Duration::from_millis(self.config.query_timeout_ms);
174            tokio::time::timeout(
175                timeout_duration,
176                self.execute_with_security_internal(query, variables, security_context),
177            )
178            .await
179            .map_err(|_| {
180                let query_snippet = if query.len() > 100 {
181                    format!("{}...", &query[..100])
182                } else {
183                    query.to_string()
184                };
185                FraiseQLError::Timeout {
186                    timeout_ms: self.config.query_timeout_ms,
187                    query:      Some(query_snippet),
188                }
189            })?
190        } else {
191            self.execute_with_security_internal(query, variables, security_context).await
192        }
193    }
194
195    /// Internal execution logic with security context (called by `execute_with_security` with
196    /// timeout wrapper).
197    async fn execute_with_security_internal(
198        &self,
199        query: &str,
200        variables: Option<&serde_json::Value>,
201        security_context: &SecurityContext,
202    ) -> Result<String> {
203        // 1. Classify query type
204        let query_type = self.classify_query(query)?;
205
206        // 2. Route to appropriate handler (with RLS support for regular queries)
207        match query_type {
208            QueryType::Regular => {
209                self.execute_regular_query_with_security(query, variables, security_context)
210                    .await
211            },
212            // Other query types don't support RLS yet (relay is handled inside
213            // execute_regular_query_with_security)
214            QueryType::Aggregate(query_name) => {
215                self.execute_aggregate_dispatch(&query_name, variables).await
216            },
217            QueryType::Window(query_name) => {
218                self.execute_window_dispatch(&query_name, variables).await
219            },
220            #[cfg(feature = "federation")]
221            QueryType::Federation(query_name) => {
222                self.execute_federation_query(&query_name, query, variables).await
223            },
224            #[cfg(not(feature = "federation"))]
225            QueryType::Federation(_) => {
226                let _ = (query, variables);
227                Err(FraiseQLError::Validation {
228                    message: "Federation is not enabled in this build".to_string(),
229                    path:    None,
230                })
231            },
232            QueryType::IntrospectionSchema => Ok(self.introspection.schema_response.clone()),
233            QueryType::IntrospectionType(type_name) => {
234                Ok(self.introspection.get_type_response(&type_name))
235            },
236            QueryType::Mutation(mutation_name) => {
237                self.execute_mutation_query_with_security(
238                    &mutation_name,
239                    variables,
240                    Some(security_context),
241                )
242                .await
243            },
244            QueryType::NodeQuery => self.execute_node_query(query, variables).await,
245        }
246    }
247
248    /// Check if a specific field can be accessed with given scopes.
249    ///
250    /// This is a convenience method for checking field access without executing a query.
251    ///
252    /// # Arguments
253    ///
254    /// * `type_name` - The GraphQL type name
255    /// * `field_name` - The field name
256    /// * `user_scopes` - User's scopes from JWT token
257    ///
258    /// # Returns
259    ///
260    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
261    ///
262    /// # Errors
263    ///
264    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
265    /// required scope for the field.
266    pub fn check_field_access(
267        &self,
268        type_name: &str,
269        field_name: &str,
270        user_scopes: &[String],
271    ) -> std::result::Result<(), FieldAccessError> {
272        if let Some(ref filter) = self.config.field_filter {
273            filter.can_access(type_name, field_name, user_scopes)
274        } else {
275            // No filter configured, allow all access
276            Ok(())
277        }
278    }
279
280    /// Apply field-level RBAC filtering to projection fields.
281    ///
282    /// Classifies each requested field against the user's security context:
283    /// - **Allowed**: user has the required scope (or field is public)
284    /// - **Masked**: user lacks scope, but `on_deny = Mask` → field value will be nulled
285    /// - **Rejected**: user lacks scope, `on_deny = Reject` → query fails with FORBIDDEN
286    ///
287    /// # Errors
288    ///
289    /// Returns `FraiseQLError::Forbidden` if any requested field has `on_deny = Reject`
290    /// and the user lacks the required scope.
291    pub(super) fn apply_field_rbac_filtering(
292        &self,
293        return_type: &str,
294        projection_fields: Vec<String>,
295        security_context: &SecurityContext,
296    ) -> Result<super::super::field_filter::FieldAccessResult> {
297        use super::super::field_filter::FieldAccessResult;
298
299        // Try to extract security config from compiled schema
300        if let Some(security_config) = self.schema.security.as_ref() {
301            if let Some(type_def) = self.schema.types.iter().find(|t| t.name == return_type) {
302                return classify_field_access(
303                    security_context,
304                    security_config,
305                    &type_def.fields,
306                    projection_fields,
307                )
308                .map_err(|rejected_field| FraiseQLError::Authorization {
309                    message:  format!(
310                        "Access denied: field '{rejected_field}' on type '{return_type}' \
311                         requires a scope you do not have"
312                    ),
313                    action:   Some("read".to_string()),
314                    resource: Some(format!("{return_type}.{rejected_field}")),
315                });
316            }
317        }
318
319        // No security config or type not found → all fields allowed, none masked
320        Ok(FieldAccessResult {
321            allowed: projection_fields,
322            masked:  Vec::new(),
323        })
324    }
325
326    /// Execute a query and return parsed JSON.
327    ///
328    /// Same as `execute()` but returns parsed `serde_json::Value` instead of string.
329    ///
330    /// # Errors
331    ///
332    /// Propagates all errors from [`Self::execute`] and additionally returns
333    /// [`FraiseQLError::Database`] if the response string is not valid JSON.
334    pub async fn execute_json(
335        &self,
336        query: &str,
337        variables: Option<&serde_json::Value>,
338    ) -> Result<serde_json::Value> {
339        let result_str = self.execute(query, variables).await?;
340        Ok(serde_json::from_str(&result_str)?)
341    }
342}