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