1use super::{Executor, support};
5use crate::{
6 error::{FraiseQLError, Result},
7 runtime::ExecutionContext,
8 security::{FieldAccessError, SecurityContext},
9};
10
11pub fn resolve_session_variables(
38 schema: &crate::schema::CompiledSchema,
39 security_context: &SecurityContext,
40) -> crate::error::Result<Vec<(String, String)>> {
41 support::security::resolve_session_variables(
44 &schema.session_variables,
45 security_context,
46 schema.tenant_claim(),
47 support::security::SessionPurpose::Write,
48 )
49}
50
51impl Executor {
52 pub(super) fn validate_field_access(
54 &self,
55 query: &str,
56 variables: Option<&serde_json::Value>,
57 user_scopes: &[String],
58 filter: &crate::security::FieldFilter,
59 ) -> Result<()> {
60 let query_match = self.ctx.matcher.match_query(query, variables)?;
62
63 let type_name = &query_match.query_def.return_type;
65
66 let field_refs: Vec<&str> = query_match.fields.iter().map(String::as_str).collect();
68 let errors = filter.validate_fields(type_name, &field_refs, user_scopes);
69
70 if errors.is_empty() {
71 Ok(())
72 } else {
73 let first_error = &errors[0];
75 Err(FraiseQLError::Authorization {
76 message: first_error.message.clone(),
77 action: Some("read".to_string()),
78 resource: Some(format!("{}.{}", first_error.type_name, first_error.field_name)),
79 })
80 }
81 }
82
83 pub async fn execute_with_context(
126 &self,
127 query: &str,
128 variables: Option<&serde_json::Value>,
129 ctx: &ExecutionContext,
130 ) -> Result<serde_json::Value> {
131 if ctx.is_cancelled() {
133 return Err(FraiseQLError::cancelled(
134 ctx.query_id().to_string(),
135 "Query cancelled before execution".to_string(),
136 ));
137 }
138
139 let token = ctx.cancellation_token().clone();
140
141 tokio::select! {
143 result = self.execute(query, variables) => {
144 result
145 }
146 () = token.cancelled() => {
147 Err(FraiseQLError::cancelled(
148 ctx.query_id().to_string(),
149 "Query cancelled during execution".to_string(),
150 ))
151 }
152 }
153 }
154
155 pub async fn execute_with_security(
205 &self,
206 query: &str,
207 variables: Option<&serde_json::Value>,
208 security_context: &SecurityContext,
209 ) -> Result<serde_json::Value> {
210 self.execute_with_timeout(query, variables, Some(security_context), None).await
216 }
217
218 pub async fn execute_operation_with_security(
232 &self,
233 query: &str,
234 variables: Option<&serde_json::Value>,
235 security_context: &SecurityContext,
236 operation_name: Option<&str>,
237 ) -> Result<serde_json::Value> {
238 self.execute_with_timeout(
239 query,
240 variables,
241 Some(security_context),
242 super::execution::normalize_operation_name(operation_name),
243 )
244 .await
245 }
246
247 pub fn check_field_access(
266 &self,
267 type_name: &str,
268 field_name: &str,
269 user_scopes: &[String],
270 ) -> std::result::Result<(), FieldAccessError> {
271 if let Some(ref filter) = self.ctx.config.field_filter {
272 filter.can_access(type_name, field_name, user_scopes)
273 } else {
274 Ok(())
276 }
277 }
278
279 #[deprecated(
288 since = "2.2.0",
289 note = "use execute() directly — it now returns Value"
290 )]
291 pub async fn execute_json(
292 &self,
293 query: &str,
294 variables: Option<&serde_json::Value>,
295 ) -> Result<serde_json::Value> {
296 self.execute(query, variables).await
297 }
298}
299
300#[cfg(test)]
301mod session_variable_tests {
302 #![allow(clippy::unwrap_used)] use chrono::Utc;
305
306 use crate::{
307 error::Result,
308 schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
309 security::SecurityContext,
310 };
311
312 fn resolve_session_variables(
314 config: &SessionVariablesConfig,
315 ctx: &SecurityContext,
316 ) -> Result<Vec<(String, String)>> {
317 super::support::security::resolve_session_variables(
318 config,
319 ctx,
320 crate::schema::security_config::DEFAULT_TENANT_CLAIM,
321 super::support::security::SessionPurpose::Write,
322 )
323 }
324
325 fn make_context() -> SecurityContext {
326 let mut attributes = std::collections::HashMap::new();
327 attributes.insert("tenant_id".to_string(), serde_json::json!("tenant-abc"));
328 attributes.insert("x-tenant-id".to_string(), serde_json::json!("header-tenant"));
329 attributes.insert("region".to_string(), serde_json::json!("eu-west-1"));
330 SecurityContext {
331 user_id: crate::types::UserId::new("user-42"),
332 roles: vec!["admin".to_string()],
333 tenant_id: Some(crate::types::TenantId::new("tenant-123")),
334 scopes: vec![],
335 attributes,
336 request_id: "req-test".to_string(),
337 ip_address: None,
338 authenticated_at: Utc::now(),
339 expires_at: Utc::now(),
340 issuer: None,
341 audience: None,
342 email: None,
343 display_name: None,
344 }
345 }
346
347 #[test]
348 fn resolve_session_variables_jwt_claim() {
349 let ctx = make_context();
350 let config = SessionVariablesConfig {
351 variables: vec![SessionVariableMapping {
352 name: "app.tenant_id".to_string(),
353 source: SessionVariableSource::Jwt {
354 claim: "tenant_id".to_string(),
355 },
356 }],
357 inject_started_at: false,
358 };
359 let vars = resolve_session_variables(&config, &ctx).unwrap();
360 assert_eq!(vars.len(), 1);
362 assert_eq!(vars[0].0, "app.tenant_id");
363 assert_eq!(vars[0].1, "tenant-abc");
364 }
365
366 #[test]
370 fn a_jwt_session_variable_never_reads_the_tenant_under_another_claim_name() {
371 let ctx = make_context(); let config = SessionVariablesConfig {
373 variables: vec![SessionVariableMapping {
374 name: "app.org".to_string(),
375 source: SessionVariableSource::Jwt {
376 claim: "org_id".to_string(),
377 },
378 }],
379 inject_started_at: false,
380 };
381 let vars = resolve_session_variables(&config, &ctx).unwrap();
382 assert!(vars.is_empty(), "an absent claim sets nothing, got {vars:?}");
383 }
384
385 #[test]
386 fn resolve_session_variables_jwt_well_known_sub() {
387 let ctx = make_context();
388 let config = SessionVariablesConfig {
389 variables: vec![SessionVariableMapping {
390 name: "app.user_id".to_string(),
391 source: SessionVariableSource::Jwt {
392 claim: "sub".to_string(),
393 },
394 }],
395 inject_started_at: false,
396 };
397 let vars = resolve_session_variables(&config, &ctx).unwrap();
398 assert_eq!(vars.len(), 1);
399 assert_eq!(vars[0].0, "app.user_id");
400 assert_eq!(vars[0].1, "user-42");
401 }
402
403 #[test]
404 fn resolve_session_variables_literal() {
405 let ctx = make_context();
406 let config = SessionVariablesConfig {
407 variables: vec![SessionVariableMapping {
408 name: "app.locale".to_string(),
409 source: SessionVariableSource::Literal {
410 value: "en".to_string(),
411 },
412 }],
413 inject_started_at: false,
414 };
415 let vars = resolve_session_variables(&config, &ctx).unwrap();
416 assert_eq!(vars.len(), 1);
417 assert_eq!(vars[0].0, "app.locale");
418 assert_eq!(vars[0].1, "en");
419 }
420
421 #[test]
422 fn inject_started_at_prepended() {
423 let ctx = make_context();
424 let config = SessionVariablesConfig {
425 variables: vec![SessionVariableMapping {
426 name: "app.locale".to_string(),
427 source: SessionVariableSource::Literal {
428 value: "en".to_string(),
429 },
430 }],
431 inject_started_at: true,
432 };
433 let vars = resolve_session_variables(&config, &ctx).unwrap();
434 assert_eq!(vars.len(), 2);
436 assert_eq!(vars[0].0, fraiseql_db::STARTED_AT_VAR);
437 assert_eq!(vars[0].1, fraiseql_db::CLOCK_TIMESTAMP_DIRECTIVE);
440 assert_eq!(vars[1].0, "app.locale");
441 }
442
443 #[test]
444 fn inject_started_at_disabled() {
445 let ctx = make_context();
446 let config = SessionVariablesConfig {
447 variables: vec![],
448 inject_started_at: false,
449 };
450 let vars = resolve_session_variables(&config, &ctx).unwrap();
451 assert!(vars.is_empty());
452 assert!(!vars.iter().any(|(k, _)| k == "fraiseql.started_at"));
453 }
454
455 #[test]
456 fn resolve_session_variables_header() {
457 let ctx = make_context();
458 let config = SessionVariablesConfig {
459 variables: vec![SessionVariableMapping {
460 name: "app.tenant".to_string(),
461 source: SessionVariableSource::Header {
462 header: "x-tenant-id".to_string(),
463 },
464 }],
465 inject_started_at: false,
466 };
467 let vars = resolve_session_variables(&config, &ctx).unwrap();
468 assert_eq!(vars.len(), 1);
469 assert_eq!(vars[0].0, "app.tenant");
470 assert_eq!(vars[0].1, "header-tenant");
471 }
472
473 #[test]
474 fn resolve_session_variables_jwt_email() {
475 let mut ctx = make_context();
476 ctx.email = Some("user@corp.com".to_string());
477 let config = SessionVariablesConfig {
478 variables: vec![SessionVariableMapping {
479 name: "app.email".to_string(),
480 source: SessionVariableSource::Jwt {
481 claim: "email".to_string(),
482 },
483 }],
484 inject_started_at: false,
485 };
486 let vars = resolve_session_variables(&config, &ctx).unwrap();
487 assert_eq!(vars.len(), 1);
488 assert_eq!(vars[0].0, "app.email");
489 assert_eq!(vars[0].1, "user@corp.com");
490 }
491
492 #[test]
493 fn resolve_session_variables_jwt_display_name() {
494 let mut ctx = make_context();
495 ctx.display_name = Some("Jane Doe".to_string());
496 let config = SessionVariablesConfig {
497 variables: vec![
498 SessionVariableMapping {
499 name: "app.name".to_string(),
500 source: SessionVariableSource::Jwt {
501 claim: "name".to_string(),
502 },
503 },
504 SessionVariableMapping {
505 name: "app.display_name".to_string(),
506 source: SessionVariableSource::Jwt {
507 claim: "display_name".to_string(),
508 },
509 },
510 ],
511 inject_started_at: false,
512 };
513 let vars = resolve_session_variables(&config, &ctx).unwrap();
514 assert_eq!(vars.len(), 2);
515 assert_eq!(vars[0].1, "Jane Doe");
516 assert_eq!(vars[1].1, "Jane Doe");
517 }
518
519 #[test]
520 fn resolve_session_variables_missing_email_skipped() {
521 let ctx = make_context(); let config = SessionVariablesConfig {
523 variables: vec![SessionVariableMapping {
524 name: "app.email".to_string(),
525 source: SessionVariableSource::Jwt {
526 claim: "email".to_string(),
527 },
528 }],
529 inject_started_at: false,
530 };
531 let vars = resolve_session_variables(&config, &ctx).unwrap();
532 assert!(vars.is_empty(), "missing email should be silently skipped");
533 }
534
535 #[test]
536 fn resolve_session_variables_enrichment_reads_namespace() {
537 let mut ctx = make_context();
538 ctx.attributes
542 .insert("fraiseql.enriched.actor_role".to_string(), serde_json::json!("manager"));
543 let config = SessionVariablesConfig {
544 variables: vec![SessionVariableMapping {
545 name: "app.actor_role".to_string(),
546 source: SessionVariableSource::Enrichment {
547 field: "actor_role".to_string(),
548 },
549 }],
550 inject_started_at: false,
551 };
552 let vars = resolve_session_variables(&config, &ctx).unwrap();
553 assert_eq!(vars.len(), 1);
554 assert_eq!(vars[0].0, "app.actor_role");
555 assert_eq!(vars[0].1, "manager");
556 }
557
558 #[test]
559 fn resolve_session_variables_enrichment_missing_field_errors() {
560 let ctx = make_context();
563 let config = SessionVariablesConfig {
564 variables: vec![SessionVariableMapping {
565 name: "app.actor_role".to_string(),
566 source: SessionVariableSource::Enrichment {
567 field: "actor_role".to_string(),
568 },
569 }],
570 inject_started_at: false,
571 };
572 assert!(resolve_session_variables(&config, &ctx).is_err());
573 }
574
575 #[test]
576 fn resolve_session_variables_enrichment_does_not_fall_back_to_raw_claim() {
577 let mut ctx = make_context();
582 ctx.attributes.insert("actor_role".to_string(), serde_json::json!("admin"));
583 let config = SessionVariablesConfig {
584 variables: vec![SessionVariableMapping {
585 name: "app.actor_role".to_string(),
586 source: SessionVariableSource::Enrichment {
587 field: "actor_role".to_string(),
588 },
589 }],
590 inject_started_at: false,
591 };
592 assert!(
593 resolve_session_variables(&config, &ctx).is_err(),
594 "Enrichment must not fall back to a raw JWT claim"
595 );
596 }
597}
598
599#[cfg(test)]
606mod enrichment_entry_point_tests {
607 #![allow(clippy::unwrap_used)] use std::sync::Arc;
610
611 use chrono::Utc;
612
613 use super::super::mutation::any_write_selections;
614 use crate::{
615 error::FraiseQLError,
616 runtime::{Executor, executor::test_support::MockAdapter},
617 schema::{
618 CompiledSchema, MutationDefinition, QueryDefinition, SessionVariableMapping,
619 SessionVariableSource, TypeDefinition,
620 },
621 security::{EnrichmentMark, SecurityContext},
622 };
623
624 fn schema() -> CompiledSchema {
627 let mut schema = CompiledSchema::default();
628 schema.session_variables.variables.push(SessionVariableMapping {
629 name: "app.actor_id".to_string(),
630 source: SessionVariableSource::Enrichment {
631 field: "actor_id".to_string(),
632 },
633 });
634
635 let mut order = TypeDefinition::new("Order", "v_order");
636 order.fields = vec![crate::schema::FieldDefinition::new(
637 "id",
638 crate::schema::FieldType::Id,
639 )];
640 schema.types.push(order);
641
642 let mut query = QueryDefinition::new("orders", "Order");
643 query.sql_source = Some("v_order".to_string());
644 query.returns_list = true;
645 schema.queries.push(query);
646
647 let mut create = MutationDefinition::new("createOrder", "Order");
648 create.sql_source = Some("fn_create_order".to_string());
649 schema.mutations.push(create);
650
651 schema.build_indexes();
652 schema
653 }
654
655 fn executor() -> Executor {
656 Executor::new(schema(), Arc::new(MockAdapter::new(vec![])))
657 }
658
659 fn unresolved() -> SecurityContext {
661 SecurityContext {
662 user_id: crate::types::UserId::new("user-1336"),
663 roles: vec![],
664 tenant_id: None,
665 scopes: vec![],
666 attributes: std::collections::HashMap::new(),
667 request_id: "req-1336".to_string(),
668 ip_address: None,
669 authenticated_at: Utc::now(),
670 expires_at: Utc::now() + chrono::Duration::hours(1),
671 issuer: None,
672 audience: None,
673 email: None,
674 display_name: None,
675 }
676 }
677
678 fn resolved() -> SecurityContext {
679 let mut ctx = unresolved();
680 ctx.mark_enrichment(EnrichmentMark::Resolved);
681 ctx
682 }
683
684 fn is_refusal(err: &FraiseQLError) -> bool {
685 matches!(err, FraiseQLError::Authorization { .. })
686 }
687
688 #[tokio::test]
689 async fn the_graphql_document_entry_refuses_an_unresolved_principal() {
690 let err = executor()
693 .execute_with_security("{ orders { id } }", None, &unresolved())
694 .await
695 .expect_err("an unresolved principal must not execute");
696
697 assert!(is_refusal(&err), "expected an authorization refusal, got: {err}");
698 }
699
700 #[tokio::test]
701 async fn the_graphql_document_entry_admits_a_resolved_principal() {
702 let outcome =
705 executor().execute_with_security("{ orders { id } }", None, &resolved()).await;
706
707 assert!(
708 outcome.as_ref().err().is_none_or(|e| !is_refusal(e)),
709 "a resolved principal must get past this guard; got: {outcome:?}"
710 );
711 }
712
713 #[tokio::test]
714 async fn the_direct_read_entries_refuse_an_unresolved_principal() {
715 let executor = executor();
718 let query_match = crate::runtime::QueryMatcher::new(schema())
721 .match_query("{ orders { id } }", None)
722 .expect("the fixture query matches");
723
724 let read = executor
725 .execute_query_direct(&query_match, None, Some(&unresolved()), None)
726 .await;
727 assert!(
728 read.as_ref().err().is_some_and(is_refusal),
729 "execute_query_direct must refuse; got: {read:?}"
730 );
731
732 let count = executor.count_rows(&query_match, None, Some(&unresolved())).await;
733 assert!(
734 count.as_ref().err().is_some_and(is_refusal),
735 "count_rows must refuse too — it is the second chokepoint every REST read \
736 passes through, and a guard on one of the pair leaves the other open; \
737 got: {count:?}"
738 );
739 }
740
741 #[tokio::test]
742 async fn the_streaming_read_entry_refuses_an_unresolved_principal() {
743 let executor = executor();
748 let query_match = crate::runtime::QueryMatcher::new(schema())
749 .match_query("{ orders { id } }", None)
750 .expect("the fixture query matches");
751
752 let stream = executor.stream_query_direct(query_match, None, Some(unresolved())).await;
753
754 assert!(
755 stream.as_ref().err().is_some_and(is_refusal),
756 "stream_query_direct must refuse an unresolved principal too; got an Ok or a \
757 non-authorization error"
758 );
759 }
760
761 #[tokio::test]
762 async fn the_mutation_chokepoint_refuses_an_unresolved_principal() {
763 let err = executor()
764 .execute_mutation_as("createOrder", None, Some(&unresolved()), any_write_selections())
765 .await
766 .expect_err("an unresolved principal must not write");
767
768 assert!(is_refusal(&err), "expected an authorization refusal, got: {err}");
769 }
770}