Skip to main content

fraiseql_core/security/
security_context.rs

1//! Security context for runtime authorization
2//!
3//! This module provides the `SecurityContext` struct that flows through the executor,
4//! carrying information about the authenticated user and their permissions.
5//!
6//! The security context is extracted from:
7//! - JWT claims (`user_id` from 'sub', roles from 'roles', etc.)
8//! - HTTP headers (`request_id`, `tenant_id`, etc.)
9//! - Configuration (OAuth provider, scopes, etc.)
10//!
11//! # Architecture
12//!
13//! ```text
14//! HTTP Request with Authorization header
15//!     ↓
16//! AuthMiddleware → AuthenticatedUser
17//!     ↓
18//! SecurityContext (created from AuthenticatedUser + request metadata)
19//!     ↓
20//! Executor (with context available for RLS policy evaluation)
21//! ```
22//!
23//! # RLS Integration
24//!
25//! The `SecurityContext` is passed to `RLSPolicy::evaluate()` to determine what
26//! rows a user can access. Policies are compiled into schema.compiled.json
27//! and evaluated at runtime with the `SecurityContext`.
28
29use std::collections::HashMap;
30
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use uuid::Uuid;
34
35use crate::{
36    security::{ActorType, AuthenticatedUser, derive_actor},
37    types::{TenantId, UserId},
38};
39
40/// Extract the user's roles from the (signature-verified) JWT custom claims.
41///
42/// Reads, in order, the scalar `role` claim, the `roles` array, and the
43/// `fraiseql_roles` array, then sorts and de-duplicates. This mirrors the
44/// claim names honoured by `fraiseql_auth::operation_rbac` so the GraphQL
45/// `requires_role` gate and the observer-admin RBAC engine agree on what a
46/// token grants. Without this, `SecurityContext.roles` was always empty and
47/// every `requires_role` operation was unreachable over HTTP (#503).
48fn roles_from_claims(extra_claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
49    let mut roles = Vec::new();
50
51    if let Some(serde_json::Value::String(role)) = extra_claims.get("role") {
52        roles.push(role.clone());
53    }
54
55    for key in ["roles", "fraiseql_roles"] {
56        if let Some(serde_json::Value::Array(values)) = extra_claims.get(key) {
57            roles.extend(values.iter().filter_map(|v| match v {
58                serde_json::Value::String(role) => Some(role.clone()),
59                _ => None,
60            }));
61        }
62    }
63
64    roles.sort();
65    roles.dedup();
66    roles
67}
68
69/// What a transport should do after an enrichment attempt (#1336).
70///
71/// Lives here rather than in `fraiseql-server` because a third crate needs it:
72/// `fraiseql-arrow`'s Flight transport resolves through the [`IdentityEnricher`] seam
73/// below and must answer a denial exactly as the other six transports do (#1349).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum EnrichmentOutcome {
76    /// Identity resolved and merged — continue to dispatch.
77    Proceed,
78    /// Permanent denial — fail closed (403 / `PERMISSION_DENIED`) before any data query.
79    Denied,
80    /// Transient resolver failure — fail the request (503 / `UNAVAILABLE`), never fall
81    /// through to an unscoped query.
82    Unavailable,
83}
84
85impl EnrichmentOutcome {
86    /// The outward body for a denial. Generic by design: the precise reason is logged
87    /// server-side, never surfaced, so the response cannot be used as an actor-table
88    /// existence oracle. One constant rather than a literal per transport, because a
89    /// transport that phrased it differently would leak the difference between "unknown
90    /// subject" and "denied subject".
91    pub const DENIED_MESSAGE: &'static str = "Access denied";
92    /// The outward body for a transient resolver failure — distinct from a denial,
93    /// because a client may retry this one and must not retry the other.
94    pub const UNAVAILABLE_MESSAGE: &'static str = "Identity resolution temporarily unavailable";
95}
96
97/// An owned, `Send` boxed future — the object-safe async return used instead of a new
98/// `async_trait`, keeping the dyn-dispatch ratchet flat (ADR-0016 §2.2).
99pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
100
101/// Resolve a request subject's database identity and merge it into the context (#1349).
102///
103/// The implementation lives in `fraiseql-server` — it needs `sqlx` and the unscoped
104/// enrichment pool — so this is the seam a crate that cannot depend on the server calls.
105/// It exists for exactly one caller today: `fraiseql-arrow`'s Flight handlers, which
106/// build their own principal and had no way to resolve it.
107///
108/// Transports inside `fraiseql-server` call `identity::resolve_request_identity`
109/// directly rather than through this trait; both run the same resolver, so a denial
110/// means the same thing on every door.
111pub trait IdentityEnricher: Send + Sync {
112    /// Resolve `ctx`'s identity, merging the resolved fields on success.
113    ///
114    /// All-or-nothing: on a denial or a transient failure nothing is merged and the
115    /// caller must stop the request. On success the context is marked
116    /// [`EnrichmentMark::Resolved`], which is what keeps the engine's backstop from
117    /// refusing it downstream.
118    fn enrich<'a>(&'a self, ctx: &'a mut SecurityContext) -> BoxFuture<'a, EnrichmentOutcome>;
119}
120
121/// How a principal satisfied the enrichment requirement (#1336).
122///
123/// The contract `[identity.enrichment]` documents is "every authenticated
124/// request resolves and fail-closes". Enforcing it needs the engine to tell an
125/// *enriched* principal from one that never met a resolver — and the transports
126/// that produce principals are not all in one crate, so the answer rides on the
127/// context rather than on a per-deployment flag the producer might not see.
128///
129/// There is deliberately **no** `NotResolved` variant: the absence of a mark is
130/// that state. A variant would be settable, and anything settable eventually
131/// gets set by the code path that should have refused.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum EnrichmentMark {
134    /// The resolver ran and the identity resolved — or the principal already
135    /// carried server-injected `fraiseql.enriched.*` fields, which stand in lieu
136    /// of a resolve (ADR-0018 decision 5).
137    Resolved,
138    /// A principal with no subject to resolve: the server acting as itself
139    /// (`system_job`). Set at exactly one construction site, never as a fallback
140    /// for "no resolver was available" — that case must fail closed.
141    Exempt,
142}
143
144impl EnrichmentMark {
145    const EXEMPT: &'static str = "exempt";
146    const RESOLVED: &'static str = "resolved";
147
148    /// The wire token stored in the context attribute.
149    #[must_use]
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::Resolved => Self::RESOLVED,
153            Self::Exempt => Self::EXEMPT,
154        }
155    }
156}
157
158/// Security context for authorization evaluation.
159///
160/// Carries information about the authenticated user and their permissions
161/// throughout the request lifecycle.
162///
163/// # Fields
164///
165/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
166/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
167/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
168/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
169/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
170/// - `request_id`: Correlation ID for audit logging and tracing
171/// - `ip_address`: Client IP address for geolocation and fraud detection
172/// - `authenticated_at`: When the JWT was issued
173/// - `expires_at`: When the JWT expires
174/// - `issuer`: Token issuer for multi-issuer systems
175/// - `audience`: Token audience for validation
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct SecurityContext {
178    /// User ID (from JWT 'sub' claim)
179    pub user_id: UserId,
180
181    /// User's roles (e.g., `["admin", "moderator"]`)
182    ///
183    /// Extracted from JWT 'roles' claim or derived from other claims.
184    /// Used for role-based access control (RBAC) decisions.
185    pub roles: Vec<String>,
186
187    /// Tenant/organization ID (for multi-tenancy)
188    ///
189    /// When present, RLS policies can enforce tenant isolation.
190    /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
191    pub tenant_id: Option<TenantId>,
192
193    /// OAuth/permission scopes
194    ///
195    /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
196    /// Examples:
197    /// - `read:user`
198    /// - `write:post`
199    /// - `read:User.email`
200    /// - `admin:*`
201    ///
202    /// Extracted from JWT 'scope' claim.
203    pub scopes: Vec<String>,
204
205    /// Custom attributes from JWT claims
206    ///
207    /// Arbitrary key-value pairs from JWT payload.
208    /// Examples: "department", "region", "tier", "country"
209    ///
210    /// Used by custom RLS policies that need domain-specific attributes.
211    pub attributes: HashMap<String, serde_json::Value>,
212
213    /// Request correlation ID for audit trails
214    ///
215    /// Extracted from X-Request-Id header or generated.
216    /// Used for tracing and audit logging across services.
217    pub request_id: String,
218
219    /// Client IP address
220    ///
221    /// Extracted from X-Forwarded-For or connection socket.
222    /// Used for geolocation and fraud detection in RLS policies.
223    pub ip_address: Option<String>,
224
225    /// When the JWT was issued
226    pub authenticated_at: DateTime<Utc>,
227
228    /// When the JWT expires
229    pub expires_at: DateTime<Utc>,
230
231    /// Token issuer (for multi-issuer systems)
232    pub issuer: Option<String>,
233
234    /// Token audience (for audience validation)
235    pub audience: Option<String>,
236
237    /// Normalised email address from the JWT `email` claim.
238    ///
239    /// Available as `jwt:email` in session variable mappings for RLS policies.
240    pub email: Option<String>,
241
242    /// Normalised display name from the JWT `name` claim.
243    ///
244    /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
245    pub display_name: Option<String>,
246}
247
248impl SecurityContext {
249    /// Attribute key under which the delegated user's UUID is carried (string
250    /// form), for an agent request acting on behalf of a human. Read back via
251    /// [`acting_for`](Self::acting_for) (#390).
252    pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
253    /// Attribute key under which the request's [`ActorType`] is carried (the
254    /// `snake_case` token), derived at [`from_user`](Self::from_user) and read
255    /// back via [`actor_type`](Self::actor_type) (#390).
256    pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
257    /// Attribute key recording that this principal passed the enrichment seam
258    /// (#1336). Absent means "no transport resolved this principal", which is
259    /// the fail-closed case the engine refuses when the schema declares an
260    /// enrichment consumer — see [`EnrichmentMark`].
261    pub const ENRICHMENT_ATTRIBUTE: &'static str = "fraiseql.enrichment";
262    /// Attribute key under which the originating request's full W3C trace context
263    /// is carried (a JSON object), used to populate the change-log `trace_context`
264    /// JSONB column (#375).
265    pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
266    /// Attribute key under which the originating request's W3C trace id is
267    /// stamped. Set by the server's request pipeline from the inbound
268    /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
269    pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
270    /// Attribute key under which the ingress transport is carried (#376): the
271    /// door this request came through, e.g. `"mcp"`. Set by a transport that
272    /// declares itself (via [`with_transport`](Self::with_transport)) — never
273    /// from a client field: the `fraiseql.` namespace is stripped from token
274    /// claims by the extractor, so a caller cannot forge it. Recorded into the
275    /// change-log row's `extra_metadata.transport`.
276    pub const TRANSPORT_ATTRIBUTE: &'static str = "fraiseql.transport";
277
278    /// Create a security context from an authenticated user and request metadata.
279    ///
280    /// # Arguments
281    ///
282    /// * `user` - Authenticated user from JWT validation
283    /// * `request_id` - Correlation ID for this request
284    ///
285    /// # Example
286    ///
287    /// ```no_run
288    /// // Requires: a live AuthenticatedUser from JWT validation.
289    /// // See: tests/integration/ for runnable examples.
290    /// # use fraiseql_core::security::SecurityContext;
291    /// # use fraiseql_core::security::AuthenticatedUser;
292    /// # let authenticated_user: AuthenticatedUser = panic!("example");
293    /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
294    /// ```
295    #[must_use]
296    pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
297        // Classify the actor from the (signature-verified) JWT material at
298        // construction time, so every auth path (OIDC, HS256, gRPC, MCP) that
299        // builds a context from a user gets it. The API-key path overrides to
300        // ServiceAccount at its own construction site (#390).
301        let (actor_type, acting_for) =
302            derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
303        SecurityContext {
304            user_id: user.user_id.clone(),
305            roles: roles_from_claims(&user.extra_claims),
306            tenant_id: None,
307            scopes: user.scopes.clone(),
308            attributes: HashMap::new(),
309            request_id,
310            ip_address: None,
311            authenticated_at: Utc::now(),
312            expires_at: user.expires_at,
313            issuer: None,
314            audience: None,
315            email: user.email.clone(),
316            display_name: user.display_name.clone(),
317        }
318        .with_actor_type(actor_type)
319        .with_acting_for(acting_for)
320    }
321
322    /// Construct the context for an internal scheduled / system job (#573 D6).
323    ///
324    /// Unlike [`from_user`](Self::from_user), this carries **no JWT and no request**
325    /// — it is the server acting *as itself* for background work: a scheduled
326    /// [`Source`](crate::schema::SourceDefinition), and (in future) `after:ingest`
327    /// handlers and saga steps. It is the first and, at introduction, only
328    /// construction of [`ActorType::SystemJob`].
329    ///
330    /// Its authority is **exactly** the `roles` + `scopes` the caller grants (the
331    /// source's `run_as` ceiling). With neither, it is **fail-closed**: no roles and
332    /// no scopes mean every RBAC / field-authz check denies and — with no `tenant` —
333    /// tenant-scoped RLS admits nothing. A source therefore cannot write until an
334    /// operator grants it a ceiling.
335    ///
336    /// `tenant` scopes writes for a single-tenant or global source (`None` = global
337    /// / NULL tenant); a multi-tenant source leaves it `None` and re-scopes each
338    /// write per message via [`with_tenant`](Self::with_tenant). `job_id` names the
339    /// job (the source name) and becomes the `system_job:<id>` principal for the
340    /// audit envelope; `request_id` correlates a single firing.
341    ///
342    /// The [`ActorType`] is *recorded* for audit, never an authorization input — the
343    /// authority comes solely from `roles`/`scopes`.
344    #[must_use]
345    pub fn system_job(
346        job_id: impl Into<String>,
347        request_id: impl Into<String>,
348        roles: Vec<String>,
349        scopes: Vec<String>,
350        tenant: Option<TenantId>,
351    ) -> Self {
352        let mut ctx = Self::principal_from_ceiling(
353            format!("system_job:{}", job_id.into()),
354            request_id,
355            roles,
356            scopes,
357            tenant,
358            ActorType::SystemJob,
359        );
360        // #1336: the server acting as itself has no subject a resolver could look
361        // up, so it is exempt from the enrichment requirement — stated here, at the
362        // one site that mints such a principal, rather than inferred downstream from
363        // "nothing resolved it".
364        ctx.mark_enrichment(EnrichmentMark::Exempt);
365        ctx
366    }
367
368    /// Mint a **service-account** context from a `run_as` ceiling (ADR-0018).
369    ///
370    /// A service account is an *external* credentialed caller — a daemon or service
371    /// authenticated by a static secret — as opposed to an internal
372    /// [`system_job`](Self::system_job) firing. Authority is **exactly** the `run_as`
373    /// ceiling (`roles`/`scopes`/`tenant`); an absent/empty ceiling ⇒ no authority (RLS
374    /// and field-authz deny its writes). The identity is `service_account:<name>` and the
375    /// [`ActorType`] is [`ServiceAccount`](ActorType::ServiceAccount) — recorded for audit
376    /// (`tb_entity_change_log.actor_type`), never an authorization input.
377    #[must_use]
378    pub fn service_account(
379        name: impl Into<String>,
380        request_id: impl Into<String>,
381        roles: Vec<String>,
382        scopes: Vec<String>,
383        tenant: Option<TenantId>,
384    ) -> Self {
385        Self::principal_from_ceiling(
386            format!("service_account:{}", name.into()),
387            request_id,
388            roles,
389            scopes,
390            tenant,
391            ActorType::ServiceAccount,
392        )
393    }
394
395    /// Shared body for a machine principal minted from a `run_as` ceiling. The authority
396    /// is exactly `roles`/`scopes`/`tenant`; `user_id` + `actor_type` carry the
397    /// provenance (audit-only). Wrapped by [`system_job`](Self::system_job) and
398    /// [`service_account`](Self::service_account).
399    fn principal_from_ceiling(
400        user_id: String,
401        request_id: impl Into<String>,
402        roles: Vec<String>,
403        scopes: Vec<String>,
404        tenant: Option<TenantId>,
405        actor_type: ActorType,
406    ) -> Self {
407        let now = Utc::now();
408        SecurityContext {
409            user_id: UserId(user_id),
410            roles,
411            tenant_id: tenant,
412            scopes,
413            attributes: HashMap::new(),
414            request_id: request_id.into(),
415            ip_address: None,
416            authenticated_at: now,
417            // A machine principal's identity is minted fresh per request/firing and does
418            // not outlive it; a generous window keeps any TTL check from tripping
419            // mid-run (mirrors the live host's default context).
420            expires_at: now + chrono::Duration::hours(24),
421            issuer: None,
422            audience: None,
423            email: None,
424            display_name: None,
425        }
426        .with_actor_type(actor_type)
427    }
428
429    /// Check if the user has a specific role.
430    ///
431    /// # Arguments
432    ///
433    /// * `role` - Role name to check (e.g., "admin", "moderator")
434    ///
435    /// # Returns
436    ///
437    /// `true` if the user has the specified role, `false` otherwise.
438    #[must_use]
439    pub fn has_role(&self, role: &str) -> bool {
440        self.roles.iter().any(|r| r == role)
441    }
442
443    /// Check if the user has a specific scope.
444    ///
445    /// Supports wildcards: `admin:*` matches any admin scope.
446    ///
447    /// # Arguments
448    ///
449    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
450    ///
451    /// # Returns
452    ///
453    /// `true` if the user has the specified scope, `false` otherwise.
454    #[must_use]
455    pub fn has_scope(&self, scope: &str) -> bool {
456        self.scopes.iter().any(|s| {
457            if s == scope {
458                return true;
459            }
460            // Support wildcard matching: "admin:*" matches "admin:read"
461            if s.ends_with(':') {
462                scope.starts_with(s)
463            } else if s.ends_with('*') {
464                let prefix = &s[..s.len() - 1];
465                scope.starts_with(prefix)
466            } else {
467                false
468            }
469        })
470    }
471
472    /// Get a custom attribute from the JWT claims.
473    ///
474    /// # Arguments
475    ///
476    /// * `key` - Attribute name
477    ///
478    /// # Returns
479    ///
480    /// The attribute value if present, `None` otherwise.
481    #[must_use]
482    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
483        self.attributes.get(key)
484    }
485
486    /// The originating request's W3C trace id, when the server stamped one from
487    /// the inbound `traceparent` header.
488    ///
489    /// Used to populate the change-log `trace_id` column so an outbox row links
490    /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
491    /// the investigation handle. `None` when the request carried no trace context.
492    #[must_use]
493    pub fn trace_id(&self) -> Option<&str> {
494        self.attributes
495            .get(Self::TRACE_ID_ATTRIBUTE)
496            .and_then(serde_json::Value::as_str)
497    }
498
499    /// Stamp the originating request's W3C trace id onto the context (carried in
500    /// `attributes`). Read back via [`trace_id`](Self::trace_id).
501    #[must_use]
502    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
503        self.attributes.insert(
504            Self::TRACE_ID_ATTRIBUTE.to_string(),
505            serde_json::Value::String(trace_id.into()),
506        );
507        self
508    }
509
510    /// The originating request's full W3C trace context (a JSON object), when the
511    /// server stamped one from the inbound `traceparent`/`tracestate` headers.
512    ///
513    /// Used to populate the change-log `trace_context` JSONB column so an outbox
514    /// row carries enough to re-propagate the distributed trace (#375). `None` when
515    /// the request carried no valid trace context.
516    #[must_use]
517    pub fn trace_context(&self) -> Option<&serde_json::Value> {
518        self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
519    }
520
521    /// Stamp the originating request's full W3C trace context (a JSON object) onto
522    /// the context (carried in `attributes`). Read back via
523    /// [`trace_context`](Self::trace_context).
524    #[must_use]
525    pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
526        self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
527        self
528    }
529
530    /// The request's actor classification (#390). Derived at
531    /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
532    /// context built directly without derivation).
533    #[must_use]
534    pub fn actor_type(&self) -> ActorType {
535        self.attributes
536            .get(Self::ACTOR_TYPE_ATTRIBUTE)
537            .and_then(serde_json::Value::as_str)
538            .and_then(ActorType::from_token)
539            .unwrap_or_default()
540    }
541
542    /// Stamp the request's [`ActorType`] onto the context (carried in
543    /// `attributes`). Read back via [`actor_type`](Self::actor_type).
544    #[must_use]
545    pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
546        self.attributes.insert(
547            Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
548            serde_json::Value::String(actor_type.as_str().to_string()),
549        );
550        self
551    }
552
553    /// The delegated user (the human a delegated agent acts for), when the request
554    /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
555    /// or when the underlying subject was not UUID-shaped.
556    #[must_use]
557    pub fn acting_for(&self) -> Option<Uuid> {
558        self.attributes
559            .get(Self::ACTING_FOR_ATTRIBUTE)
560            .and_then(serde_json::Value::as_str)
561            .and_then(|s| Uuid::parse_str(s).ok())
562    }
563
564    /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
565    /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
566    #[must_use]
567    pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
568        match acting_for {
569            Some(uuid) => {
570                self.attributes.insert(
571                    Self::ACTING_FOR_ATTRIBUTE.to_string(),
572                    serde_json::Value::String(uuid.to_string()),
573                );
574            },
575            None => {
576                self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
577            },
578        }
579        self
580    }
581
582    /// The ingress transport this request declared (#376), e.g. `"mcp"`.
583    /// `None` for transports that do not stamp one (today: everything except
584    /// MCP), and for contexts built without a server pipeline.
585    #[must_use]
586    pub fn transport(&self) -> Option<&str> {
587        self.attributes
588            .get(Self::TRANSPORT_ATTRIBUTE)
589            .and_then(serde_json::Value::as_str)
590    }
591
592    /// Stamp the ingress transport onto the context (carried in `attributes`,
593    /// forge-safe: the extractor strips the `fraiseql.` namespace from token
594    /// claims). Read back via [`transport`](Self::transport) and recorded into
595    /// the change-log's `extra_metadata.transport` by the mutation runner.
596    #[must_use]
597    pub fn with_transport(mut self, transport: &str) -> Self {
598        self.attributes.insert(
599            Self::TRANSPORT_ATTRIBUTE.to_string(),
600            serde_json::Value::String(transport.to_string()),
601        );
602        self
603    }
604
605    /// How this principal satisfied the enrichment requirement (#1336), or
606    /// `None` when it never passed the seam.
607    ///
608    /// `None` is the fail-closed state, and it is the *default* precisely
609    /// because the defect this guards against is a transport that builds a
610    /// principal and dispatches it without resolving. A context that skipped
611    /// the seam cannot claim to have passed it by saying nothing.
612    #[must_use]
613    pub fn enrichment_mark(&self) -> Option<EnrichmentMark> {
614        match self
615            .attributes
616            .get(Self::ENRICHMENT_ATTRIBUTE)
617            .and_then(serde_json::Value::as_str)
618        {
619            Some(EnrichmentMark::RESOLVED) => Some(EnrichmentMark::Resolved),
620            Some(EnrichmentMark::EXEMPT) => Some(EnrichmentMark::Exempt),
621            _ => None,
622        }
623    }
624
625    /// Record that this principal passed the enrichment seam.
626    ///
627    /// Carried in `attributes` under the reserved `fraiseql.` namespace, which
628    /// the token-claim builder strips — so a caller cannot mint this mark from a
629    /// JWT claim. Only server code on the request path sets it.
630    pub fn mark_enrichment(&mut self, mark: EnrichmentMark) {
631        self.attributes.insert(
632            Self::ENRICHMENT_ATTRIBUTE.to_string(),
633            serde_json::Value::String(mark.as_str().to_string()),
634        );
635    }
636
637    /// Check if the token has expired.
638    ///
639    /// # Returns
640    ///
641    /// `true` if the JWT has expired, `false` otherwise.
642    #[must_use]
643    pub fn is_expired(&self) -> bool {
644        self.expires_at <= Utc::now()
645    }
646
647    /// Get time until expiry in seconds.
648    ///
649    /// # Returns
650    ///
651    /// Seconds until JWT expiry, negative if already expired.
652    #[must_use]
653    pub fn ttl_secs(&self) -> i64 {
654        (self.expires_at - Utc::now()).num_seconds()
655    }
656
657    /// Check if the user is an admin.
658    ///
659    /// # Returns
660    ///
661    /// `true` if the user has the "admin" role, `false` otherwise.
662    #[must_use]
663    pub fn is_admin(&self) -> bool {
664        self.has_role("admin")
665    }
666
667    /// Check if the context has a tenant ID (multi-tenancy enabled).
668    ///
669    /// # Returns
670    ///
671    /// `true` if `tenant_id` is present, `false` otherwise.
672    #[must_use]
673    pub const fn is_multi_tenant(&self) -> bool {
674        self.tenant_id.is_some()
675    }
676
677    /// Set or override a role (for testing or runtime role modification).
678    #[must_use]
679    pub fn with_role(mut self, role: String) -> Self {
680        self.roles.push(role);
681        self
682    }
683
684    /// Set or override scopes (for testing or runtime permission modification).
685    #[must_use]
686    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
687        self.scopes = scopes;
688        self
689    }
690
691    /// Set tenant ID (for multi-tenancy).
692    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
693        self.tenant_id = Some(tenant_id.into());
694        self
695    }
696
697    /// Set a custom attribute (for testing or runtime attribute addition).
698    #[must_use]
699    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
700        self.attributes.insert(key, value);
701        self
702    }
703
704    /// Check if user can access a field based on role definitions.
705    ///
706    /// Takes a required scope and checks if any of the user's roles grant that scope.
707    ///
708    /// # Arguments
709    ///
710    /// * `security_config` - Security config from compiled schema with role definitions
711    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
712    ///
713    /// # Returns
714    ///
715    /// `true` if the user's roles grant the required scope, `false` otherwise.
716    ///
717    /// # `default_role`
718    ///
719    /// A principal carrying **no role at all** falls back to
720    /// `SecurityConfig::default_role` (#894). The condition is an *empty* role set,
721    /// not a failed lookup: a principal that has been assigned roles has been given
722    /// its authority, and quietly widening it when those roles fall short would make
723    /// `default_role` a floor under every principal rather than a default.
724    ///
725    /// The fallback reaches authenticated principals only, because a
726    /// `SecurityContext` exists only for one — an anonymous request has no principal
727    /// and is classified by `apply_anonymous_field_rbac_filtering`, which never calls
728    /// this method. That is deliberate: extending the fallback to anonymous callers
729    /// would re-open #743's privilege inversion from the other side, handing every
730    /// `requires_scope` field to callers who presented no credential.
731    ///
732    /// # Example
733    ///
734    /// ```no_run
735    /// // Requires: a SecurityConfig from a compiled schema.
736    /// // See: tests/integration/ for runnable examples.
737    /// # use fraiseql_core::security::SecurityContext;
738    /// # use fraiseql_core::schema::SecurityConfig;
739    /// # let context: SecurityContext = panic!("example");
740    /// # let config: SecurityConfig = panic!("example");
741    /// let can_access = context.can_access_scope(&config, "read:User.email");
742    /// ```
743    #[must_use]
744    pub fn can_access_scope(
745        &self,
746        security_config: &crate::schema::SecurityConfig,
747        required_scope: &str,
748    ) -> bool {
749        // Check if any of user's roles grant this scope
750        if self
751            .roles
752            .iter()
753            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
754        {
755            return true;
756        }
757
758        // #894: no role at all → the schema's declared default, if it declared one.
759        if self.roles.is_empty() {
760            if let Some(default_role) = security_config.default_role.as_deref() {
761                return security_config.role_has_scope(default_role, required_scope);
762            }
763        }
764
765        false
766    }
767}
768
769impl std::fmt::Display for SecurityContext {
770    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        write!(
772            f,
773            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
774            self.user_id,
775            self.roles,
776            self.scopes.len(),
777            self.tenant_id
778        )
779    }
780}