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}