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}