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 { name, selection_fields } => {
237                self.execute_mutation_query_with_security(
238                    &name,
239                    variables,
240                    Some(security_context),
241                    &selection_fields,
242                )
243                .await
244            },
245            QueryType::NodeQuery => self.execute_node_query(query, variables).await,
246        }
247    }
248
249    /// Check if a specific field can be accessed with given scopes.
250    ///
251    /// This is a convenience method for checking field access without executing a query.
252    ///
253    /// # Arguments
254    ///
255    /// * `type_name` - The GraphQL type name
256    /// * `field_name` - The field name
257    /// * `user_scopes` - User's scopes from JWT token
258    ///
259    /// # Returns
260    ///
261    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
262    ///
263    /// # Errors
264    ///
265    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
266    /// required scope for the field.
267    pub fn check_field_access(
268        &self,
269        type_name: &str,
270        field_name: &str,
271        user_scopes: &[String],
272    ) -> std::result::Result<(), FieldAccessError> {
273        if let Some(ref filter) = self.config.field_filter {
274            filter.can_access(type_name, field_name, user_scopes)
275        } else {
276            // No filter configured, allow all access
277            Ok(())
278        }
279    }
280
281    /// Apply field-level RBAC filtering to projection fields.
282    ///
283    /// Classifies each requested field against the user's security context:
284    /// - **Allowed**: user has the required scope (or field is public)
285    /// - **Masked**: user lacks scope, but `on_deny = Mask` → field value will be nulled
286    /// - **Rejected**: user lacks scope, `on_deny = Reject` → query fails with FORBIDDEN
287    ///
288    /// # Errors
289    ///
290    /// Returns `FraiseQLError::Forbidden` if any requested field has `on_deny = Reject`
291    /// and the user lacks the required scope.
292    pub(super) fn apply_field_rbac_filtering(
293        &self,
294        return_type: &str,
295        projection_fields: Vec<String>,
296        security_context: &SecurityContext,
297    ) -> Result<super::super::field_filter::FieldAccessResult> {
298        use super::super::field_filter::FieldAccessResult;
299
300        // Try to extract security config from compiled schema
301        if let Some(security_config) = self.schema.security.as_ref() {
302            if let Some(type_def) = self.schema.types.iter().find(|t| t.name == return_type) {
303                return classify_field_access(
304                    security_context,
305                    security_config,
306                    &type_def.fields,
307                    projection_fields,
308                )
309                .map_err(|rejected_field| FraiseQLError::Authorization {
310                    message:  format!(
311                        "Access denied: field '{rejected_field}' on type '{return_type}' \
312                         requires a scope you do not have"
313                    ),
314                    action:   Some("read".to_string()),
315                    resource: Some(format!("{return_type}.{rejected_field}")),
316                });
317            }
318        }
319
320        // No security config or type not found → all fields allowed, none masked
321        Ok(FieldAccessResult {
322            allowed: projection_fields,
323            masked:  Vec::new(),
324        })
325    }
326
327    /// Execute a query and return parsed JSON.
328    ///
329    /// Same as `execute()` but returns parsed `serde_json::Value` instead of string.
330    ///
331    /// # Errors
332    ///
333    /// Propagates all errors from [`Self::execute`] and additionally returns
334    /// [`FraiseQLError::Database`] if the response string is not valid JSON.
335    pub async fn execute_json(
336        &self,
337        query: &str,
338        variables: Option<&serde_json::Value>,
339    ) -> Result<serde_json::Value> {
340        let result_str = self.execute(query, variables).await?;
341        Ok(serde_json::from_str(&result_str)?)
342    }
343}