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