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. Retain the parsed AST for `Regular` queries — the authorizer
216        //    needs the per-root operation names to gate multi-root requests.
217        let (query_type, parsed) = self.classify_query_with_parse(query)?;
218
219        // 1b. Operation-level authorization (#422): consult the configured `Authorizer`
220        //     before dispatch. Mutations are gated downstream at `execute_mutation_impl`
221        //     (the single point every mutation entry path converges), so
222        //     `collect_authz_ops` returns no ops for the `Mutation` variant here.
223        //     Fail-closed: a `Deny` or any policy error returns 403.
224        if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
225            let ops = support::authz::collect_authz_ops(&query_type, parsed.as_ref());
226            crate::security::authorizer::enforce_authz(
227                authorizer.as_ref(),
228                Some(security_context),
229                &ops,
230                variables,
231            )?;
232        }
233
234        // 2. Route to appropriate handler (with RLS support for regular queries)
235        match query_type {
236            QueryType::Regular => {
237                self.query_runner()
238                    .execute_regular_query_with_security(query, variables, security_context)
239                    .await
240            },
241            QueryType::Aggregate(query_name) => {
242                self.aggregate_runner()
243                    .execute_aggregate_dispatch(&query_name, variables, Some(security_context))
244                    .await
245            },
246            QueryType::Window(query_name) => {
247                self.aggregate_runner()
248                    .execute_window_dispatch(&query_name, variables, Some(security_context))
249                    .await
250            },
251            #[cfg(feature = "federation")]
252            QueryType::Federation(query_name) => {
253                self.execute_federation_query(&query_name, query, variables).await
254            },
255            #[cfg(not(feature = "federation"))]
256            QueryType::Federation(_) => {
257                let _ = (query, variables);
258                Err(FraiseQLError::Validation {
259                    message: "Federation is not enabled in this build".to_string(),
260                    path:    None,
261                })
262            },
263            QueryType::IntrospectionSchema => {
264                Ok(self.ctx.introspection.schema_response.as_ref().clone())
265            },
266            QueryType::IntrospectionType(type_name) => {
267                Ok(self.ctx.introspection.get_type_response(&type_name))
268            },
269            QueryType::Mutation { name, selections } => {
270                runners::mutation::execute_mutation_impl(
271                    &self.ctx,
272                    &name,
273                    variables,
274                    Some(security_context),
275                    &selections,
276                )
277                .await
278            },
279            QueryType::NodeQuery { selections } => {
280                self.query_runner().execute_node_query(query, variables, &selections).await
281            },
282        }
283    }
284
285    /// Check if a specific field can be accessed with given scopes.
286    ///
287    /// This is a convenience method for checking field access without executing a query.
288    ///
289    /// # Arguments
290    ///
291    /// * `type_name` - The GraphQL type name
292    /// * `field_name` - The field name
293    /// * `user_scopes` - User's scopes from JWT token
294    ///
295    /// # Returns
296    ///
297    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
298    ///
299    /// # Errors
300    ///
301    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
302    /// required scope for the field.
303    pub fn check_field_access(
304        &self,
305        type_name: &str,
306        field_name: &str,
307        user_scopes: &[String],
308    ) -> std::result::Result<(), FieldAccessError> {
309        if let Some(ref filter) = self.ctx.config.field_filter {
310            filter.can_access(type_name, field_name, user_scopes)
311        } else {
312            // No filter configured, allow all access
313            Ok(())
314        }
315    }
316
317    /// Execute a query and return parsed JSON.
318    ///
319    /// This method is now equivalent to `execute()` since `execute()` already
320    /// returns `serde_json::Value`.
321    ///
322    /// # Errors
323    ///
324    /// Returns any error from `execute()`.
325    #[deprecated(
326        since = "2.2.0",
327        note = "use execute() directly — it now returns Value"
328    )]
329    pub async fn execute_json(
330        &self,
331        query: &str,
332        variables: Option<&serde_json::Value>,
333    ) -> Result<serde_json::Value> {
334        self.execute(query, variables).await
335    }
336}
337
338#[cfg(test)]
339mod session_variable_tests {
340    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
341
342    use chrono::Utc;
343
344    use super::resolve_session_variables;
345    use crate::{
346        schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
347        security::SecurityContext,
348    };
349
350    fn make_context() -> SecurityContext {
351        let mut attributes = std::collections::HashMap::new();
352        attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
353        attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
354        attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
355        SecurityContext {
356            user_id: crate::types::UserId::new("user-42"),
357            roles: vec!["admin".to_string()],
358            tenant_id: Some(crate::types::TenantId::new("tenant-123")),
359            scopes: vec![],
360            attributes,
361            request_id: "req-test".to_string(),
362            ip_address: None,
363            authenticated_at: Utc::now(),
364            expires_at: Utc::now(),
365            issuer: None,
366            audience: None,
367            email: None,
368            display_name: None,
369        }
370    }
371
372    #[test]
373    fn resolve_session_variables_jwt_claim() {
374        let ctx = make_context();
375        let config = SessionVariablesConfig {
376            variables:         vec![SessionVariableMapping {
377                name:   "app.tenant_id".to_string(),
378                source: SessionVariableSource::Jwt {
379                    claim: "tenant_id".to_string(),
380                },
381            }],
382            inject_started_at: false,
383        };
384        let vars = resolve_session_variables(&config, &ctx);
385        // tenant_id is in attributes
386        assert_eq!(vars.len(), 1);
387        assert_eq!(vars[0].0, "app.tenant_id");
388        assert_eq!(vars[0].1, "tenant-abc");
389    }
390
391    #[test]
392    fn resolve_session_variables_jwt_well_known_sub() {
393        let ctx = make_context();
394        let config = SessionVariablesConfig {
395            variables:         vec![SessionVariableMapping {
396                name:   "app.user_id".to_string(),
397                source: SessionVariableSource::Jwt {
398                    claim: "sub".to_string(),
399                },
400            }],
401            inject_started_at: false,
402        };
403        let vars = resolve_session_variables(&config, &ctx);
404        assert_eq!(vars.len(), 1);
405        assert_eq!(vars[0].0, "app.user_id");
406        assert_eq!(vars[0].1, "user-42");
407    }
408
409    #[test]
410    fn resolve_session_variables_literal() {
411        let ctx = make_context();
412        let config = SessionVariablesConfig {
413            variables:         vec![SessionVariableMapping {
414                name:   "app.locale".to_string(),
415                source: SessionVariableSource::Literal {
416                    value: "en".to_string(),
417                },
418            }],
419            inject_started_at: false,
420        };
421        let vars = resolve_session_variables(&config, &ctx);
422        assert_eq!(vars.len(), 1);
423        assert_eq!(vars[0].0, "app.locale");
424        assert_eq!(vars[0].1, "en");
425    }
426
427    #[test]
428    fn inject_started_at_prepended() {
429        let ctx = make_context();
430        let config = SessionVariablesConfig {
431            variables:         vec![SessionVariableMapping {
432                name:   "app.locale".to_string(),
433                source: SessionVariableSource::Literal {
434                    value: "en".to_string(),
435                },
436            }],
437            inject_started_at: true,
438        };
439        let vars = resolve_session_variables(&config, &ctx);
440        // started_at must come first
441        assert_eq!(vars.len(), 2);
442        assert_eq!(vars[0].0, "fraiseql.started_at");
443        // Verify it's an ISO 8601 / RFC 3339 string (contains 'T')
444        assert!(vars[0].1.contains('T'), "started_at should be ISO 8601");
445        assert_eq!(vars[1].0, "app.locale");
446    }
447
448    #[test]
449    fn inject_started_at_disabled() {
450        let ctx = make_context();
451        let config = SessionVariablesConfig {
452            variables:         vec![],
453            inject_started_at: false,
454        };
455        let vars = resolve_session_variables(&config, &ctx);
456        assert!(vars.is_empty());
457        assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
458    }
459
460    #[test]
461    fn resolve_session_variables_header() {
462        let ctx = make_context();
463        let config = SessionVariablesConfig {
464            variables:         vec![SessionVariableMapping {
465                name:   "app.tenant".to_string(),
466                source: SessionVariableSource::Header {
467                    header: "x-tenant-id".to_string(),
468                },
469            }],
470            inject_started_at: false,
471        };
472        let vars = resolve_session_variables(&config, &ctx);
473        assert_eq!(vars.len(), 1);
474        assert_eq!(vars[0].0, "app.tenant");
475        assert_eq!(vars[0].1, "header-tenant");
476    }
477
478    #[test]
479    fn resolve_session_variables_jwt_email() {
480        let mut ctx = make_context();
481        ctx.email = Some("user@corp.com".to_string());
482        let config = SessionVariablesConfig {
483            variables:         vec![SessionVariableMapping {
484                name:   "app.email".to_string(),
485                source: SessionVariableSource::Jwt {
486                    claim: "email".to_string(),
487                },
488            }],
489            inject_started_at: false,
490        };
491        let vars = resolve_session_variables(&config, &ctx);
492        assert_eq!(vars.len(), 1);
493        assert_eq!(vars[0].0, "app.email");
494        assert_eq!(vars[0].1, "user@corp.com");
495    }
496
497    #[test]
498    fn resolve_session_variables_jwt_display_name() {
499        let mut ctx = make_context();
500        ctx.display_name = Some("Jane Doe".to_string());
501        let config = SessionVariablesConfig {
502            variables:         vec![
503                SessionVariableMapping {
504                    name:   "app.name".to_string(),
505                    source: SessionVariableSource::Jwt {
506                        claim: "name".to_string(),
507                    },
508                },
509                SessionVariableMapping {
510                    name:   "app.display_name".to_string(),
511                    source: SessionVariableSource::Jwt {
512                        claim: "display_name".to_string(),
513                    },
514                },
515            ],
516            inject_started_at: false,
517        };
518        let vars = resolve_session_variables(&config, &ctx);
519        assert_eq!(vars.len(), 2);
520        assert_eq!(vars[0].1, "Jane Doe");
521        assert_eq!(vars[1].1, "Jane Doe");
522    }
523
524    #[test]
525    fn resolve_session_variables_missing_email_skipped() {
526        let ctx = make_context(); // email is None
527        let config = SessionVariablesConfig {
528            variables:         vec![SessionVariableMapping {
529                name:   "app.email".to_string(),
530                source: SessionVariableSource::Jwt {
531                    claim: "email".to_string(),
532                },
533            }],
534            inject_started_at: false,
535        };
536        let vars = resolve_session_variables(&config, &ctx);
537        assert!(vars.is_empty(), "missing email should be silently skipped");
538    }
539}