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