Skip to main content

fraiseql_core/runtime/executor/
security.rs

1//! Security-aware execution — field access, RBAC filtering, JWT inject resolution,
2//! `execute_with_context()`, `execute_with_security()`, `execute_json()`.
3
4use super::{Executor, support};
5use crate::{
6    error::{FraiseQLError, Result},
7    runtime::ExecutionContext,
8    security::{FieldAccessError, SecurityContext},
9};
10
11/// Resolve session variable mappings against the current security context.
12///
13/// Returns the `(name, value)` pairs to apply transaction-locally with
14/// `set_config` before the statement, so PostgreSQL RLS policies reading
15/// `current_setting()` see the caller's identity (#329).
16///
17/// Resolution follows each mapping's [`SessionVariableSource`]: a `Jwt` claim is
18/// looked up in the context's attributes, falling back to `user_id` for
19/// `sub`/`user_id` and to `tenant_id`/`email`/`name` for their own claims; a
20/// `Header` is read from attributes; a `Literal` is used as-is; and an
21/// `Enrichment` field reads the reserved `fraiseql.enriched.*` namespace with
22/// **no** fallback — a missing enriched field is an error, never a silently
23/// absent GUC (#539). With `inject_started_at`, the started-at directive is
24/// prepended for the adapter to stamp on the database clock.
25///
26/// This is the one construction site: the query, mutation and aggregate runners
27/// call it, and so does the admin SQL console's RLS preview (#962). A preview
28/// computed by a second implementation would be a preview of that
29/// implementation.
30///
31/// [`SessionVariableSource`]: crate::schema::SessionVariableSource
32///
33/// # Errors
34///
35/// Returns [`FraiseQLError::Validation`] if a `SessionVariableSource::Enrichment`
36/// mapping references an enriched field absent from the resolved identity (#539).
37pub fn resolve_session_variables(
38    schema: &crate::schema::CompiledSchema,
39    security_context: &SecurityContext,
40) -> crate::error::Result<Vec<(String, String)>> {
41    // An arbitrary statement (the admin SQL console previews writes too) gets
42    // everything the executor would set for a write.
43    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    /// Validate that user has access to all requested fields.
53    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        // Parse query to get field selections
61        let query_match = self.ctx.matcher.match_query(query, variables)?;
62
63        // Get the return type name from the query definition
64        let type_name = &query_match.query_def.return_type;
65
66        // Validate each requested field
67        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            // Return the first error (could aggregate all errors if desired)
74            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    /// Execute a GraphQL query with cancellation support via `ExecutionContext`.
84    ///
85    /// This method allows graceful cancellation of long-running queries through a
86    /// cancellation token. If the token is cancelled during execution, the query
87    /// returns a `FraiseQLError::Cancelled` error.
88    ///
89    /// # Arguments
90    ///
91    /// * `query` - GraphQL query string
92    /// * `variables` - Query variables (optional)
93    /// * `ctx` - `ExecutionContext` with cancellation token
94    ///
95    /// # Returns
96    ///
97    /// GraphQL response as JSON string, or error if cancelled or execution fails
98    ///
99    /// # Errors
100    ///
101    /// * [`FraiseQLError::Cancelled`] — the cancellation token was triggered before or during
102    ///   execution.
103    /// * Propagates any error from the underlying [`execute`](Self::execute) call.
104    ///
105    /// # Example
106    ///
107    /// ```no_run
108    /// // Requires: a live database adapter and running tokio runtime.
109    /// // See: tests/integration/ for runnable examples.
110    /// use fraiseql_core::runtime::ExecutionContext;
111    /// use fraiseql_core::error::FraiseQLError;
112    /// use std::time::Duration;
113    ///
114    /// let ctx = ExecutionContext::new("user-query-123".to_string());
115    /// let cancel_token = ctx.cancellation_token().clone();
116    ///
117    /// // Spawn a task to cancel after 5 seconds
118    /// tokio::spawn(async move {
119    ///     tokio::time::sleep(Duration::from_secs(5)).await;
120    ///     cancel_token.cancel();
121    /// });
122    ///
123    /// // let result = executor.execute_with_context(query, None, &ctx).await;
124    /// ```
125    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        // Check if already cancelled before starting
132        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        // Use tokio::select! to race between execution and cancellation
142        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    /// Execute a GraphQL query or mutation with a JWT [`SecurityContext`].
156    ///
157    /// This is the **main authenticated entry point** for the executor. It routes the
158    /// incoming request to the appropriate handler based on the query type:
159    ///
160    /// - **Regular queries**: RLS `WHERE` clauses are applied so each user only sees their own
161    ///   rows, as determined by the RLS policy in `RuntimeConfig`.
162    /// - **Mutations**: the security context is forwarded so server-side `inject` parameters (e.g.
163    ///   `jwt:sub`) are resolved from the caller's JWT claims.
164    /// - **Multi-root queries** (e.g. `{ users { id } posts { id } }`): each root is dispatched in
165    ///   parallel with the security context applied to every root (H19).
166    /// - **Aggregations, window queries, federation, node lookups**: the security context **is**
167    ///   forwarded to each handler (RLS / `requires_role` / `inject` gates apply).
168    /// - **Introspection**: served from the pre-built response (no per-user data).
169    ///
170    /// If `query_timeout_ms` is non-zero in the `RuntimeConfig`, the entire
171    /// execution is raced against a Tokio deadline and returns
172    /// [`FraiseQLError::Timeout`] when the deadline is exceeded.
173    ///
174    /// # Arguments
175    ///
176    /// * `query` - GraphQL query string (e.g. `"query { posts { id title } }"`)
177    /// * `variables` - Optional JSON object of GraphQL variable values
178    /// * `security_context` - Authenticated user context extracted from a validated JWT
179    ///
180    /// # Returns
181    ///
182    /// A JSON-encoded GraphQL response string on success, conforming to the
183    /// [GraphQL over HTTP](https://graphql.github.io/graphql-over-http/) specification.
184    ///
185    /// # Errors
186    ///
187    /// * [`FraiseQLError::Parse`] — the query string is not valid GraphQL
188    /// * [`FraiseQLError::Validation`] — unknown mutation name, missing `sql_source`, or a mutation
189    ///   requires `inject` params but the security context is absent
190    /// * [`FraiseQLError::Database`] — the underlying adapter returns an error
191    /// * [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`
192    ///
193    /// # Example
194    ///
195    /// ```no_run
196    /// // Requires: a live database adapter and a SecurityContext from authentication.
197    /// // See: tests/integration/ for runnable examples.
198    /// use fraiseql_core::security::SecurityContext;
199    ///
200    /// // let query = r#"query { posts { id title } }"#;
201    /// // Returns a JSON string: {"data":{"posts":[...]}}
202    /// // let result = executor.execute_with_security(query, None, &context).await?;
203    /// ```
204    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        // Authenticated entry: delegate to the shared dispatch with the principal.
211        // GATE-1, the parse cache, the multi-root fan-out, and every per-operation
212        // runner are threaded with `Some(security_context)` in `execute_dispatch`,
213        // so this path cannot drift from the anonymous one (H19, L-gate1-skip,
214        // L-parse-cache). The timeout wrapper is shared via `execute_with_timeout`.
215        self.execute_with_timeout(query, variables, Some(security_context), None).await
216    }
217
218    /// Authenticated execution that selects the operation named by
219    /// `operation_name` (GraphQL § 6.1 *`GetOperation`*).
220    ///
221    /// [`execute_with_security`](Self::execute_with_security) is this with
222    /// `None`, which requires the document to define exactly one operation.
223    /// Every HTTP request carries an `operationName` field, so the server path
224    /// uses this entry point.
225    ///
226    /// # Errors
227    ///
228    /// - [`FraiseQLError::Parse`] — the document does not parse, names an operation that does not
229    ///   exist, or defines several operations while `operation_name` is `None`.
230    /// - Any error returned by [`execute_with_security`](Self::execute_with_security).
231    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    /// Check if a specific field can be accessed with given scopes.
248    ///
249    /// This is a convenience method for checking field access without executing a query.
250    ///
251    /// # Arguments
252    ///
253    /// * `type_name` - The GraphQL type name
254    /// * `field_name` - The field name
255    /// * `user_scopes` - User's scopes from JWT token
256    ///
257    /// # Returns
258    ///
259    /// `Ok(())` if access is allowed, `Err(FieldAccessError)` if denied
260    ///
261    /// # Errors
262    ///
263    /// Returns `FieldAccessError::AccessDenied` if the user's scopes do not include the
264    /// required scope for the field.
265    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            // No filter configured, allow all access
275            Ok(())
276        }
277    }
278
279    /// Execute a query and return parsed JSON.
280    ///
281    /// This method is now equivalent to `execute()` since `execute()` already
282    /// returns `serde_json::Value`.
283    ///
284    /// # Errors
285    ///
286    /// Returns any error from `execute()`.
287    #[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)] // Reason: test code, panics are acceptable
303
304    use chrono::Utc;
305
306    use crate::{
307        error::Result,
308        schema::{SessionVariableMapping, SessionVariableSource, SessionVariablesConfig},
309        security::SecurityContext,
310    };
311
312    /// The resolver as the executor calls it, under the default tenant claim.
313    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        // tenant_id is in attributes
361        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    /// The session-variable resolver and the inject resolver answer `jwt:<claim>`
367    /// identically (#1388): the tenant answers the configured tenant claim only, never
368    /// another name the token does not carry.
369    #[test]
370    fn a_jwt_session_variable_never_reads_the_tenant_under_another_claim_name() {
371        let ctx = make_context(); // tenant set, no `org_id` claim
372        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        // started_at must come first
435        assert_eq!(vars.len(), 2);
436        assert_eq!(vars[0].0, fraiseql_db::STARTED_AT_VAR);
437        // It carries the clock-timestamp directive (DB-clock-stamped at apply
438        // time), NOT an app-clock literal — see resolve_session_variables.
439        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(); // email is None
522        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        // A resolved enriched field, merged by the server under the reserved
539        // namespace (the extractor strips `fraiseql.` claims, so a token can't
540        // forge this key).
541        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        // Enrichment declared but the field is absent from the namespace: a
561        // hard error, never a silently-skipped/empty GUC (DESIGN §3.2, §5.2).
562        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        // A raw claim of the same name is present in attributes, but the
578        // Enrichment source reads ONLY the reserved namespace — so it must still
579        // fail, never impersonate a DB-derived field with an attacker-influenced
580        // claim (the security property `Enrichment` exists for).
581        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// ── #1336: the backstop fires at every engine entry, not only in isolation ──
600//
601// `enforce_enrichment_resolved` has its own unit tests; these assert it is actually
602// *reached* from each family of entry point. The distinction matters: the defect this
603// guards against is a call site that does not consult a rule, so testing the rule
604// without testing the sites would reproduce the original failure exactly.
605#[cfg(test)]
606mod enrichment_entry_point_tests {
607    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
608
609    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    /// A schema that reads enriched identity, with one query and one mutation so
625    /// every entry family has something to dispatch to.
626    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    /// A principal exactly as a transport that never resolved would produce it.
660    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        // `execute_with_security` is how `fraiseql-arrow`'s Flight server reaches the
691        // engine, from a crate that cannot see the resolver at all.
692        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        // The twin that keeps the case above honest: this query fails for its own
703        // reasons against a mock adapter, but it must not fail as a *refusal*.
704        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        // REST does not go through the GraphQL document path, so the guard has to be
716        // at these entries too — the gap that let #808 and #739 ship.
717        let executor = executor();
718        // A real match, built the way REST builds one — route resolution first, then
719        // the pre-resolved `QueryMatch` handed straight to the executor.
720        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        // Found by mutating the guard away: with only the two non-streaming read cases
744        // above, this call site could be deleted and the suite stayed green. REST's
745        // NDJSON/CSV/XLSX routes are the ones that reach it, and they carry exactly the
746        // same principal the JSON route does.
747        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}