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, runners, support};
7use crate::{
8    db::traits::DatabaseAdapter,
9    error::{FraiseQLError, Result},
10    runtime::ExecutionContext,
11    schema::SessionVariablesConfig,
12    security::{FieldAccessError, SecurityContext},
13};
14
15/// Resolve session variable mappings against the current security context.
16///
17/// See [`support::security::resolve_session_variables`] for full documentation.
18#[must_use]
19pub fn resolve_session_variables(
20    config: &SessionVariablesConfig,
21    security_context: &SecurityContext,
22) -> Vec<(String, String)> {
23    support::security::resolve_session_variables(config, security_context)
24}
25
26impl<A: DatabaseAdapter> Executor<A> {
27    /// Validate that user has access to all requested fields.
28    pub(super) fn validate_field_access(
29        &self,
30        query: &str,
31        variables: Option<&serde_json::Value>,
32        user_scopes: &[String],
33        filter: &crate::security::FieldFilter,
34    ) -> Result<()> {
35        // Parse query to get field selections
36        let query_match = self.ctx.matcher.match_query(query, variables)?;
37
38        // Get the return type name from the query definition
39        let type_name = &query_match.query_def.return_type;
40
41        // Validate each requested field
42        let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
43        let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
44
45        if errors.is_empty() {
46            Ok(())
47        } else {
48            // Return the first error (could aggregate all errors if desired)
49            let first_error = &errors[0];
50            Err(FraiseQLError::Authorization {
51                message:  first_error.message.clone(),
52                action:   Some("read".to_string()),
53                resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
54            })
55        }
56    }
57
58    /// Execute a GraphQL query with cancellation support via `ExecutionContext`.
59    ///
60    /// This method allows graceful cancellation of long-running queries through a
61    /// cancellation token. If the token is cancelled during execution, the query
62    /// returns a `FraiseQLError::Cancelled` error.
63    ///
64    /// # Arguments
65    ///
66    /// * `query` - GraphQL query string
67    /// * `variables` - Query variables (optional)
68    /// * `ctx` - `ExecutionContext` with cancellation token
69    ///
70    /// # Returns
71    ///
72    /// GraphQL response as JSON string, or error if cancelled or execution fails
73    ///
74    /// # Errors
75    ///
76    /// * [`FraiseQLError::Cancelled`] — the cancellation token was triggered before or during
77    ///   execution.
78    /// * Propagates any error from the underlying [`execute`](Self::execute) call.
79    ///
80    /// # Example
81    ///
82    /// ```no_run
83    /// // Requires: a live database adapter and running tokio runtime.
84    /// // See: tests/integration/ for runnable examples.
85    /// use fraiseql_core::runtime::ExecutionContext;
86    /// use fraiseql_core::error::FraiseQLError;
87    /// use std::time::Duration;
88    ///
89    /// let ctx = ExecutionContext::new("user-query-123".to_string());
90    /// let cancel_token = ctx.cancellation_token().clone();
91    ///
92    /// // Spawn a task to cancel after 5 seconds
93    /// tokio::spawn(async move {
94    ///     tokio::time::sleep(Duration::from_secs(5)).await;
95    ///     cancel_token.cancel();
96    /// });
97    ///
98    /// // let result = executor.execute_with_context(query, None, &ctx).await;
99    /// ```
100    pub async fn execute_with_context(
101        &self,
102        query: &str,
103        variables: Option<&serde_json::Value>,
104        ctx: &ExecutionContext,
105    ) -> Result<serde_json::Value> {
106        // Check if already cancelled before starting
107        if ctx.is_cancelled() {
108            return Err(FraiseQLError::cancelled(
109                ctx.query_id().to_string(),
110                "Query cancelled before execution".to_string(),
111            ));
112        }
113
114        let token = ctx.cancellation_token().clone();
115
116        // Use tokio::select! to race between execution and cancellation
117        tokio::select! {
118            result = self.execute(query, variables) => {
119                result
120            }
121            () = token.cancelled() => {
122                Err(FraiseQLError::cancelled(
123                    ctx.query_id().to_string(),
124                    "Query cancelled during execution".to_string(),
125                ))
126            }
127        }
128    }
129
130    /// Execute a GraphQL query or mutation with a JWT [`SecurityContext`].
131    ///
132    /// This is the **main authenticated entry point** for the executor. It routes the
133    /// incoming request to the appropriate handler based on the query type:
134    ///
135    /// - **Regular queries**: RLS `WHERE` clauses are applied so each user only sees their own
136    ///   rows, as determined by the RLS policy in `RuntimeConfig`.
137    /// - **Mutations**: The security context is forwarded to `execute_mutation_query_with_security`
138    ///   so server-side `inject` parameters (e.g. `jwt:sub`) are resolved from the caller's JWT
139    ///   claims.
140    /// - **Aggregations, window queries, federation, introspection**: Delegated to their respective
141    ///   handlers (security context is not yet applied to these).
142    ///
143    /// If `query_timeout_ms` is non-zero in the `RuntimeConfig`, the entire
144    /// execution is raced against a Tokio deadline and returns
145    /// [`FraiseQLError::Timeout`] when the deadline is exceeded.
146    ///
147    /// # Arguments
148    ///
149    /// * `query` - GraphQL query string (e.g. `"query { posts { id title } }"`)
150    /// * `variables` - Optional JSON object of GraphQL variable values
151    /// * `security_context` - Authenticated user context extracted from a validated JWT
152    ///
153    /// # Returns
154    ///
155    /// A JSON-encoded GraphQL response string on success, conforming to the
156    /// [GraphQL over HTTP](https://graphql.github.io/graphql-over-http/) specification.
157    ///
158    /// # Errors
159    ///
160    /// * [`FraiseQLError::Parse`] — the query string is not valid GraphQL
161    /// * [`FraiseQLError::Validation`] — unknown mutation name, missing `sql_source`, or a mutation
162    ///   requires `inject` params but the security context is absent
163    /// * [`FraiseQLError::Database`] — the underlying adapter returns an error
164    /// * [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`
165    ///
166    /// # Example
167    ///
168    /// ```no_run
169    /// // Requires: a live database adapter and a SecurityContext from authentication.
170    /// // See: tests/integration/ for runnable examples.
171    /// use fraiseql_core::security::SecurityContext;
172    ///
173    /// // let query = r#"query { posts { id title } }"#;
174    /// // Returns a JSON string: {"data":{"posts":[...]}}
175    /// // let result = executor.execute_with_security(query, None, &context).await?;
176    /// ```
177    pub async fn execute_with_security(
178        &self,
179        query: &str,
180        variables: Option<&serde_json::Value>,
181        security_context: &SecurityContext,
182    ) -> Result<serde_json::Value> {
183        // Apply query timeout if configured
184        if self.ctx.config.query_timeout_ms > 0 {
185            let timeout_duration = Duration::from_millis(self.ctx.config.query_timeout_ms);
186            tokio::time::timeout(
187                timeout_duration,
188                self.execute_with_security_internal(query, variables, security_context),
189            )
190            .await
191            .map_err(|_| {
192                let query_snippet = if query.len() > 100 {
193                    format!("{}...", &query[..100])
194                } else {
195                    query.to_string()
196                };
197                FraiseQLError::Timeout {
198                    timeout_ms: self.ctx.config.query_timeout_ms,
199                    query:      Some(query_snippet),
200                }
201            })?
202        } else {
203            self.execute_with_security_internal(query, variables, security_context).await
204        }
205    }
206
207    /// Internal execution logic with security context (called by `execute_with_security` with
208    /// timeout wrapper).
209    async fn execute_with_security_internal(
210        &self,
211        query: &str,
212        variables: Option<&serde_json::Value>,
213        security_context: &SecurityContext,
214    ) -> Result<serde_json::Value> {
215        // 1. Classify query type
216        let query_type = self.classify_query(query)?;
217
218        // 2. Route to appropriate handler (with RLS support for regular queries)
219        match query_type {
220            QueryType::Regular => {
221                self.query_runner()
222                    .execute_regular_query_with_security(query, variables, security_context)
223                    .await
224            },
225            QueryType::Aggregate(query_name) => {
226                self.aggregate_runner()
227                    .execute_aggregate_dispatch(&query_name, variables, Some(security_context))
228                    .await
229            },
230            QueryType::Window(query_name) => {
231                self.aggregate_runner()
232                    .execute_window_dispatch(&query_name, variables, Some(security_context))
233                    .await
234            },
235            #[cfg(feature = "federation")]
236            QueryType::Federation(query_name) => {
237                self.execute_federation_query(&query_name, query, variables).await
238            },
239            #[cfg(not(feature = "federation"))]
240            QueryType::Federation(_) => {
241                let _ = (query, variables);
242                Err(FraiseQLError::Validation {
243                    message: "Federation is not enabled in this build".to_string(),
244                    path:    None,
245                })
246            },
247            QueryType::IntrospectionSchema => {
248                Ok(self.ctx.introspection.schema_response.as_ref().clone())
249            },
250            QueryType::IntrospectionType(type_name) => {
251                Ok(self.ctx.introspection.get_type_response(&type_name))
252            },
253            QueryType::Mutation { name, selections } => {
254                runners::mutation::execute_mutation_impl(
255                    &self.ctx,
256                    &name,
257                    variables,
258                    Some(security_context),
259                    &selections,
260                )
261                .await
262            },
263            QueryType::NodeQuery { selections } => {
264                self.query_runner().execute_node_query(query, variables, &selections).await
265            },
266        }
267    }
268
269    /// Check if a specific field can be accessed with given scopes.
270    ///
271    /// This is a convenience method for checking field access without executing a query.
272    ///
273    /// # Arguments
274    ///
275    /// * `type_name` - The GraphQL type name
276    /// * `field_name` - The field name
277    /// * `user_scopes` - User's scopes from JWT token
278    ///
279    /// # Returns
280    ///
281    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
282    ///
283    /// # Errors
284    ///
285    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
286    /// required scope for the field.
287    pub fn check_field_access(
288        &self,
289        type_name: &str,
290        field_name: &str,
291        user_scopes: &[String],
292    ) -> std::result::Result<(), FieldAccessError> {
293        if let Some(ref filter) = self.ctx.config.field_filter {
294            filter.can_access(type_name, field_name, user_scopes)
295        } else {
296            // No filter configured, allow all access
297            Ok(())
298        }
299    }
300
301    /// Execute a query and return parsed JSON.
302    ///
303    /// This method is now equivalent to `execute()` since `execute()` already
304    /// returns `serde_json::Value`.
305    ///
306    /// # Errors
307    ///
308    /// Returns any error from `execute()`.
309    #[deprecated(
310        since = "2.2.0",
311        note = "use execute() directly — it now returns Value"
312    )]
313    pub async fn execute_json(
314        &self,
315        query: &str,
316        variables: Option<&serde_json::Value>,
317    ) -> Result<serde_json::Value> {
318        self.execute(query, variables).await
319    }
320}
321
322#[cfg(test)]
323mod session_variable_tests {
324    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
325
326    use chrono::Utc;
327
328    use super::resolve_session_variables;
329    use crate::{
330        schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
331        security::SecurityContext,
332    };
333
334    fn make_context() -> SecurityContext {
335        let mut attributes = std::collections::HashMap::new();
336        attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
337        attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
338        attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
339        SecurityContext {
340            user_id: crate::types::UserId::new("user-42"),
341            roles: vec!["admin".to_string()],
342            tenant_id: Some(crate::types::TenantId::new("tenant-123")),
343            scopes: vec![],
344            attributes,
345            request_id: "req-test".to_string(),
346            ip_address: None,
347            authenticated_at: Utc::now(),
348            expires_at: Utc::now(),
349            issuer: None,
350            audience: None,
351            email: None,
352            display_name: None,
353        }
354    }
355
356    #[test]
357    fn resolve_session_variables_jwt_claim() {
358        let ctx = make_context();
359        let config = SessionVariablesConfig {
360            variables:         vec![SessionVariableMapping {
361                name:   "app.tenant_id".to_string(),
362                source: SessionVariableSource::Jwt {
363                    claim: "tenant_id".to_string(),
364                },
365            }],
366            inject_started_at: false,
367        };
368        let vars = resolve_session_variables(&config, &ctx);
369        // tenant_id is in attributes
370        assert_eq!(vars.len(), 1);
371        assert_eq!(vars[0].0, "app.tenant_id");
372        assert_eq!(vars[0].1, "tenant-abc");
373    }
374
375    #[test]
376    fn resolve_session_variables_jwt_well_known_sub() {
377        let ctx = make_context();
378        let config = SessionVariablesConfig {
379            variables:         vec![SessionVariableMapping {
380                name:   "app.user_id".to_string(),
381                source: SessionVariableSource::Jwt {
382                    claim: "sub".to_string(),
383                },
384            }],
385            inject_started_at: false,
386        };
387        let vars = resolve_session_variables(&config, &ctx);
388        assert_eq!(vars.len(), 1);
389        assert_eq!(vars[0].0, "app.user_id");
390        assert_eq!(vars[0].1, "user-42");
391    }
392
393    #[test]
394    fn resolve_session_variables_literal() {
395        let ctx = make_context();
396        let config = SessionVariablesConfig {
397            variables:         vec![SessionVariableMapping {
398                name:   "app.locale".to_string(),
399                source: SessionVariableSource::Literal {
400                    value: "en".to_string(),
401                },
402            }],
403            inject_started_at: false,
404        };
405        let vars = resolve_session_variables(&config, &ctx);
406        assert_eq!(vars.len(), 1);
407        assert_eq!(vars[0].0, "app.locale");
408        assert_eq!(vars[0].1, "en");
409    }
410
411    #[test]
412    fn inject_started_at_prepended() {
413        let ctx = make_context();
414        let config = SessionVariablesConfig {
415            variables:         vec![SessionVariableMapping {
416                name:   "app.locale".to_string(),
417                source: SessionVariableSource::Literal {
418                    value: "en".to_string(),
419                },
420            }],
421            inject_started_at: true,
422        };
423        let vars = resolve_session_variables(&config, &ctx);
424        // started_at must come first
425        assert_eq!(vars.len(), 2);
426        assert_eq!(vars[0].0, "fraiseql.started_at");
427        // Verify it's an ISO 8601 / RFC 3339 string (contains 'T')
428        assert!(vars[0].1.contains('T'), "started_at should be ISO 8601");
429        assert_eq!(vars[1].0, "app.locale");
430    }
431
432    #[test]
433    fn inject_started_at_disabled() {
434        let ctx = make_context();
435        let config = SessionVariablesConfig {
436            variables:         vec![],
437            inject_started_at: false,
438        };
439        let vars = resolve_session_variables(&config, &ctx);
440        assert!(vars.is_empty());
441        assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
442    }
443
444    #[test]
445    fn resolve_session_variables_header() {
446        let ctx = make_context();
447        let config = SessionVariablesConfig {
448            variables:         vec![SessionVariableMapping {
449                name:   "app.tenant".to_string(),
450                source: SessionVariableSource::Header {
451                    header: "x-tenant-id".to_string(),
452                },
453            }],
454            inject_started_at: false,
455        };
456        let vars = resolve_session_variables(&config, &ctx);
457        assert_eq!(vars.len(), 1);
458        assert_eq!(vars[0].0, "app.tenant");
459        assert_eq!(vars[0].1, "header-tenant");
460    }
461
462    #[test]
463    fn resolve_session_variables_jwt_email() {
464        let mut ctx = make_context();
465        ctx.email = Some("user@corp.com".to_string());
466        let config = SessionVariablesConfig {
467            variables:         vec![SessionVariableMapping {
468                name:   "app.email".to_string(),
469                source: SessionVariableSource::Jwt {
470                    claim: "email".to_string(),
471                },
472            }],
473            inject_started_at: false,
474        };
475        let vars = resolve_session_variables(&config, &ctx);
476        assert_eq!(vars.len(), 1);
477        assert_eq!(vars[0].0, "app.email");
478        assert_eq!(vars[0].1, "user@corp.com");
479    }
480
481    #[test]
482    fn resolve_session_variables_jwt_display_name() {
483        let mut ctx = make_context();
484        ctx.display_name = Some("Jane Doe".to_string());
485        let config = SessionVariablesConfig {
486            variables:         vec![
487                SessionVariableMapping {
488                    name:   "app.name".to_string(),
489                    source: SessionVariableSource::Jwt {
490                        claim: "name".to_string(),
491                    },
492                },
493                SessionVariableMapping {
494                    name:   "app.display_name".to_string(),
495                    source: SessionVariableSource::Jwt {
496                        claim: "display_name".to_string(),
497                    },
498                },
499            ],
500            inject_started_at: false,
501        };
502        let vars = resolve_session_variables(&config, &ctx);
503        assert_eq!(vars.len(), 2);
504        assert_eq!(vars[0].1, "Jane Doe");
505        assert_eq!(vars[1].1, "Jane Doe");
506    }
507
508    #[test]
509    fn resolve_session_variables_missing_email_skipped() {
510        let ctx = make_context(); // email is None
511        let config = SessionVariablesConfig {
512            variables:         vec![SessionVariableMapping {
513                name:   "app.email".to_string(),
514                source: SessionVariableSource::Jwt {
515                    claim: "email".to_string(),
516                },
517            }],
518            inject_started_at: false,
519        };
520        let vars = resolve_session_variables(&config, &ctx);
521        assert!(vars.is_empty(), "missing email should be silently skipped");
522    }
523}