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            // The issuer that vouched for the token (#1400); the validator forwards
314            // `iss` among the claims it verified.
315            issuer: user.extra_claims.get("iss").and_then(|v| v.as_str()).map(String::from),
316            audience: None,
317            email: user.email.clone(),
318            display_name: user.display_name.clone(),
319        }
320        .with_actor_type(actor_type)
321        .with_acting_for(acting_for)
322    }
323
324    /// Construct the context for an internal scheduled / system job (#573 D6).
325    ///
326    /// Unlike [`from_user`](Self::from_user), this carries **no JWT and no request**
327    /// — it is the server acting *as itself* for background work: a scheduled
328    /// [`Source`](crate::schema::SourceDefinition), and (in future) `after:ingest`
329    /// handlers and saga steps. It is the first and, at introduction, only
330    /// construction of [`ActorType::SystemJob`].
331    ///
332    /// Its authority is **exactly** the `roles` + `scopes` the caller grants (the
333    /// source's `run_as` ceiling). With neither, it is **fail-closed**: no roles and
334    /// no scopes mean every RBAC / field-authz check denies and — with no `tenant` —
335    /// tenant-scoped RLS admits nothing. A source therefore cannot write until an
336    /// operator grants it a ceiling.
337    ///
338    /// `tenant` scopes writes for a single-tenant or global source (`None` = global
339    /// / NULL tenant); a multi-tenant source leaves it `None` and re-scopes each
340    /// write per message via [`with_tenant`](Self::with_tenant). `job_id` names the
341    /// job (the source name) and becomes the `system_job:<id>` principal for the
342    /// audit envelope; `request_id` correlates a single firing.
343    ///
344    /// The [`ActorType`] is *recorded* for audit, never an authorization input — the
345    /// authority comes solely from `roles`/`scopes`.
346    #[must_use]
347    pub fn system_job(
348        job_id: impl Into<String>,
349        request_id: impl Into<String>,
350        roles: Vec<String>,
351        scopes: Vec<String>,
352        tenant: Option<TenantId>,
353    ) -> Self {
354        let mut ctx = Self::principal_from_ceiling(
355            format!("system_job:{}", job_id.into()),
356            request_id,
357            roles,
358            scopes,
359            tenant,
360            ActorType::SystemJob,
361        );
362        // #1336: the server acting as itself has no subject a resolver could look
363        // up, so it is exempt from the enrichment requirement — stated here, at the
364        // one site that mints such a principal, rather than inferred downstream from
365        // "nothing resolved it".
366        ctx.mark_enrichment(EnrichmentMark::Exempt);
367        ctx
368    }
369
370    /// Mint a **service-account** context from a `run_as` ceiling (ADR-0018).
371    ///
372    /// A service account is an *external* credentialed caller — a daemon or service
373    /// authenticated by a static secret — as opposed to an internal
374    /// [`system_job`](Self::system_job) firing. Authority is **exactly** the `run_as`
375    /// ceiling (`roles`/`scopes`/`tenant`); an absent/empty ceiling ⇒ no authority (RLS
376    /// and field-authz deny its writes). The identity is `service_account:<name>` and the
377    /// [`ActorType`] is [`ServiceAccount`](ActorType::ServiceAccount) — recorded for audit
378    /// (`tb_entity_change_log.actor_type`), never an authorization input.
379    #[must_use]
380    pub fn service_account(
381        name: impl Into<String>,
382        request_id: impl Into<String>,
383        roles: Vec<String>,
384        scopes: Vec<String>,
385        tenant: Option<TenantId>,
386    ) -> Self {
387        Self::principal_from_ceiling(
388            format!("service_account:{}", name.into()),
389            request_id,
390            roles,
391            scopes,
392            tenant,
393            ActorType::ServiceAccount,
394        )
395    }
396
397    /// Shared body for a machine principal minted from a `run_as` ceiling. The authority
398    /// is exactly `roles`/`scopes`/`tenant`; `user_id` + `actor_type` carry the
399    /// provenance (audit-only). Wrapped by [`system_job`](Self::system_job) and
400    /// [`service_account`](Self::service_account).
401    fn principal_from_ceiling(
402        user_id: String,
403        request_id: impl Into<String>,
404        roles: Vec<String>,
405        scopes: Vec<String>,
406        tenant: Option<TenantId>,
407        actor_type: ActorType,
408    ) -> Self {
409        let now = Utc::now();
410        SecurityContext {
411            user_id: UserId(user_id),
412            roles,
413            tenant_id: tenant,
414            scopes,
415            attributes: HashMap::new(),
416            request_id: request_id.into(),
417            ip_address: None,
418            authenticated_at: now,
419            // A machine principal's identity is minted fresh per request/firing and does
420            // not outlive it; a generous window keeps any TTL check from tripping
421            // mid-run (mirrors the live host's default context).
422            expires_at: now + chrono::Duration::hours(24),
423            issuer: None,
424            audience: None,
425            email: None,
426            display_name: None,
427        }
428        .with_actor_type(actor_type)
429    }
430
431    /// Check if the user has a specific role.
432    ///
433    /// # Arguments
434    ///
435    /// * `role` - Role name to check (e.g., "admin", "moderator")
436    ///
437    /// # Returns
438    ///
439    /// `true` if the user has the specified role, `false` otherwise.
440    #[must_use]
441    pub fn has_role(&self, role: &str) -> bool {
442        self.roles.iter().any(|r| r == role)
443    }
444
445    /// Check if the user has a specific scope.
446    ///
447    /// Supports wildcards: `admin:*` matches any admin scope.
448    ///
449    /// # Arguments
450    ///
451    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
452    ///
453    /// # Returns
454    ///
455    /// `true` if the user has the specified scope, `false` otherwise.
456    #[must_use]
457    pub fn has_scope(&self, scope: &str) -> bool {
458        self.scopes.iter().any(|s| {
459            if s == scope {
460                return true;
461            }
462            // Support wildcard matching: "admin:*" matches "admin:read"
463            if s.ends_with(':') {
464                scope.starts_with(s)
465            } else if s.ends_with('*') {
466                let prefix = &s[..s.len() - 1];
467                scope.starts_with(prefix)
468            } else {
469                false
470            }
471        })
472    }
473
474    /// Get a custom attribute from the JWT claims.
475    ///
476    /// # Arguments
477    ///
478    /// * `key` - Attribute name
479    ///
480    /// # Returns
481    ///
482    /// The attribute value if present, `None` otherwise.
483    #[must_use]
484    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
485        self.attributes.get(key)
486    }
487
488    /// The originating request's W3C trace id, when the server stamped one from
489    /// the inbound `traceparent` header.
490    ///
491    /// Used to populate the change-log `trace_id` column so an outbox row links
492    /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
493    /// the investigation handle. `None` when the request carried no trace context.
494    #[must_use]
495    pub fn trace_id(&self) -> Option<&str> {
496        self.attributes
497            .get(Self::TRACE_ID_ATTRIBUTE)
498            .and_then(serde_json::Value::as_str)
499    }
500
501    /// Stamp the originating request's W3C trace id onto the context (carried in
502    /// `attributes`). Read back via [`trace_id`](Self::trace_id).
503    #[must_use]
504    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
505        self.attributes.insert(
506            Self::TRACE_ID_ATTRIBUTE.to_string(),
507            serde_json::Value::String(trace_id.into()),
508        );
509        self
510    }
511
512    /// The originating request's full W3C trace context (a JSON object), when the
513    /// server stamped one from the inbound `traceparent`/`tracestate` headers.
514    ///
515    /// Used to populate the change-log `trace_context` JSONB column so an outbox
516    /// row carries enough to re-propagate the distributed trace (#375). `None` when
517    /// the request carried no valid trace context.
518    #[must_use]
519    pub fn trace_context(&self) -> Option<&serde_json::Value> {
520        self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
521    }
522
523    /// Stamp the originating request's full W3C trace context (a JSON object) onto
524    /// the context (carried in `attributes`). Read back via
525    /// [`trace_context`](Self::trace_context).
526    #[must_use]
527    pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
528        self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
529        self
530    }
531
532    /// The request's actor classification (#390). Derived at
533    /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
534    /// context built directly without derivation).
535    #[must_use]
536    pub fn actor_type(&self) -> ActorType {
537        self.attributes
538            .get(Self::ACTOR_TYPE_ATTRIBUTE)
539            .and_then(serde_json::Value::as_str)
540            .and_then(ActorType::from_token)
541            .unwrap_or_default()
542    }
543
544    /// Stamp the request's [`ActorType`] onto the context (carried in
545    /// `attributes`). Read back via [`actor_type`](Self::actor_type).
546    #[must_use]
547    pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
548        self.attributes.insert(
549            Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
550            serde_json::Value::String(actor_type.as_str().to_string()),
551        );
552        self
553    }
554
555    /// The delegated user (the human a delegated agent acts for), when the request
556    /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
557    /// or when the underlying subject was not UUID-shaped.
558    #[must_use]
559    pub fn acting_for(&self) -> Option<Uuid> {
560        self.attributes
561            .get(Self::ACTING_FOR_ATTRIBUTE)
562            .and_then(serde_json::Value::as_str)
563            .and_then(|s| Uuid::parse_str(s).ok())
564    }
565
566    /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
567    /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
568    #[must_use]
569    pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
570        match acting_for {
571            Some(uuid) => {
572                self.attributes.insert(
573                    Self::ACTING_FOR_ATTRIBUTE.to_string(),
574                    serde_json::Value::String(uuid.to_string()),
575                );
576            },
577            None => {
578                self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
579            },
580        }
581        self
582    }
583
584    /// The ingress transport this request declared (#376), e.g. `"mcp"`.
585    /// `None` for transports that do not stamp one (today: everything except
586    /// MCP), and for contexts built without a server pipeline.
587    #[must_use]
588    pub fn transport(&self) -> Option<&str> {
589        self.attributes
590            .get(Self::TRANSPORT_ATTRIBUTE)
591            .and_then(serde_json::Value::as_str)
592    }
593
594    /// Stamp the ingress transport onto the context (carried in `attributes`,
595    /// forge-safe: the extractor strips the `fraiseql.` namespace from token
596    /// claims). Read back via [`transport`](Self::transport) and recorded into
597    /// the change-log's `extra_metadata.transport` by the mutation runner.
598    #[must_use]
599    pub fn with_transport(mut self, transport: &str) -> Self {
600        self.attributes.insert(
601            Self::TRANSPORT_ATTRIBUTE.to_string(),
602            serde_json::Value::String(transport.to_string()),
603        );
604        self
605    }
606
607    /// How this principal satisfied the enrichment requirement (#1336), or
608    /// `None` when it never passed the seam.
609    ///
610    /// `None` is the fail-closed state, and it is the *default* precisely
611    /// because the defect this guards against is a transport that builds a
612    /// principal and dispatches it without resolving. A context that skipped
613    /// the seam cannot claim to have passed it by saying nothing.
614    #[must_use]
615    pub fn enrichment_mark(&self) -> Option<EnrichmentMark> {
616        match self
617            .attributes
618            .get(Self::ENRICHMENT_ATTRIBUTE)
619            .and_then(serde_json::Value::as_str)
620        {
621            Some(EnrichmentMark::RESOLVED) => Some(EnrichmentMark::Resolved),
622            Some(EnrichmentMark::EXEMPT) => Some(EnrichmentMark::Exempt),
623            _ => None,
624        }
625    }
626
627    /// Record that this principal passed the enrichment seam.
628    ///
629    /// Carried in `attributes` under the reserved `fraiseql.` namespace, which
630    /// the token-claim builder strips — so a caller cannot mint this mark from a
631    /// JWT claim. Only server code on the request path sets it.
632    pub fn mark_enrichment(&mut self, mark: EnrichmentMark) {
633        self.attributes.insert(
634            Self::ENRICHMENT_ATTRIBUTE.to_string(),
635            serde_json::Value::String(mark.as_str().to_string()),
636        );
637    }
638
639    /// Check if the token has expired.
640    ///
641    /// # Returns
642    ///
643    /// `true` if the JWT has expired, `false` otherwise.
644    #[must_use]
645    pub fn is_expired(&self) -> bool {
646        self.expires_at <= Utc::now()
647    }
648
649    /// Get time until expiry in seconds.
650    ///
651    /// # Returns
652    ///
653    /// Seconds until JWT expiry, negative if already expired.
654    #[must_use]
655    pub fn ttl_secs(&self) -> i64 {
656        (self.expires_at - Utc::now()).num_seconds()
657    }
658
659    /// Check if the user is an admin.
660    ///
661    /// # Returns
662    ///
663    /// `true` if the user has the "admin" role, `false` otherwise.
664    #[must_use]
665    pub fn is_admin(&self) -> bool {
666        self.has_role("admin")
667    }
668
669    /// Check if the context has a tenant ID (multi-tenancy enabled).
670    ///
671    /// # Returns
672    ///
673    /// `true` if `tenant_id` is present, `false` otherwise.
674    #[must_use]
675    pub const fn is_multi_tenant(&self) -> bool {
676        self.tenant_id.is_some()
677    }
678
679    /// Set or override a role (for testing or runtime role modification).
680    #[must_use]
681    pub fn with_role(mut self, role: String) -> Self {
682        self.roles.push(role);
683        self
684    }
685
686    /// Set or override scopes (for testing or runtime permission modification).
687    #[must_use]
688    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
689        self.scopes = scopes;
690        self
691    }
692
693    /// Set tenant ID (for multi-tenancy).
694    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
695        self.tenant_id = Some(tenant_id.into());
696        self
697    }
698
699    /// Set a custom attribute (for testing or runtime attribute addition).
700    #[must_use]
701    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
702        self.attributes.insert(key, value);
703        self
704    }
705
706    /// The value of JWT claim `claim` for this request.
707    ///
708    /// The one answer to "what is claim X" (#1388). Inject parameters (`jwt:<claim>`),
709    /// session variables (`source = "jwt"`) and identity-enrichment bindings all read
710    /// claims through this, so a claim name means the same thing on every path. Three
711    /// copies used to answer differently, and two of them answered both `tenant_id` and
712    /// `org_id` with [`Self::tenant_id`] whatever the schema's tenant claim was, so a
713    /// token carrying both scoped a query by the wrong one.
714    ///
715    /// Resolution, first match wins:
716    /// 1. the raw claim, from [`Self::attributes`] (every non-reserved claim lands there);
717    /// 2. a **registered** claim the validator lifts out of the claim map into its own field, so it
718    ///    never reaches `attributes`: `sub` (also spelled `user_id`) → [`Self::user_id`], `email` →
719    ///    [`Self::email`], `name` (also `display_name`) → [`Self::display_name`], `iss` →
720    ///    [`Self::issuer`], `scope` → [`Self::scopes`] as the space-separated string an OAuth token
721    ///    carries (#1417; absent when no scope is granted);
722    /// 3. `tenant_claim` — the schema's configured tenant claim, and only that name — →
723    ///    [`Self::tenant_id`].
724    ///
725    /// Step 3 is what lets a principal the server mints itself (a service account, a
726    /// system job, a source's per-message tenant, an admin preview) answer row-mode
727    /// tenancy's `jwt:<tenant_claim>` with the tenant it was assigned. For a principal
728    /// built from a token it changes nothing: its tenant *is* that claim's value (see
729    /// the server's context builder), so step 1 already answered. No other name reads
730    /// the tenant.
731    #[must_use]
732    pub fn jwt_claim(&self, claim: &str, tenant_claim: &str) -> Option<serde_json::Value> {
733        if let Some(value) = self.attributes.get(claim) {
734            return Some(value.clone());
735        }
736        let field = match claim {
737            "sub" | "user_id" => Some(self.user_id.0.clone()),
738            "email" => self.email.clone(),
739            "name" | "display_name" => self.display_name.clone(),
740            "iss" => self.issuer.clone(),
741            "scope" => (!self.scopes.is_empty()).then(|| self.scopes.join(" ")),
742            _ if claim == tenant_claim => self.tenant_id.as_ref().map(|t| t.0.clone()),
743            _ => None,
744        };
745        field.map(serde_json::Value::String)
746    }
747
748    /// Check if user can access a field based on role definitions.
749    ///
750    /// Takes a required scope and checks if any of the user's roles grant that scope.
751    ///
752    /// # Arguments
753    ///
754    /// * `security_config` - Security config from compiled schema with role definitions
755    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
756    ///
757    /// # Returns
758    ///
759    /// `true` if the user's roles grant the required scope, `false` otherwise.
760    ///
761    /// # `default_role`
762    ///
763    /// A principal carrying **no role at all** falls back to
764    /// `SecurityConfig::default_role` (#894). The condition is an *empty* role set,
765    /// not a failed lookup: a principal that has been assigned roles has been given
766    /// its authority, and quietly widening it when those roles fall short would make
767    /// `default_role` a floor under every principal rather than a default.
768    ///
769    /// The fallback reaches authenticated principals only, because a
770    /// `SecurityContext` exists only for one — an anonymous request has no principal
771    /// and is classified by `apply_anonymous_field_rbac_filtering`, which never calls
772    /// this method. That is deliberate: extending the fallback to anonymous callers
773    /// would re-open #743's privilege inversion from the other side, handing every
774    /// `requires_scope` field to callers who presented no credential.
775    ///
776    /// # Example
777    ///
778    /// ```no_run
779    /// // Requires: a SecurityConfig from a compiled schema.
780    /// // See: tests/integration/ for runnable examples.
781    /// # use fraiseql_core::security::SecurityContext;
782    /// # use fraiseql_core::schema::SecurityConfig;
783    /// # let context: SecurityContext = panic!("example");
784    /// # let config: SecurityConfig = panic!("example");
785    /// let can_access = context.can_access_scope(&config, "read:User.email");
786    /// ```
787    #[must_use]
788    pub fn can_access_scope(
789        &self,
790        security_config: &crate::schema::SecurityConfig,
791        required_scope: &str,
792    ) -> bool {
793        // Check if any of user's roles grant this scope
794        if self
795            .roles
796            .iter()
797            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
798        {
799            return true;
800        }
801
802        // #894: no role at all → the schema's declared default, if it declared one.
803        if self.roles.is_empty() {
804            if let Some(default_role) = security_config.default_role.as_deref() {
805                return security_config.role_has_scope(default_role, required_scope);
806            }
807        }
808
809        false
810    }
811}
812
813impl std::fmt::Display for SecurityContext {
814    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815        write!(
816            f,
817            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
818            self.user_id,
819            self.roles,
820            self.scopes.len(),
821            self.tenant_id
822        )
823    }
824}