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}