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}