fraiseql-core 2.15.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
//! Pure RBAC field-access classification helpers and session variable resolution.
//!
//! These are stateless functions that require no `&self` — all inputs come from
//! parameters.  They are shared by multiple runners without creating any coupling
//! to `Executor`.

use crate::{
    error::{FraiseQLError, Result},
    runtime::{classify_field_access, field_filter::FieldAccessResult},
    schema::{CompiledSchema, FieldDenyPolicy, SessionVariableSource, SessionVariablesConfig},
    security::{ENRICHED_NAMESPACE_PREFIX, SecurityContext},
};

/// Refuse a principal that never passed a transport's enrichment seam (#1336).
///
/// The enforcement that matters is the producer's: every transport resolves the
/// subject it dispatches with, and fail-closes there. This is the **backstop** for
/// the failure that produced the issue — a transport that builds a principal and
/// dispatches it without resolving. REST, MCP and gRPC each did exactly that, and
/// nothing downstream noticed, because an operation reading no enriched field
/// answers the same either way.
///
/// So the engine asks the one question it can answer for every transport that
/// reaches it: *was this principal ever resolved?* An unmarked context is refused
/// when the schema declares an enrichment consumer — which, given the boot check
/// that refuses such a schema without `[identity.enrichment].enabled`, means a
/// resolver exists and something skipped it.
///
/// Absence of the mark is the fail-closed state, so this catches a transport nobody
/// remembered to change — including `fraiseql-arrow`'s Flight server, which lives in
/// a crate that cannot reach the resolver and reaches the engine through
/// `execute_with_security`.
///
/// Anonymous requests carry no principal and are not this gate's business; a
/// `system_job` marks itself [`EnrichmentMark::Exempt`], having no subject to
/// resolve.
///
/// ⚠ A deployment that enables enrichment while declaring **no** consumer is outside
/// this backstop — the engine has no schema-visible signal there. The producer seam
/// still fail-closes such a deployment; the server already warns at startup that it
/// is probably a misconfiguration.
///
/// # Errors
///
/// [`FraiseQLError::Authorization`] — surfaced as 403, matching the transport
/// producers' own answer for a refused identity.
pub(in super::super) fn enforce_enrichment_resolved(
    schema: &crate::schema::CompiledSchema,
    security_context: Option<&SecurityContext>,
) -> crate::error::Result<()> {
    let Some(ctx) = security_context else {
        return Ok(());
    };
    if ctx.enrichment_mark().is_some() || !schema.declares_enrichment_consumer() {
        return Ok(());
    }
    Err(FraiseQLError::Authorization {
        message:  "Identity was not resolved for this request. The schema declares an \
                   enriched-identity consumer, so every authenticated request must resolve \
                   before dispatch — this one reached the engine unresolved, which means the \
                   transport it arrived on does not run the enrichment seam."
            .to_string(),
        action:   Some("execute".to_string()),
        resource: None,
    })
}

/// Resolve session variable mappings against the current security context.
///
/// Returns a list of `(name, value)` pairs to inject as PostgreSQL transaction-scoped
/// session variables via `set_config()`.
///
/// Resolution rules:
/// - [`SessionVariableSource::Jwt`] — looks up the claim in `security_context.attributes`; falls
///   back to `user_id` for `"sub"` and to `tenant_id` for `"tenant_id"`.  Missing claims are
///   silently skipped.
/// - [`SessionVariableSource::Header`] — looks up the header name in `security_context.attributes`.
///   Missing headers are silently skipped.
/// - [`SessionVariableSource::Literal`] — uses the fixed value as-is.
/// - [`SessionVariableSource::Enrichment`] — reads the reserved `fraiseql.enriched.*` attribute
///   namespace with **no** fallback; a missing enriched field is a hard error, never a
///   silently-skipped/empty GUC (#539).
///
/// When `config.inject_started_at` is `true`, the pair
/// `(STARTED_AT_VAR, CLOCK_TIMESTAMP_DIRECTIVE)` is **prepended** to the returned
/// list. The adapter resolves that directive by stamping the variable with the
/// database's `clock_timestamp()` at apply time — so the start timestamp is on
/// the **DB clock**, the same clock used to close the interval at the change-log
/// outbox write (no app↔DB skew). This replaces the former app-clock
/// `Utc::now()` RFC-3339 value.
///
/// # Errors
///
/// Returns [`FraiseQLError::Validation`] if a [`SessionVariableSource::Enrichment`]
/// mapping references an enriched field absent from the resolved identity. The
/// lenient `Jwt`/`Header`/`Literal` arms never error. This is defense-in-depth:
/// the server fail-closes enrichment before dispatch, so reaching a missing
/// enriched field here is an invariant violation (a config mismatch), treated as
/// one rather than silently skipped.
pub(in super::super) fn resolve_session_variables(
    config: &SessionVariablesConfig,
    security_context: &SecurityContext,
) -> Result<Vec<(String, String)>> {
    let mut vars: Vec<(String, String)> = Vec::new();

    if config.inject_started_at {
        vars.push((
            fraiseql_db::STARTED_AT_VAR.to_string(),
            fraiseql_db::CLOCK_TIMESTAMP_DIRECTIVE.to_string(),
        ));
    }

    for mapping in &config.variables {
        let value: Option<String> = match &mapping.source {
            SessionVariableSource::Jwt { claim } => {
                // Check custom attributes first (raw JWT claims forwarded there).
                // Fall back to well-known SecurityContext fields for `sub`/`user_id`
                // and `tenant_id` so that schemas that populate only those fields
                // (not attributes) still work.
                if let Some(v) = security_context.attributes.get(claim.as_str()) {
                    Some(if let serde_json::Value::String(s) = v {
                        s.clone()
                    } else {
                        v.to_string()
                    })
                } else if claim == "sub" || claim == "user_id" {
                    Some(security_context.user_id.0.clone())
                } else if claim == "tenant_id" {
                    security_context.tenant_id.as_ref().map(|t| t.0.clone())
                } else if claim == "email" {
                    security_context.email.clone()
                } else if claim == "name" || claim == "display_name" {
                    security_context.display_name.clone()
                } else {
                    None
                }
            },
            SessionVariableSource::Header { header } => {
                // HTTP headers are forwarded into attributes
                security_context.attributes.get(header.as_str()).map(|v| {
                    if let serde_json::Value::String(s) = v {
                        s.clone()
                    } else {
                        v.to_string()
                    }
                })
            },
            SessionVariableSource::Literal { value } => Some(value.clone()),
            SessionVariableSource::Enrichment { field } => {
                // Read ONLY the reserved namespace — no fallback to a raw claim or
                // a well-known field. The extractor strips `fraiseql.` claims, so
                // this key can only have been written by the server's identity
                // resolver (DESIGN §3.2).
                let key = format!("{ENRICHED_NAMESPACE_PREFIX}{field}");
                let Some(v) = security_context.attributes.get(&key) else {
                    return Err(FraiseQLError::Validation {
                        message: format!(
                            "Session variable '{}' maps to enriched field '{field}', which is \
                             absent from the resolved identity (enrichment did not run, or the \
                             enrichment query's `map` does not produce it)",
                            mapping.name
                        ),
                        path:    None,
                    });
                };
                Some(if let serde_json::Value::String(s) = v {
                    s.clone()
                } else {
                    v.to_string()
                })
            },
        };
        if let Some(v) = value {
            vars.push((mapping.name.clone(), v));
        }
    }

    Ok(vars)
}

/// Classify each requested field as allowed, masked, or rejected.
///
/// Does NOT require `&self` — all data comes from parameters.
///
/// # Errors
///
/// Returns `FraiseQLError::Authorization` if any field has `on_deny = Reject`
/// and the user lacks the required scope.
pub(in super::super) fn apply_field_rbac_filtering(
    schema: &CompiledSchema,
    return_type: &str,
    projection_fields: Vec<String>,
    security_context: &SecurityContext,
) -> Result<FieldAccessResult> {
    // A scope is granted only by a role the `security` section defines. Without the
    // section no role is defined, so no principal holds any scope: the gate still
    // applies, and denies (ruling Y 7). It used to be skipped, serving every scoped field
    // to every principal. A compiled schema in that shape is refused at load; this is the
    // same answer for one built in code.
    let no_roles = crate::schema::SecurityConfig::default();
    let security_config = schema.security.as_ref().unwrap_or(&no_roles);
    if let Some(type_def) = schema.types.iter().find(|t| t.name == return_type) {
        return classify_field_access(
            security_context,
            security_config,
            &type_def.fields,
            projection_fields,
        )
        .map_err(|rejected_field| FraiseQLError::Authorization {
            message:  format!(
                "Access denied: field '{rejected_field}' on type '{return_type}' \
                 requires a scope you do not have"
            ),
            action:   Some("read".to_string()),
            resource: Some(format!("{return_type}.{rejected_field}")),
        });
    }

    Ok(FieldAccessResult {
        projected: projection_fields,
        masked:    Vec::new(),
    })
}

/// Classify field access for a request that carries **no** principal.
///
/// The anonymous path used to skip field RBAC entirely, so a field protected only
/// by `requires_scope` (and not the dynamic `authorize` flag) was served in full to
/// unauthenticated callers while authenticated-but-unscoped callers were denied it
/// — a privilege inversion (#743).
///
/// Every `requires_scope` field is denied here, through its own `on_deny` policy.
/// Deriving the answer directly avoids fabricating a stand-in `SecurityContext` that
/// could leak into RLS session variables or inject-param resolution.
///
/// **Not the same as an authenticated principal with no roles.** That used to be an
/// exact equivalence — `can_access_scope` folded over `roles`, so an empty set granted
/// nothing — but since #894 an empty role set falls back to
/// `SecurityConfig::default_role`. The fallback is for principals who authenticated
/// and carry no role claim; extending it here would hand every `requires_scope` field
/// to callers who presented no credential, which is #743's privilege inversion in the
/// opposite direction. Anonymous stays deny-all, unconditionally.
///
/// # Errors
///
/// Returns [`FraiseQLError::Authorization`] if a requested field requires a scope
/// and has `on_deny = Reject`.
pub(in super::super) fn apply_anonymous_field_rbac_filtering(
    schema: &CompiledSchema,
    return_type: &str,
    projection_fields: &[String],
) -> Result<FieldAccessResult> {
    let allow_all = || FieldAccessResult {
        projected: projection_fields.to_vec(),
        masked:    Vec::new(),
    };

    let Some(type_def) = schema.types.iter().find(|t| t.name == return_type) else {
        return Ok(allow_all());
    };

    let mut projected = Vec::with_capacity(projection_fields.len());
    let mut masked = Vec::new();

    for name in projection_fields {
        // Fields absent from the type definition pass through, as in
        // classify_field_access — they are built-ins such as `__typename`.
        // Masked fields keep their requested position; only their value is
        // withheld.
        projected.push(name.clone());
        let Some(field) = type_def.fields.iter().find(|f| &f.name == name) else {
            continue;
        };

        if field.requires_scope.is_none() {
            continue;
        }
        match field.on_deny {
            FieldDenyPolicy::Mask => masked.push(name.clone()),
            FieldDenyPolicy::Reject => {
                return Err(FraiseQLError::Authorization {
                    message:  format!(
                        "Access denied: field '{name}' on type '{return_type}' \
                         requires a scope you do not have"
                    ),
                    action:   Some("read".to_string()),
                    resource: Some(format!("{return_type}.{name}")),
                });
            },
        }
    }

    Ok(FieldAccessResult { projected, masked })
}

/// Classify the requested field set for a read, with or without a principal.
///
/// Which of the two classifiers applies is a property of **whether a principal
/// exists**, not of the transport the read arrived on. Both GraphQL entry points
/// already made that choice — the authenticated runner calls
/// [`apply_field_rbac_filtering`], the anonymous one calls
/// [`apply_anonymous_field_rbac_filtering`] — each open-coding it at its own call
/// site. `execute_query_direct`, the runner behind the whole REST read surface,
/// made neither call and ran no field RBAC at all (`#886`).
///
/// Routing every read through one function is what stops the next transport from
/// silently arriving without a classifier: there is no longer a correct-looking way
/// to project results without having classified them first.
///
/// # Errors
///
/// Returns [`FraiseQLError::Authorization`] if a requested field requires a scope the
/// caller lacks and its `on_deny` policy is `Reject`.
pub(in super::super) fn classify_fields_for_read(
    schema: &CompiledSchema,
    return_type: &str,
    projection_fields: Vec<String>,
    security_context: Option<&SecurityContext>,
) -> Result<FieldAccessResult> {
    match security_context {
        Some(ctx) => apply_field_rbac_filtering(schema, return_type, projection_fields, ctx),
        None => apply_anonymous_field_rbac_filtering(schema, return_type, &projection_fields),
    }
}

/// Refuse a client's reference to `field` of `type_name` — to `usage` it: filter, order or
/// search by it — that [`can_reference_field`](crate::runtime::can_reference_field) does not
/// allow (ruling AA 3).
///
/// # Errors
///
/// [`FraiseQLError::Authorization`] naming the field and the use.
pub(in super::super) fn refuse_unreadable_reference(
    schema: &CompiledSchema,
    type_name: &str,
    field: &crate::schema::FieldDefinition,
    security_context: Option<&SecurityContext>,
    usage: &str,
) -> Result<()> {
    if crate::runtime::can_reference_field(schema.security.as_ref(), field, security_context) {
        return Ok(());
    }
    Err(FraiseQLError::Authorization {
        message:  format!(
            "Access denied: '{type_name}.{}' cannot be used to {usage}: the request may not \
             read it",
            field.name
        ),
        action:   Some("read".to_string()),
        resource: Some(format!("{type_name}.{}", field.name)),
    })
}

/// Refuse a client filter over `type_name` that references a field the caller may not
/// read, at any depth (ruling AA 3).
///
/// Walks the parsed clause. Each condition's path runs through relation and object fields
/// to the field it compares, and every field on the way is a reference: filtering by
/// `orders.margin` asks about each order's margin, and about which users have orders at
/// all. A step into a type whose own `requires_role` the caller lacks is refused likewise —
/// the filter reads that type. Native-column conditions come from arguments the schema
/// author declared, not from the client's filter, and are not walked.
///
/// # Errors
///
/// [`FraiseQLError::Authorization`] for the first such reference.
pub(in super::super) fn refuse_unreadable_where(
    schema: &CompiledSchema,
    type_name: &str,
    clause: &crate::db::WhereClause,
    security_context: Option<&SecurityContext>,
) -> Result<()> {
    use crate::db::WhereClause;
    match clause {
        WhereClause::And(clauses) | WhereClause::Or(clauses) => clauses
            .iter()
            .try_for_each(|c| refuse_unreadable_where(schema, type_name, c, security_context)),
        WhereClause::Not(inner)
        | WhereClause::Typed { inner, .. }
        | WhereClause::Guarded { inner, .. } => {
            refuse_unreadable_where(schema, type_name, inner, security_context)
        },
        WhereClause::Field { path, .. } => {
            refuse_unreadable_path(schema, type_name, path, security_context)
        },
        _ => Ok(()),
    }
}

/// [`refuse_unreadable_where`] for one condition's storage-key path.
fn refuse_unreadable_path(
    schema: &CompiledSchema,
    type_name: &str,
    path: &[String],
    security_context: Option<&SecurityContext>,
) -> Result<()> {
    let mut current = type_name.to_string();
    for (depth, segment) in path.iter().enumerate() {
        let Some(type_def) = schema.find_type(&current) else {
            return Ok(());
        };
        let Some(field) = type_def
            .fields
            .iter()
            .find(|f| crate::utils::to_snake_case(f.name.as_str()) == *segment)
        else {
            // A key inside a JSON value the schema does not type: nothing to classify.
            return Ok(());
        };
        refuse_unreadable_reference(schema, &current, field, security_context, "filter by")?;
        if depth + 1 == path.len() {
            return Ok(());
        }
        let Some(child) = field.field_type.inner_type().unwrap_or(&field.field_type).type_name()
        else {
            return Ok(());
        };
        if let Some(role) = schema.find_type(child).and_then(|t| t.requires_role.as_deref()) {
            if !security_context.is_some_and(|ctx| ctx.roles.iter().any(|r| r == role)) {
                return Err(FraiseQLError::Authorization {
                    message:  format!(
                        "Access denied: '{current}.{}' cannot be used to filter by: it reads \
                         '{child}', whose read requires a role the request does not hold",
                        field.name
                    ),
                    action:   Some("read".to_string()),
                    resource: Some(child.to_string()),
                });
            }
        }
        current = child.to_string();
    }
    Ok(())
}

// ── #1336: the backstop for a transport that never resolved ────────────────
#[cfg(test)]
mod enrichment_backstop_tests {
    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable

    use chrono::Utc;

    use super::enforce_enrichment_resolved;
    use crate::{
        schema::{
            CompiledSchema, InjectedParamSource, SessionVariableMapping, SessionVariableSource,
        },
        security::{EnrichmentMark, SecurityContext},
    };

    fn principal() -> SecurityContext {
        SecurityContext {
            user_id:          crate::types::UserId::new("user-1336"),
            roles:            vec![],
            tenant_id:        None,
            scopes:           vec![],
            attributes:       std::collections::HashMap::new(),
            request_id:       "req-1336".to_string(),
            ip_address:       None,
            authenticated_at: Utc::now(),
            expires_at:       Utc::now() + chrono::Duration::hours(1),
            issuer:           None,
            audience:         None,
            email:            None,
            display_name:     None,
        }
    }

    /// A schema that reads enriched identity through a **session variable**.
    fn schema_with_session_variable_consumer() -> CompiledSchema {
        let mut schema = CompiledSchema::default();
        schema.session_variables.variables.push(SessionVariableMapping {
            name:   "app.actor_id".to_string(),
            source: SessionVariableSource::Enrichment {
                field: "actor_id".to_string(),
            },
        });
        schema
    }

    /// A schema that reads enriched identity through an **inject param** instead.
    ///
    /// Both declaration sites, because a scan that covered one would let the other
    /// deployment through the backstop entirely — and they are separate loops.
    fn schema_with_inject_param_consumer() -> CompiledSchema {
        let mut schema = CompiledSchema::default();
        let mut query = crate::schema::QueryDefinition::new("orders", "Order");
        query
            .inject_params
            .insert("actor".to_string(), InjectedParamSource::Enrichment("actor_id".to_string()));
        schema.queries.push(query);
        schema
    }

    #[test]
    fn an_unresolved_principal_is_refused() {
        // The case this guard exists for: a transport that builds a principal and
        // dispatches it without resolving. REST, MCP and gRPC each did, and the
        // Flight server in `fraiseql-arrow` still cannot reach the resolver at all.
        for schema in [
            schema_with_session_variable_consumer(),
            schema_with_inject_param_consumer(),
        ] {
            assert!(
                enforce_enrichment_resolved(&schema, Some(&principal())).is_err(),
                "a principal carrying no enrichment mark must not execute against a schema \
                 that reads enriched identity"
            );
        }
    }

    #[test]
    fn a_resolved_principal_proceeds() {
        // The positive twin. Without it a guard that refused everything would satisfy
        // every refusal case in this module.
        let mut ctx = principal();
        ctx.mark_enrichment(EnrichmentMark::Resolved);

        assert!(
            enforce_enrichment_resolved(&schema_with_session_variable_consumer(), Some(&ctx))
                .is_ok(),
            "a principal that passed a transport's seam must execute"
        );
    }

    #[test]
    fn a_system_job_is_exempt() {
        // The server acting as itself has no subject a resolver could look up, so a
        // scheduled Source must keep running against an enrichment-declaring schema.
        let ctx = SecurityContext::system_job("ingest", "req-1", vec![], vec![], None);

        assert_eq!(ctx.enrichment_mark(), Some(EnrichmentMark::Exempt));
        assert!(
            enforce_enrichment_resolved(&schema_with_session_variable_consumer(), Some(&ctx))
                .is_ok(),
            "a system job must not be refused by a gate about resolving request subjects"
        );
    }

    #[test]
    fn an_anonymous_request_is_not_this_gates_business() {
        assert!(
            enforce_enrichment_resolved(&schema_with_session_variable_consumer(), None).is_ok(),
            "there is no subject to resolve, so there is nothing to refuse here — the \
             operation's own inject/session-variable requirements still apply downstream"
        );
    }

    #[test]
    fn a_schema_that_reads_no_enriched_field_admits_an_unmarked_principal() {
        // The over-refusal case. Every deployment that does not use enriched identity
        // dispatches unmarked principals — including every fixture in this repo — so a
        // guard keyed on the principal alone would refuse the entire test suite.
        assert!(
            enforce_enrichment_resolved(&CompiledSchema::default(), Some(&principal())).is_ok(),
            "a schema declaring no enrichment consumer must be unaffected by this guard"
        );
    }
}