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