1use 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#[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 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 let query_match = self.ctx.matcher.match_query(query, variables)?;
37
38 let type_name = &query_match.query_def.return_type;
40
41 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 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 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 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 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 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 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 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 let (query_type, parsed) = self.classify_query_with_parse(query)?;
218
219 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
225 let ops = support::authz::collect_authz_ops(&query_type, parsed.as_ref());
226 crate::security::authorizer::enforce_authz(
227 authorizer.as_ref(),
228 Some(security_context),
229 &ops,
230 variables,
231 )?;
232 }
233
234 match query_type {
236 QueryType::Regular => {
237 self.query_runner()
238 .execute_regular_query_with_security(query, variables, security_context)
239 .await
240 },
241 QueryType::Aggregate(query_name) => {
242 self.aggregate_runner()
243 .execute_aggregate_dispatch(&query_name, variables, Some(security_context))
244 .await
245 },
246 QueryType::Window(query_name) => {
247 self.aggregate_runner()
248 .execute_window_dispatch(&query_name, variables, Some(security_context))
249 .await
250 },
251 #[cfg(feature = "federation")]
252 QueryType::Federation(query_name) => {
253 self.execute_federation_query(&query_name, query, variables).await
254 },
255 #[cfg(not(feature = "federation"))]
256 QueryType::Federation(_) => {
257 let _ = (query, variables);
258 Err(FraiseQLError::Validation {
259 message: "Federation is not enabled in this build".to_string(),
260 path: None,
261 })
262 },
263 QueryType::IntrospectionSchema => {
264 Ok(self.ctx.introspection.schema_response.as_ref().clone())
265 },
266 QueryType::IntrospectionType(type_name) => {
267 Ok(self.ctx.introspection.get_type_response(&type_name))
268 },
269 QueryType::Mutation { name, selections } => {
270 runners::mutation::execute_mutation_impl(
271 &self.ctx,
272 &name,
273 variables,
274 Some(security_context),
275 &selections,
276 )
277 .await
278 },
279 QueryType::NodeQuery { selections } => {
280 self.query_runner().execute_node_query(query, variables, &selections).await
281 },
282 }
283 }
284
285 pub fn check_field_access(
304 &self,
305 type_name: &str,
306 field_name: &str,
307 user_scopes: &[String],
308 ) -> std::result::Result<(), FieldAccessError> {
309 if let Some(ref filter) = self.ctx.config.field_filter {
310 filter.can_access(type_name, field_name, user_scopes)
311 } else {
312 Ok(())
314 }
315 }
316
317 #[deprecated(
326 since = "2.2.0",
327 note = "use execute() directly — it now returns Value"
328 )]
329 pub async fn execute_json(
330 &self,
331 query: &str,
332 variables: Option<&serde_json::Value>,
333 ) -> Result<serde_json::Value> {
334 self.execute(query, variables).await
335 }
336}
337
338#[cfg(test)]
339mod session_variable_tests {
340 #![allow(clippy::unwrap_used)] use chrono::Utc;
343
344 use super::resolve_session_variables;
345 use crate::{
346 schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
347 security::SecurityContext,
348 };
349
350 fn make_context() -> SecurityContext {
351 let mut attributes = std::collections::HashMap::new();
352 attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
353 attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
354 attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
355 SecurityContext {
356 user_id: crate::types::UserId::new("user-42"),
357 roles: vec!["admin".to_string()],
358 tenant_id: Some(crate::types::TenantId::new("tenant-123")),
359 scopes: vec![],
360 attributes,
361 request_id: "req-test".to_string(),
362 ip_address: None,
363 authenticated_at: Utc::now(),
364 expires_at: Utc::now(),
365 issuer: None,
366 audience: None,
367 email: None,
368 display_name: None,
369 }
370 }
371
372 #[test]
373 fn resolve_session_variables_jwt_claim() {
374 let ctx = make_context();
375 let config = SessionVariablesConfig {
376 variables: vec![SessionVariableMapping {
377 name: "app.tenant_id".to_string(),
378 source: SessionVariableSource::Jwt {
379 claim: "tenant_id".to_string(),
380 },
381 }],
382 inject_started_at: false,
383 };
384 let vars = resolve_session_variables(&config, &ctx);
385 assert_eq!(vars.len(), 1);
387 assert_eq!(vars[0].0, "app.tenant_id");
388 assert_eq!(vars[0].1, "tenant-abc");
389 }
390
391 #[test]
392 fn resolve_session_variables_jwt_well_known_sub() {
393 let ctx = make_context();
394 let config = SessionVariablesConfig {
395 variables: vec![SessionVariableMapping {
396 name: "app.user_id".to_string(),
397 source: SessionVariableSource::Jwt {
398 claim: "sub".to_string(),
399 },
400 }],
401 inject_started_at: false,
402 };
403 let vars = resolve_session_variables(&config, &ctx);
404 assert_eq!(vars.len(), 1);
405 assert_eq!(vars[0].0, "app.user_id");
406 assert_eq!(vars[0].1, "user-42");
407 }
408
409 #[test]
410 fn resolve_session_variables_literal() {
411 let ctx = make_context();
412 let config = SessionVariablesConfig {
413 variables: vec![SessionVariableMapping {
414 name: "app.locale".to_string(),
415 source: SessionVariableSource::Literal {
416 value: "en".to_string(),
417 },
418 }],
419 inject_started_at: false,
420 };
421 let vars = resolve_session_variables(&config, &ctx);
422 assert_eq!(vars.len(), 1);
423 assert_eq!(vars[0].0, "app.locale");
424 assert_eq!(vars[0].1, "en");
425 }
426
427 #[test]
428 fn inject_started_at_prepended() {
429 let ctx = make_context();
430 let config = SessionVariablesConfig {
431 variables: vec![SessionVariableMapping {
432 name: "app.locale".to_string(),
433 source: SessionVariableSource::Literal {
434 value: "en".to_string(),
435 },
436 }],
437 inject_started_at: true,
438 };
439 let vars = resolve_session_variables(&config, &ctx);
440 assert_eq!(vars.len(), 2);
442 assert_eq!(vars[0].0, "fraiseql.started_at");
443 assert!(vars[0].1.contains('T'), "started_at should be ISO 8601");
445 assert_eq!(vars[1].0, "app.locale");
446 }
447
448 #[test]
449 fn inject_started_at_disabled() {
450 let ctx = make_context();
451 let config = SessionVariablesConfig {
452 variables: vec![],
453 inject_started_at: false,
454 };
455 let vars = resolve_session_variables(&config, &ctx);
456 assert!(vars.is_empty());
457 assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
458 }
459
460 #[test]
461 fn resolve_session_variables_header() {
462 let ctx = make_context();
463 let config = SessionVariablesConfig {
464 variables: vec![SessionVariableMapping {
465 name: "app.tenant".to_string(),
466 source: SessionVariableSource::Header {
467 header: "x-tenant-id".to_string(),
468 },
469 }],
470 inject_started_at: false,
471 };
472 let vars = resolve_session_variables(&config, &ctx);
473 assert_eq!(vars.len(), 1);
474 assert_eq!(vars[0].0, "app.tenant");
475 assert_eq!(vars[0].1, "header-tenant");
476 }
477
478 #[test]
479 fn resolve_session_variables_jwt_email() {
480 let mut ctx = make_context();
481 ctx.email = Some("user@corp.com".to_string());
482 let config = SessionVariablesConfig {
483 variables: vec![SessionVariableMapping {
484 name: "app.email".to_string(),
485 source: SessionVariableSource::Jwt {
486 claim: "email".to_string(),
487 },
488 }],
489 inject_started_at: false,
490 };
491 let vars = resolve_session_variables(&config, &ctx);
492 assert_eq!(vars.len(), 1);
493 assert_eq!(vars[0].0, "app.email");
494 assert_eq!(vars[0].1, "user@corp.com");
495 }
496
497 #[test]
498 fn resolve_session_variables_jwt_display_name() {
499 let mut ctx = make_context();
500 ctx.display_name = Some("Jane Doe".to_string());
501 let config = SessionVariablesConfig {
502 variables: vec![
503 SessionVariableMapping {
504 name: "app.name".to_string(),
505 source: SessionVariableSource::Jwt {
506 claim: "name".to_string(),
507 },
508 },
509 SessionVariableMapping {
510 name: "app.display_name".to_string(),
511 source: SessionVariableSource::Jwt {
512 claim: "display_name".to_string(),
513 },
514 },
515 ],
516 inject_started_at: false,
517 };
518 let vars = resolve_session_variables(&config, &ctx);
519 assert_eq!(vars.len(), 2);
520 assert_eq!(vars[0].1, "Jane Doe");
521 assert_eq!(vars[1].1, "Jane Doe");
522 }
523
524 #[test]
525 fn resolve_session_variables_missing_email_skipped() {
526 let ctx = make_context(); let config = SessionVariablesConfig {
528 variables: vec![SessionVariableMapping {
529 name: "app.email".to_string(),
530 source: SessionVariableSource::Jwt {
531 claim: "email".to_string(),
532 },
533 }],
534 inject_started_at: false,
535 };
536 let vars = resolve_session_variables(&config, &ctx);
537 assert!(vars.is_empty(), "missing email should be silently skipped");
538 }
539}