fraiseql-core 2.16.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
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
//! Security context for runtime authorization
//!
//! This module provides the `SecurityContext` struct that flows through the executor,
//! carrying information about the authenticated user and their permissions.
//!
//! The security context is extracted from:
//! - JWT claims (`user_id` from 'sub', roles from 'roles', etc.)
//! - HTTP headers (`request_id`, `tenant_id`, etc.)
//! - Configuration (OAuth provider, scopes, etc.)
//!
//! # Architecture
//!
//! ```text
//! HTTP Request with Authorization header
//!     ↓
//! AuthMiddleware → AuthenticatedUser
//!     ↓
//! SecurityContext (created from AuthenticatedUser + request metadata)
//!     ↓
//! Executor (with context available for RLS policy evaluation)
//! ```
//!
//! # RLS Integration
//!
//! The `SecurityContext` is passed to `RLSPolicy::evaluate()` to determine what
//! rows a user can access. Policies are compiled into schema.compiled.json
//! and evaluated at runtime with the `SecurityContext`.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::{
    security::{ActorType, AuthenticatedUser, derive_actor},
    types::{TenantId, UserId},
};

/// Extract the user's roles from the (signature-verified) JWT custom claims.
///
/// Reads, in order, the scalar `role` claim, the `roles` array, and the
/// `fraiseql_roles` array, then sorts and de-duplicates. This mirrors the
/// claim names honoured by `fraiseql_auth::operation_rbac` so the GraphQL
/// `requires_role` gate and the observer-admin RBAC engine agree on what a
/// token grants. Without this, `SecurityContext.roles` was always empty and
/// every `requires_role` operation was unreachable over HTTP (#503).
fn roles_from_claims(extra_claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
    let mut roles = Vec::new();

    if let Some(serde_json::Value::String(role)) = extra_claims.get("role") {
        roles.push(role.clone());
    }

    for key in ["roles", "fraiseql_roles"] {
        if let Some(serde_json::Value::Array(values)) = extra_claims.get(key) {
            roles.extend(values.iter().filter_map(|v| match v {
                serde_json::Value::String(role) => Some(role.clone()),
                _ => None,
            }));
        }
    }

    roles.sort();
    roles.dedup();
    roles
}

/// What a transport should do after an enrichment attempt (#1336).
///
/// Lives here rather than in `fraiseql-server` because a third crate needs it:
/// `fraiseql-arrow`'s Flight transport resolves through the [`IdentityEnricher`] seam
/// below and must answer a denial exactly as the other six transports do (#1349).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnrichmentOutcome {
    /// Identity resolved and merged — continue to dispatch.
    Proceed,
    /// Permanent denial — fail closed (403 / `PERMISSION_DENIED`) before any data query.
    Denied,
    /// Transient resolver failure — fail the request (503 / `UNAVAILABLE`), never fall
    /// through to an unscoped query.
    Unavailable,
}

impl EnrichmentOutcome {
    /// The outward body for a denial. Generic by design: the precise reason is logged
    /// server-side, never surfaced, so the response cannot be used as an actor-table
    /// existence oracle. One constant rather than a literal per transport, because a
    /// transport that phrased it differently would leak the difference between "unknown
    /// subject" and "denied subject".
    pub const DENIED_MESSAGE: &'static str = "Access denied";
    /// The outward body for a transient resolver failure — distinct from a denial,
    /// because a client may retry this one and must not retry the other.
    pub const UNAVAILABLE_MESSAGE: &'static str = "Identity resolution temporarily unavailable";
}

/// An owned, `Send` boxed future — the object-safe async return used instead of a new
/// `async_trait`, keeping the dyn-dispatch ratchet flat (ADR-0016 §2.2).
pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;

/// Resolve a request subject's database identity and merge it into the context (#1349).
///
/// The implementation lives in `fraiseql-server` — it needs `sqlx` and the unscoped
/// enrichment pool — so this is the seam a crate that cannot depend on the server calls.
/// It exists for exactly one caller today: `fraiseql-arrow`'s Flight handlers, which
/// build their own principal and had no way to resolve it.
///
/// Transports inside `fraiseql-server` call `identity::resolve_request_identity`
/// directly rather than through this trait; both run the same resolver, so a denial
/// means the same thing on every door.
pub trait IdentityEnricher: Send + Sync {
    /// Resolve `ctx`'s identity, merging the resolved fields on success.
    ///
    /// All-or-nothing: on a denial or a transient failure nothing is merged and the
    /// caller must stop the request. On success the context is marked
    /// [`EnrichmentMark::Resolved`], which is what keeps the engine's backstop from
    /// refusing it downstream.
    fn enrich<'a>(&'a self, ctx: &'a mut SecurityContext) -> BoxFuture<'a, EnrichmentOutcome>;
}

/// How a principal satisfied the enrichment requirement (#1336).
///
/// The contract `[identity.enrichment]` documents is "every authenticated
/// request resolves and fail-closes". Enforcing it needs the engine to tell an
/// *enriched* principal from one that never met a resolver — and the transports
/// that produce principals are not all in one crate, so the answer rides on the
/// context rather than on a per-deployment flag the producer might not see.
///
/// There is deliberately **no** `NotResolved` variant: the absence of a mark is
/// that state. A variant would be settable, and anything settable eventually
/// gets set by the code path that should have refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnrichmentMark {
    /// The resolver ran and the identity resolved — or the principal already
    /// carried server-injected `fraiseql.enriched.*` fields, which stand in lieu
    /// of a resolve (ADR-0018 decision 5).
    Resolved,
    /// A principal with no subject to resolve: the server acting as itself
    /// (`system_job`). Set at exactly one construction site, never as a fallback
    /// for "no resolver was available" — that case must fail closed.
    Exempt,
}

impl EnrichmentMark {
    const EXEMPT: &'static str = "exempt";
    const RESOLVED: &'static str = "resolved";

    /// The wire token stored in the context attribute.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Resolved => Self::RESOLVED,
            Self::Exempt => Self::EXEMPT,
        }
    }
}

/// Security context for authorization evaluation.
///
/// Carries information about the authenticated user and their permissions
/// throughout the request lifecycle.
///
/// # Fields
///
/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
/// - `request_id`: Correlation ID for audit logging and tracing
/// - `ip_address`: Client IP address for geolocation and fraud detection
/// - `authenticated_at`: When the JWT was issued
/// - `expires_at`: When the JWT expires
/// - `issuer`: Token issuer for multi-issuer systems
/// - `audience`: Token audience for validation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityContext {
    /// User ID (from JWT 'sub' claim)
    pub user_id: UserId,

    /// User's roles (e.g., `["admin", "moderator"]`)
    ///
    /// Extracted from JWT 'roles' claim or derived from other claims.
    /// Used for role-based access control (RBAC) decisions.
    pub roles: Vec<String>,

    /// Tenant/organization ID (for multi-tenancy)
    ///
    /// When present, RLS policies can enforce tenant isolation.
    /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
    pub tenant_id: Option<TenantId>,

    /// OAuth/permission scopes
    ///
    /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
    /// Examples:
    /// - `read:user`
    /// - `write:post`
    /// - `read:User.email`
    /// - `admin:*`
    ///
    /// Extracted from JWT 'scope' claim.
    pub scopes: Vec<String>,

    /// Custom attributes from JWT claims
    ///
    /// Arbitrary key-value pairs from JWT payload.
    /// Examples: "department", "region", "tier", "country"
    ///
    /// Used by custom RLS policies that need domain-specific attributes.
    pub attributes: HashMap<String, serde_json::Value>,

    /// Request correlation ID for audit trails
    ///
    /// Extracted from X-Request-Id header or generated.
    /// Used for tracing and audit logging across services.
    pub request_id: String,

    /// Client IP address
    ///
    /// Extracted from X-Forwarded-For or connection socket.
    /// Used for geolocation and fraud detection in RLS policies.
    pub ip_address: Option<String>,

    /// When the JWT was issued
    pub authenticated_at: DateTime<Utc>,

    /// When the JWT expires
    pub expires_at: DateTime<Utc>,

    /// Token issuer (for multi-issuer systems)
    pub issuer: Option<String>,

    /// Token audience (for audience validation)
    pub audience: Option<String>,

    /// Normalised email address from the JWT `email` claim.
    ///
    /// Available as `jwt:email` in session variable mappings for RLS policies.
    pub email: Option<String>,

    /// Normalised display name from the JWT `name` claim.
    ///
    /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
    pub display_name: Option<String>,
}

impl SecurityContext {
    /// Attribute key under which the delegated user's UUID is carried (string
    /// form), for an agent request acting on behalf of a human. Read back via
    /// [`acting_for`](Self::acting_for) (#390).
    pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
    /// Attribute key under which the request's [`ActorType`] is carried (the
    /// `snake_case` token), derived at [`from_user`](Self::from_user) and read
    /// back via [`actor_type`](Self::actor_type) (#390).
    pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
    /// Attribute key recording that this principal passed the enrichment seam
    /// (#1336). Absent means "no transport resolved this principal", which is
    /// the fail-closed case the engine refuses when the schema declares an
    /// enrichment consumer — see [`EnrichmentMark`].
    pub const ENRICHMENT_ATTRIBUTE: &'static str = "fraiseql.enrichment";
    /// Attribute key under which the originating request's full W3C trace context
    /// is carried (a JSON object), used to populate the change-log `trace_context`
    /// JSONB column (#375).
    pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
    /// Attribute key under which the originating request's W3C trace id is
    /// stamped. Set by the server's request pipeline from the inbound
    /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
    pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
    /// Attribute key under which the ingress transport is carried (#376): the
    /// door this request came through, e.g. `"mcp"`. Set by a transport that
    /// declares itself (via [`with_transport`](Self::with_transport)) — never
    /// from a client field: the `fraiseql.` namespace is stripped from token
    /// claims by the extractor, so a caller cannot forge it. Recorded into the
    /// change-log row's `extra_metadata.transport`.
    pub const TRANSPORT_ATTRIBUTE: &'static str = "fraiseql.transport";

    /// Create a security context from an authenticated user and request metadata.
    ///
    /// # Arguments
    ///
    /// * `user` - Authenticated user from JWT validation
    /// * `request_id` - Correlation ID for this request
    ///
    /// # Example
    ///
    /// ```no_run
    /// // Requires: a live AuthenticatedUser from JWT validation.
    /// // See: tests/integration/ for runnable examples.
    /// # use fraiseql_core::security::SecurityContext;
    /// # use fraiseql_core::security::AuthenticatedUser;
    /// # let authenticated_user: AuthenticatedUser = panic!("example");
    /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
    /// ```
    #[must_use]
    pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
        // Classify the actor from the (signature-verified) JWT material at
        // construction time, so every auth path (OIDC, HS256, gRPC, MCP) that
        // builds a context from a user gets it. The API-key path overrides to
        // ServiceAccount at its own construction site (#390).
        let (actor_type, acting_for) =
            derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
        SecurityContext {
            user_id: user.user_id.clone(),
            roles: roles_from_claims(&user.extra_claims),
            tenant_id: None,
            scopes: user.scopes.clone(),
            attributes: HashMap::new(),
            request_id,
            ip_address: None,
            authenticated_at: Utc::now(),
            expires_at: user.expires_at,
            // The issuer that vouched for the token (#1400); the validator forwards
            // `iss` among the claims it verified.
            issuer: user.extra_claims.get("iss").and_then(|v| v.as_str()).map(String::from),
            audience: None,
            email: user.email.clone(),
            display_name: user.display_name.clone(),
        }
        .with_actor_type(actor_type)
        .with_acting_for(acting_for)
    }

    /// Construct the context for an internal scheduled / system job (#573 D6).
    ///
    /// Unlike [`from_user`](Self::from_user), this carries **no JWT and no request**
    /// — it is the server acting *as itself* for background work: a scheduled
    /// [`Source`](crate::schema::SourceDefinition), and (in future) `after:ingest`
    /// handlers and saga steps. It is the first and, at introduction, only
    /// construction of [`ActorType::SystemJob`].
    ///
    /// Its authority is **exactly** the `roles` + `scopes` the caller grants (the
    /// source's `run_as` ceiling). With neither, it is **fail-closed**: no roles and
    /// no scopes mean every RBAC / field-authz check denies and — with no `tenant` —
    /// tenant-scoped RLS admits nothing. A source therefore cannot write until an
    /// operator grants it a ceiling.
    ///
    /// `tenant` scopes writes for a single-tenant or global source (`None` = global
    /// / NULL tenant); a multi-tenant source leaves it `None` and re-scopes each
    /// write per message via [`with_tenant`](Self::with_tenant). `job_id` names the
    /// job (the source name) and becomes the `system_job:<id>` principal for the
    /// audit envelope; `request_id` correlates a single firing.
    ///
    /// The [`ActorType`] is *recorded* for audit, never an authorization input — the
    /// authority comes solely from `roles`/`scopes`.
    #[must_use]
    pub fn system_job(
        job_id: impl Into<String>,
        request_id: impl Into<String>,
        roles: Vec<String>,
        scopes: Vec<String>,
        tenant: Option<TenantId>,
    ) -> Self {
        let mut ctx = Self::principal_from_ceiling(
            format!("system_job:{}", job_id.into()),
            request_id,
            roles,
            scopes,
            tenant,
            ActorType::SystemJob,
        );
        // #1336: the server acting as itself has no subject a resolver could look
        // up, so it is exempt from the enrichment requirement — stated here, at the
        // one site that mints such a principal, rather than inferred downstream from
        // "nothing resolved it".
        ctx.mark_enrichment(EnrichmentMark::Exempt);
        ctx
    }

    /// Mint a **service-account** context from a `run_as` ceiling (ADR-0018).
    ///
    /// A service account is an *external* credentialed caller — a daemon or service
    /// authenticated by a static secret — as opposed to an internal
    /// [`system_job`](Self::system_job) firing. Authority is **exactly** the `run_as`
    /// ceiling (`roles`/`scopes`/`tenant`); an absent/empty ceiling ⇒ no authority (RLS
    /// and field-authz deny its writes). The identity is `service_account:<name>` and the
    /// [`ActorType`] is [`ServiceAccount`](ActorType::ServiceAccount) — recorded for audit
    /// (`tb_entity_change_log.actor_type`), never an authorization input.
    #[must_use]
    pub fn service_account(
        name: impl Into<String>,
        request_id: impl Into<String>,
        roles: Vec<String>,
        scopes: Vec<String>,
        tenant: Option<TenantId>,
    ) -> Self {
        Self::principal_from_ceiling(
            format!("service_account:{}", name.into()),
            request_id,
            roles,
            scopes,
            tenant,
            ActorType::ServiceAccount,
        )
    }

    /// Shared body for a machine principal minted from a `run_as` ceiling. The authority
    /// is exactly `roles`/`scopes`/`tenant`; `user_id` + `actor_type` carry the
    /// provenance (audit-only). Wrapped by [`system_job`](Self::system_job) and
    /// [`service_account`](Self::service_account).
    fn principal_from_ceiling(
        user_id: String,
        request_id: impl Into<String>,
        roles: Vec<String>,
        scopes: Vec<String>,
        tenant: Option<TenantId>,
        actor_type: ActorType,
    ) -> Self {
        let now = Utc::now();
        SecurityContext {
            user_id: UserId(user_id),
            roles,
            tenant_id: tenant,
            scopes,
            attributes: HashMap::new(),
            request_id: request_id.into(),
            ip_address: None,
            authenticated_at: now,
            // A machine principal's identity is minted fresh per request/firing and does
            // not outlive it; a generous window keeps any TTL check from tripping
            // mid-run (mirrors the live host's default context).
            expires_at: now + chrono::Duration::hours(24),
            issuer: None,
            audience: None,
            email: None,
            display_name: None,
        }
        .with_actor_type(actor_type)
    }

    /// Check if the user has a specific role.
    ///
    /// # Arguments
    ///
    /// * `role` - Role name to check (e.g., "admin", "moderator")
    ///
    /// # Returns
    ///
    /// `true` if the user has the specified role, `false` otherwise.
    #[must_use]
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }

    /// Check if the user has a specific scope.
    ///
    /// Supports wildcards: `admin:*` matches any admin scope.
    ///
    /// # Arguments
    ///
    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
    ///
    /// # Returns
    ///
    /// `true` if the user has the specified scope, `false` otherwise.
    #[must_use]
    pub fn has_scope(&self, scope: &str) -> bool {
        self.scopes.iter().any(|s| {
            if s == scope {
                return true;
            }
            // Support wildcard matching: "admin:*" matches "admin:read"
            if s.ends_with(':') {
                scope.starts_with(s)
            } else if s.ends_with('*') {
                let prefix = &s[..s.len() - 1];
                scope.starts_with(prefix)
            } else {
                false
            }
        })
    }

    /// Get a custom attribute from the JWT claims.
    ///
    /// # Arguments
    ///
    /// * `key` - Attribute name
    ///
    /// # Returns
    ///
    /// The attribute value if present, `None` otherwise.
    #[must_use]
    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
        self.attributes.get(key)
    }

    /// The originating request's W3C trace id, when the server stamped one from
    /// the inbound `traceparent` header.
    ///
    /// Used to populate the change-log `trace_id` column so an outbox row links
    /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
    /// the investigation handle. `None` when the request carried no trace context.
    #[must_use]
    pub fn trace_id(&self) -> Option<&str> {
        self.attributes
            .get(Self::TRACE_ID_ATTRIBUTE)
            .and_then(serde_json::Value::as_str)
    }

    /// Stamp the originating request's W3C trace id onto the context (carried in
    /// `attributes`). Read back via [`trace_id`](Self::trace_id).
    #[must_use]
    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
        self.attributes.insert(
            Self::TRACE_ID_ATTRIBUTE.to_string(),
            serde_json::Value::String(trace_id.into()),
        );
        self
    }

    /// The originating request's full W3C trace context (a JSON object), when the
    /// server stamped one from the inbound `traceparent`/`tracestate` headers.
    ///
    /// Used to populate the change-log `trace_context` JSONB column so an outbox
    /// row carries enough to re-propagate the distributed trace (#375). `None` when
    /// the request carried no valid trace context.
    #[must_use]
    pub fn trace_context(&self) -> Option<&serde_json::Value> {
        self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
    }

    /// Stamp the originating request's full W3C trace context (a JSON object) onto
    /// the context (carried in `attributes`). Read back via
    /// [`trace_context`](Self::trace_context).
    #[must_use]
    pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
        self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
        self
    }

    /// The request's actor classification (#390). Derived at
    /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
    /// context built directly without derivation).
    #[must_use]
    pub fn actor_type(&self) -> ActorType {
        self.attributes
            .get(Self::ACTOR_TYPE_ATTRIBUTE)
            .and_then(serde_json::Value::as_str)
            .and_then(ActorType::from_token)
            .unwrap_or_default()
    }

    /// Stamp the request's [`ActorType`] onto the context (carried in
    /// `attributes`). Read back via [`actor_type`](Self::actor_type).
    #[must_use]
    pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
        self.attributes.insert(
            Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
            serde_json::Value::String(actor_type.as_str().to_string()),
        );
        self
    }

    /// The delegated user (the human a delegated agent acts for), when the request
    /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
    /// or when the underlying subject was not UUID-shaped.
    #[must_use]
    pub fn acting_for(&self) -> Option<Uuid> {
        self.attributes
            .get(Self::ACTING_FOR_ATTRIBUTE)
            .and_then(serde_json::Value::as_str)
            .and_then(|s| Uuid::parse_str(s).ok())
    }

    /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
    /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
    #[must_use]
    pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
        match acting_for {
            Some(uuid) => {
                self.attributes.insert(
                    Self::ACTING_FOR_ATTRIBUTE.to_string(),
                    serde_json::Value::String(uuid.to_string()),
                );
            },
            None => {
                self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
            },
        }
        self
    }

    /// The ingress transport this request declared (#376), e.g. `"mcp"`.
    /// `None` for transports that do not stamp one (today: everything except
    /// MCP), and for contexts built without a server pipeline.
    #[must_use]
    pub fn transport(&self) -> Option<&str> {
        self.attributes
            .get(Self::TRANSPORT_ATTRIBUTE)
            .and_then(serde_json::Value::as_str)
    }

    /// Stamp the ingress transport onto the context (carried in `attributes`,
    /// forge-safe: the extractor strips the `fraiseql.` namespace from token
    /// claims). Read back via [`transport`](Self::transport) and recorded into
    /// the change-log's `extra_metadata.transport` by the mutation runner.
    #[must_use]
    pub fn with_transport(mut self, transport: &str) -> Self {
        self.attributes.insert(
            Self::TRANSPORT_ATTRIBUTE.to_string(),
            serde_json::Value::String(transport.to_string()),
        );
        self
    }

    /// How this principal satisfied the enrichment requirement (#1336), or
    /// `None` when it never passed the seam.
    ///
    /// `None` is the fail-closed state, and it is the *default* precisely
    /// because the defect this guards against is a transport that builds a
    /// principal and dispatches it without resolving. A context that skipped
    /// the seam cannot claim to have passed it by saying nothing.
    #[must_use]
    pub fn enrichment_mark(&self) -> Option<EnrichmentMark> {
        match self
            .attributes
            .get(Self::ENRICHMENT_ATTRIBUTE)
            .and_then(serde_json::Value::as_str)
        {
            Some(EnrichmentMark::RESOLVED) => Some(EnrichmentMark::Resolved),
            Some(EnrichmentMark::EXEMPT) => Some(EnrichmentMark::Exempt),
            _ => None,
        }
    }

    /// Record that this principal passed the enrichment seam.
    ///
    /// Carried in `attributes` under the reserved `fraiseql.` namespace, which
    /// the token-claim builder strips — so a caller cannot mint this mark from a
    /// JWT claim. Only server code on the request path sets it.
    pub fn mark_enrichment(&mut self, mark: EnrichmentMark) {
        self.attributes.insert(
            Self::ENRICHMENT_ATTRIBUTE.to_string(),
            serde_json::Value::String(mark.as_str().to_string()),
        );
    }

    /// Check if the token has expired.
    ///
    /// # Returns
    ///
    /// `true` if the JWT has expired, `false` otherwise.
    #[must_use]
    pub fn is_expired(&self) -> bool {
        self.expires_at <= Utc::now()
    }

    /// Get time until expiry in seconds.
    ///
    /// # Returns
    ///
    /// Seconds until JWT expiry, negative if already expired.
    #[must_use]
    pub fn ttl_secs(&self) -> i64 {
        (self.expires_at - Utc::now()).num_seconds()
    }

    /// Check if the user is an admin.
    ///
    /// # Returns
    ///
    /// `true` if the user has the "admin" role, `false` otherwise.
    #[must_use]
    pub fn is_admin(&self) -> bool {
        self.has_role("admin")
    }

    /// Check if the context has a tenant ID (multi-tenancy enabled).
    ///
    /// # Returns
    ///
    /// `true` if `tenant_id` is present, `false` otherwise.
    #[must_use]
    pub const fn is_multi_tenant(&self) -> bool {
        self.tenant_id.is_some()
    }

    /// Set or override a role (for testing or runtime role modification).
    #[must_use]
    pub fn with_role(mut self, role: String) -> Self {
        self.roles.push(role);
        self
    }

    /// Set or override scopes (for testing or runtime permission modification).
    #[must_use]
    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
        self.scopes = scopes;
        self
    }

    /// Set tenant ID (for multi-tenancy).
    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
        self.tenant_id = Some(tenant_id.into());
        self
    }

    /// Set a custom attribute (for testing or runtime attribute addition).
    #[must_use]
    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
        self.attributes.insert(key, value);
        self
    }

    /// The value of JWT claim `claim` for this request.
    ///
    /// The one answer to "what is claim X" (#1388). Inject parameters (`jwt:<claim>`),
    /// session variables (`source = "jwt"`) and identity-enrichment bindings all read
    /// claims through this, so a claim name means the same thing on every path. Three
    /// copies used to answer differently, and two of them answered both `tenant_id` and
    /// `org_id` with [`Self::tenant_id`] whatever the schema's tenant claim was, so a
    /// token carrying both scoped a query by the wrong one.
    ///
    /// Resolution, first match wins:
    /// 1. the raw claim, from [`Self::attributes`] (every non-reserved claim lands there);
    /// 2. a **registered** claim the validator lifts out of the claim map into its own field, so it
    ///    never reaches `attributes`: `sub` (also spelled `user_id`) → [`Self::user_id`], `email` →
    ///    [`Self::email`], `name` (also `display_name`) → [`Self::display_name`], `iss` →
    ///    [`Self::issuer`], `scope` → [`Self::scopes`] as the space-separated string an OAuth token
    ///    carries (#1417; absent when no scope is granted);
    /// 3. `tenant_claim` — the schema's configured tenant claim, and only that name — →
    ///    [`Self::tenant_id`].
    ///
    /// Step 3 is what lets a principal the server mints itself (a service account, a
    /// system job, a source's per-message tenant, an admin preview) answer row-mode
    /// tenancy's `jwt:<tenant_claim>` with the tenant it was assigned. For a principal
    /// built from a token it changes nothing: its tenant *is* that claim's value (see
    /// the server's context builder), so step 1 already answered. No other name reads
    /// the tenant.
    #[must_use]
    pub fn jwt_claim(&self, claim: &str, tenant_claim: &str) -> Option<serde_json::Value> {
        if let Some(value) = self.attributes.get(claim) {
            return Some(value.clone());
        }
        let field = match claim {
            "sub" | "user_id" => Some(self.user_id.0.clone()),
            "email" => self.email.clone(),
            "name" | "display_name" => self.display_name.clone(),
            "iss" => self.issuer.clone(),
            "scope" => (!self.scopes.is_empty()).then(|| self.scopes.join(" ")),
            _ if claim == tenant_claim => self.tenant_id.as_ref().map(|t| t.0.clone()),
            _ => None,
        };
        field.map(serde_json::Value::String)
    }

    /// Check if user can access a field based on role definitions.
    ///
    /// Takes a required scope and checks if any of the user's roles grant that scope.
    ///
    /// # Arguments
    ///
    /// * `security_config` - Security config from compiled schema with role definitions
    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
    ///
    /// # Returns
    ///
    /// `true` if the user's roles grant the required scope, `false` otherwise.
    ///
    /// # `default_role`
    ///
    /// A principal carrying **no role at all** falls back to
    /// `SecurityConfig::default_role` (#894). The condition is an *empty* role set,
    /// not a failed lookup: a principal that has been assigned roles has been given
    /// its authority, and quietly widening it when those roles fall short would make
    /// `default_role` a floor under every principal rather than a default.
    ///
    /// The fallback reaches authenticated principals only, because a
    /// `SecurityContext` exists only for one — an anonymous request has no principal
    /// and is classified by `apply_anonymous_field_rbac_filtering`, which never calls
    /// this method. That is deliberate: extending the fallback to anonymous callers
    /// would re-open #743's privilege inversion from the other side, handing every
    /// `requires_scope` field to callers who presented no credential.
    ///
    /// # Example
    ///
    /// ```no_run
    /// // Requires: a SecurityConfig from a compiled schema.
    /// // See: tests/integration/ for runnable examples.
    /// # use fraiseql_core::security::SecurityContext;
    /// # use fraiseql_core::schema::SecurityConfig;
    /// # let context: SecurityContext = panic!("example");
    /// # let config: SecurityConfig = panic!("example");
    /// let can_access = context.can_access_scope(&config, "read:User.email");
    /// ```
    #[must_use]
    pub fn can_access_scope(
        &self,
        security_config: &crate::schema::SecurityConfig,
        required_scope: &str,
    ) -> bool {
        // Check if any of user's roles grant this scope
        if self
            .roles
            .iter()
            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
        {
            return true;
        }

        // #894: no role at all → the schema's declared default, if it declared one.
        if self.roles.is_empty() {
            if let Some(default_role) = security_config.default_role.as_deref() {
                return security_config.role_has_scope(default_role, required_scope);
            }
        }

        false
    }
}

impl std::fmt::Display for SecurityContext {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
            self.user_id,
            self.roles,
            self.scopes.len(),
            self.tenant_id
        )
    }
}