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