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#[must_use]
17pub fn resolve_session_variables(
18    config: &SessionVariablesConfig,
19    security_context: &SecurityContext,
20) -> Vec<(String, String)> {
21    support::security::resolve_session_variables(config, security_context)
22}
23
24impl<A: DatabaseAdapter> Executor<A> {
25    /// Validate that user has access to all requested fields.
26    pub(super) fn validate_field_access(
27        &self,
28        query: &str,
29        variables: Option<&serde_json::Value>,
30        user_scopes: &[String],
31        filter: &crate::security::FieldFilter,
32    ) -> Result<()> {
33        // Parse query to get field selections
34        let query_match = self.ctx.matcher.match_query(query, variables)?;
35
36        // Get the return type name from the query definition
37        let type_name = &query_match.query_def.return_type;
38
39        // Validate each requested field
40        let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
41        let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
42
43        if errors.is_empty() {
44            Ok(())
45        } else {
46            // Return the first error (could aggregate all errors if desired)
47            let first_error = &errors[0];
48            Err(FraiseQLError::Authorization {
49                message:  first_error.message.clone(),
50                action:   Some("read".to_string()),
51                resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
52            })
53        }
54    }
55
56    /// Execute a GraphQL query with cancellation support via `ExecutionContext`.
57    ///
58    /// This method allows graceful cancellation of long-running queries through a
59    /// cancellation token. If the token is cancelled during execution, the query
60    /// returns a `FraiseQLError::Cancelled` error.
61    ///
62    /// # Arguments
63    ///
64    /// * `query` - GraphQL query string
65    /// * `variables` - Query variables (optional)
66    /// * `ctx` - `ExecutionContext` with cancellation token
67    ///
68    /// # Returns
69    ///
70    /// GraphQL response as JSON string, or error if cancelled or execution fails
71    ///
72    /// # Errors
73    ///
74    /// * [`FraiseQLError::Cancelled`] — the cancellation token was triggered before or during
75    ///   execution.
76    /// * Propagates any error from the underlying [`execute`](Self::execute) call.
77    ///
78    /// # Example
79    ///
80    /// ```no_run
81    /// // Requires: a live database adapter and running tokio runtime.
82    /// // See: tests/integration/ for runnable examples.
83    /// use fraiseql_core::runtime::ExecutionContext;
84    /// use fraiseql_core::error::FraiseQLError;
85    /// use std::time::Duration;
86    ///
87    /// let ctx = ExecutionContext::new("user-query-123".to_string());
88    /// let cancel_token = ctx.cancellation_token().clone();
89    ///
90    /// // Spawn a task to cancel after 5 seconds
91    /// tokio::spawn(async move {
92    ///     tokio::time::sleep(Duration::from_secs(5)).await;
93    ///     cancel_token.cancel();
94    /// });
95    ///
96    /// // let result = executor.execute_with_context(query, None, &ctx).await;
97    /// ```
98    pub async fn execute_with_context(
99        &self,
100        query: &str,
101        variables: Option<&serde_json::Value>,
102        ctx: &ExecutionContext,
103    ) -> Result<serde_json::Value> {
104        // Check if already cancelled before starting
105        if ctx.is_cancelled() {
106            return Err(FraiseQLError::cancelled(
107                ctx.query_id().to_string(),
108                "Query cancelled before execution".to_string(),
109            ));
110        }
111
112        let token = ctx.cancellation_token().clone();
113
114        // Use tokio::select! to race between execution and cancellation
115        tokio::select! {
116            result = self.execute(query, variables) => {
117                result
118            }
119            () = token.cancelled() => {
120                Err(FraiseQLError::cancelled(
121                    ctx.query_id().to_string(),
122                    "Query cancelled during execution".to_string(),
123                ))
124            }
125        }
126    }
127
128    /// Execute a GraphQL query or mutation with a JWT [`SecurityContext`].
129    ///
130    /// This is the **main authenticated entry point** for the executor. It routes the
131    /// incoming request to the appropriate handler based on the query type:
132    ///
133    /// - **Regular queries**: RLS `WHERE` clauses are applied so each user only sees their own
134    ///   rows, as determined by the RLS policy in `RuntimeConfig`.
135    /// - **Mutations**: the security context is forwarded so server-side `inject` parameters (e.g.
136    ///   `jwt:sub`) are resolved from the caller's JWT claims.
137    /// - **Multi-root queries** (e.g. `{ users { id } posts { id } }`): each root is dispatched in
138    ///   parallel with the security context applied to every root (H19).
139    /// - **Aggregations, window queries, federation, node lookups**: the security context **is**
140    ///   forwarded to each handler (RLS / `requires_role` / `inject` gates apply).
141    /// - **Introspection**: served from the pre-built response (no per-user data).
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        // Authenticated entry: delegate to the shared dispatch with the principal.
184        // GATE-1, the parse cache, the multi-root fan-out, and every per-operation
185        // runner are threaded with `Some(security_context)` in `execute_dispatch`,
186        // so this path cannot drift from the anonymous one (H19, L-gate1-skip,
187        // L-parse-cache). The timeout wrapper is shared via `execute_with_timeout`.
188        self.execute_with_timeout(query, variables, Some(security_context)).await
189    }
190
191    /// Check if a specific field can be accessed with given scopes.
192    ///
193    /// This is a convenience method for checking field access without executing a query.
194    ///
195    /// # Arguments
196    ///
197    /// * `type_name` - The GraphQL type name
198    /// * `field_name` - The field name
199    /// * `user_scopes` - User's scopes from JWT token
200    ///
201    /// # Returns
202    ///
203    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
204    ///
205    /// # Errors
206    ///
207    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
208    /// required scope for the field.
209    pub fn check_field_access(
210        &self,
211        type_name: &str,
212        field_name: &str,
213        user_scopes: &[String],
214    ) -> std::result::Result<(), FieldAccessError> {
215        if let Some(ref filter) = self.ctx.config.field_filter {
216            filter.can_access(type_name, field_name, user_scopes)
217        } else {
218            // No filter configured, allow all access
219            Ok(())
220        }
221    }
222
223    /// Execute a query and return parsed JSON.
224    ///
225    /// This method is now equivalent to `execute()` since `execute()` already
226    /// returns `serde_json::Value`.
227    ///
228    /// # Errors
229    ///
230    /// Returns any error from `execute()`.
231    #[deprecated(
232        since = "2.2.0",
233        note = "use execute() directly — it now returns Value"
234    )]
235    pub async fn execute_json(
236        &self,
237        query: &str,
238        variables: Option<&serde_json::Value>,
239    ) -> Result<serde_json::Value> {
240        self.execute(query, variables).await
241    }
242}
243
244#[cfg(test)]
245mod session_variable_tests {
246    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
247
248    use chrono::Utc;
249
250    use super::resolve_session_variables;
251    use crate::{
252        schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
253        security::SecurityContext,
254    };
255
256    fn make_context() -> SecurityContext {
257        let mut attributes = std::collections::HashMap::new();
258        attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
259        attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
260        attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
261        SecurityContext {
262            user_id: crate::types::UserId::new("user-42"),
263            roles: vec!["admin".to_string()],
264            tenant_id: Some(crate::types::TenantId::new("tenant-123")),
265            scopes: vec![],
266            attributes,
267            request_id: "req-test".to_string(),
268            ip_address: None,
269            authenticated_at: Utc::now(),
270            expires_at: Utc::now(),
271            issuer: None,
272            audience: None,
273            email: None,
274            display_name: None,
275        }
276    }
277
278    #[test]
279    fn resolve_session_variables_jwt_claim() {
280        let ctx = make_context();
281        let config = SessionVariablesConfig {
282            variables:         vec![SessionVariableMapping {
283                name:   "app.tenant_id".to_string(),
284                source: SessionVariableSource::Jwt {
285                    claim: "tenant_id".to_string(),
286                },
287            }],
288            inject_started_at: false,
289        };
290        let vars = resolve_session_variables(&config, &ctx);
291        // tenant_id is in attributes
292        assert_eq!(vars.len(), 1);
293        assert_eq!(vars[0].0, "app.tenant_id");
294        assert_eq!(vars[0].1, "tenant-abc");
295    }
296
297    #[test]
298    fn resolve_session_variables_jwt_well_known_sub() {
299        let ctx = make_context();
300        let config = SessionVariablesConfig {
301            variables:         vec![SessionVariableMapping {
302                name:   "app.user_id".to_string(),
303                source: SessionVariableSource::Jwt {
304                    claim: "sub".to_string(),
305                },
306            }],
307            inject_started_at: false,
308        };
309        let vars = resolve_session_variables(&config, &ctx);
310        assert_eq!(vars.len(), 1);
311        assert_eq!(vars[0].0, "app.user_id");
312        assert_eq!(vars[0].1, "user-42");
313    }
314
315    #[test]
316    fn resolve_session_variables_literal() {
317        let ctx = make_context();
318        let config = SessionVariablesConfig {
319            variables:         vec![SessionVariableMapping {
320                name:   "app.locale".to_string(),
321                source: SessionVariableSource::Literal {
322                    value: "en".to_string(),
323                },
324            }],
325            inject_started_at: false,
326        };
327        let vars = resolve_session_variables(&config, &ctx);
328        assert_eq!(vars.len(), 1);
329        assert_eq!(vars[0].0, "app.locale");
330        assert_eq!(vars[0].1, "en");
331    }
332
333    #[test]
334    fn inject_started_at_prepended() {
335        let ctx = make_context();
336        let config = SessionVariablesConfig {
337            variables:         vec![SessionVariableMapping {
338                name:   "app.locale".to_string(),
339                source: SessionVariableSource::Literal {
340                    value: "en".to_string(),
341                },
342            }],
343            inject_started_at: true,
344        };
345        let vars = resolve_session_variables(&config, &ctx);
346        // started_at must come first
347        assert_eq!(vars.len(), 2);
348        assert_eq!(vars[0].0, fraiseql_db::STARTED_AT_VAR);
349        // It carries the clock-timestamp directive (DB-clock-stamped at apply
350        // time), NOT an app-clock literal — see resolve_session_variables.
351        assert_eq!(vars[0].1, fraiseql_db::CLOCK_TIMESTAMP_DIRECTIVE);
352        assert_eq!(vars[1].0, "app.locale");
353    }
354
355    #[test]
356    fn inject_started_at_disabled() {
357        let ctx = make_context();
358        let config = SessionVariablesConfig {
359            variables:         vec![],
360            inject_started_at: false,
361        };
362        let vars = resolve_session_variables(&config, &ctx);
363        assert!(vars.is_empty());
364        assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
365    }
366
367    #[test]
368    fn resolve_session_variables_header() {
369        let ctx = make_context();
370        let config = SessionVariablesConfig {
371            variables:         vec![SessionVariableMapping {
372                name:   "app.tenant".to_string(),
373                source: SessionVariableSource::Header {
374                    header: "x-tenant-id".to_string(),
375                },
376            }],
377            inject_started_at: false,
378        };
379        let vars = resolve_session_variables(&config, &ctx);
380        assert_eq!(vars.len(), 1);
381        assert_eq!(vars[0].0, "app.tenant");
382        assert_eq!(vars[0].1, "header-tenant");
383    }
384
385    #[test]
386    fn resolve_session_variables_jwt_email() {
387        let mut ctx = make_context();
388        ctx.email = Some("user@corp.com".to_string());
389        let config = SessionVariablesConfig {
390            variables:         vec![SessionVariableMapping {
391                name:   "app.email".to_string(),
392                source: SessionVariableSource::Jwt {
393                    claim: "email".to_string(),
394                },
395            }],
396            inject_started_at: false,
397        };
398        let vars = resolve_session_variables(&config, &ctx);
399        assert_eq!(vars.len(), 1);
400        assert_eq!(vars[0].0, "app.email");
401        assert_eq!(vars[0].1, "user@corp.com");
402    }
403
404    #[test]
405    fn resolve_session_variables_jwt_display_name() {
406        let mut ctx = make_context();
407        ctx.display_name = Some("Jane Doe".to_string());
408        let config = SessionVariablesConfig {
409            variables:         vec![
410                SessionVariableMapping {
411                    name:   "app.name".to_string(),
412                    source: SessionVariableSource::Jwt {
413                        claim: "name".to_string(),
414                    },
415                },
416                SessionVariableMapping {
417                    name:   "app.display_name".to_string(),
418                    source: SessionVariableSource::Jwt {
419                        claim: "display_name".to_string(),
420                    },
421                },
422            ],
423            inject_started_at: false,
424        };
425        let vars = resolve_session_variables(&config, &ctx);
426        assert_eq!(vars.len(), 2);
427        assert_eq!(vars[0].1, "Jane Doe");
428        assert_eq!(vars[1].1, "Jane Doe");
429    }
430
431    #[test]
432    fn resolve_session_variables_missing_email_skipped() {
433        let ctx = make_context(); // email is None
434        let config = SessionVariablesConfig {
435            variables:         vec![SessionVariableMapping {
436                name:   "app.email".to_string(),
437                source: SessionVariableSource::Jwt {
438                    claim: "email".to_string(),
439                },
440            }],
441            inject_started_at: false,
442        };
443        let vars = resolve_session_variables(&config, &ctx);
444        assert!(vars.is_empty(), "missing email should be silently skipped");
445    }
446}