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