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/// Security context for authorization evaluation.
70///
71/// Carries information about the authenticated user and their permissions
72/// throughout the request lifecycle.
73///
74/// # Fields
75///
76/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
77/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
78/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
79/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
80/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
81/// - `request_id`: Correlation ID for audit logging and tracing
82/// - `ip_address`: Client IP address for geolocation and fraud detection
83/// - `authenticated_at`: When the JWT was issued
84/// - `expires_at`: When the JWT expires
85/// - `issuer`: Token issuer for multi-issuer systems
86/// - `audience`: Token audience for validation
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SecurityContext {
89    /// User ID (from JWT 'sub' claim)
90    pub user_id: UserId,
91
92    /// User's roles (e.g., `["admin", "moderator"]`)
93    ///
94    /// Extracted from JWT 'roles' claim or derived from other claims.
95    /// Used for role-based access control (RBAC) decisions.
96    pub roles: Vec<String>,
97
98    /// Tenant/organization ID (for multi-tenancy)
99    ///
100    /// When present, RLS policies can enforce tenant isolation.
101    /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
102    pub tenant_id: Option<TenantId>,
103
104    /// OAuth/permission scopes
105    ///
106    /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
107    /// Examples:
108    /// - `read:user`
109    /// - `write:post`
110    /// - `read:User.email`
111    /// - `admin:*`
112    ///
113    /// Extracted from JWT 'scope' claim.
114    pub scopes: Vec<String>,
115
116    /// Custom attributes from JWT claims
117    ///
118    /// Arbitrary key-value pairs from JWT payload.
119    /// Examples: "department", "region", "tier", "country"
120    ///
121    /// Used by custom RLS policies that need domain-specific attributes.
122    pub attributes: HashMap<String, serde_json::Value>,
123
124    /// Request correlation ID for audit trails
125    ///
126    /// Extracted from X-Request-Id header or generated.
127    /// Used for tracing and audit logging across services.
128    pub request_id: String,
129
130    /// Client IP address
131    ///
132    /// Extracted from X-Forwarded-For or connection socket.
133    /// Used for geolocation and fraud detection in RLS policies.
134    pub ip_address: Option<String>,
135
136    /// When the JWT was issued
137    pub authenticated_at: DateTime<Utc>,
138
139    /// When the JWT expires
140    pub expires_at: DateTime<Utc>,
141
142    /// Token issuer (for multi-issuer systems)
143    pub issuer: Option<String>,
144
145    /// Token audience (for audience validation)
146    pub audience: Option<String>,
147
148    /// Normalised email address from the JWT `email` claim.
149    ///
150    /// Available as `jwt:email` in session variable mappings for RLS policies.
151    pub email: Option<String>,
152
153    /// Normalised display name from the JWT `name` claim.
154    ///
155    /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
156    pub display_name: Option<String>,
157}
158
159impl SecurityContext {
160    /// Attribute key under which the delegated user's UUID is carried (string
161    /// form), for an agent request acting on behalf of a human. Read back via
162    /// [`acting_for`](Self::acting_for) (#390).
163    pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
164    /// Attribute key under which the request's [`ActorType`] is carried (the
165    /// `snake_case` token), derived at [`from_user`](Self::from_user) and read
166    /// back via [`actor_type`](Self::actor_type) (#390).
167    pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
168    /// Attribute key under which the originating request's full W3C trace context
169    /// is carried (a JSON object), used to populate the change-log `trace_context`
170    /// JSONB column (#375).
171    pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
172    /// Attribute key under which the originating request's W3C trace id is
173    /// stamped. Set by the server's request pipeline from the inbound
174    /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
175    pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
176
177    /// Create a security context from an authenticated user and request metadata.
178    ///
179    /// # Arguments
180    ///
181    /// * `user` - Authenticated user from JWT validation
182    /// * `request_id` - Correlation ID for this request
183    ///
184    /// # Example
185    ///
186    /// ```no_run
187    /// // Requires: a live AuthenticatedUser from JWT validation.
188    /// // See: tests/integration/ for runnable examples.
189    /// # use fraiseql_core::security::SecurityContext;
190    /// # use fraiseql_core::security::AuthenticatedUser;
191    /// # let authenticated_user: AuthenticatedUser = panic!("example");
192    /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
193    /// ```
194    #[must_use]
195    pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
196        // Classify the actor from the (signature-verified) JWT material at
197        // construction time, so every auth path (OIDC, HS256, gRPC, MCP) that
198        // builds a context from a user gets it. The API-key path overrides to
199        // ServiceAccount at its own construction site (#390).
200        let (actor_type, acting_for) =
201            derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
202        SecurityContext {
203            user_id: user.user_id.clone(),
204            roles: roles_from_claims(&user.extra_claims),
205            tenant_id: None,
206            scopes: user.scopes.clone(),
207            attributes: HashMap::new(),
208            request_id,
209            ip_address: None,
210            authenticated_at: Utc::now(),
211            expires_at: user.expires_at,
212            issuer: None,
213            audience: None,
214            email: user.email.clone(),
215            display_name: user.display_name.clone(),
216        }
217        .with_actor_type(actor_type)
218        .with_acting_for(acting_for)
219    }
220
221    /// Construct the context for an internal scheduled / system job (#573 D6).
222    ///
223    /// Unlike [`from_user`](Self::from_user), this carries **no JWT and no request**
224    /// — it is the server acting *as itself* for background work: a scheduled
225    /// [`Source`](crate::schema::SourceDefinition), and (in future) `after:ingest`
226    /// handlers and saga steps. It is the first and, at introduction, only
227    /// construction of [`ActorType::SystemJob`].
228    ///
229    /// Its authority is **exactly** the `roles` + `scopes` the caller grants (the
230    /// source's `run_as` ceiling). With neither, it is **fail-closed**: no roles and
231    /// no scopes mean every RBAC / field-authz check denies and — with no `tenant` —
232    /// tenant-scoped RLS admits nothing. A source therefore cannot write until an
233    /// operator grants it a ceiling.
234    ///
235    /// `tenant` scopes writes for a single-tenant or global source (`None` = global
236    /// / NULL tenant); a multi-tenant source leaves it `None` and re-scopes each
237    /// write per message via [`with_tenant`](Self::with_tenant). `job_id` names the
238    /// job (the source name) and becomes the `system_job:<id>` principal for the
239    /// audit envelope; `request_id` correlates a single firing.
240    ///
241    /// The [`ActorType`] is *recorded* for audit, never an authorization input — the
242    /// authority comes solely from `roles`/`scopes`.
243    #[must_use]
244    pub fn system_job(
245        job_id: impl Into<String>,
246        request_id: impl Into<String>,
247        roles: Vec<String>,
248        scopes: Vec<String>,
249        tenant: Option<TenantId>,
250    ) -> Self {
251        Self::principal_from_ceiling(
252            format!("system_job:{}", job_id.into()),
253            request_id,
254            roles,
255            scopes,
256            tenant,
257            ActorType::SystemJob,
258        )
259    }
260
261    /// Mint a **service-account** context from a `run_as` ceiling (ADR-0018).
262    ///
263    /// A service account is an *external* credentialed caller — a daemon or service
264    /// authenticated by a static secret — as opposed to an internal
265    /// [`system_job`](Self::system_job) firing. Authority is **exactly** the `run_as`
266    /// ceiling (`roles`/`scopes`/`tenant`); an absent/empty ceiling ⇒ no authority (RLS
267    /// and field-authz deny its writes). The identity is `service_account:<name>` and the
268    /// [`ActorType`] is [`ServiceAccount`](ActorType::ServiceAccount) — recorded for audit
269    /// (`tb_entity_change_log.actor_type`), never an authorization input.
270    #[must_use]
271    pub fn service_account(
272        name: impl Into<String>,
273        request_id: impl Into<String>,
274        roles: Vec<String>,
275        scopes: Vec<String>,
276        tenant: Option<TenantId>,
277    ) -> Self {
278        Self::principal_from_ceiling(
279            format!("service_account:{}", name.into()),
280            request_id,
281            roles,
282            scopes,
283            tenant,
284            ActorType::ServiceAccount,
285        )
286    }
287
288    /// Shared body for a machine principal minted from a `run_as` ceiling. The authority
289    /// is exactly `roles`/`scopes`/`tenant`; `user_id` + `actor_type` carry the
290    /// provenance (audit-only). Wrapped by [`system_job`](Self::system_job) and
291    /// [`service_account`](Self::service_account).
292    fn principal_from_ceiling(
293        user_id: String,
294        request_id: impl Into<String>,
295        roles: Vec<String>,
296        scopes: Vec<String>,
297        tenant: Option<TenantId>,
298        actor_type: ActorType,
299    ) -> Self {
300        let now = Utc::now();
301        SecurityContext {
302            user_id: UserId(user_id),
303            roles,
304            tenant_id: tenant,
305            scopes,
306            attributes: HashMap::new(),
307            request_id: request_id.into(),
308            ip_address: None,
309            authenticated_at: now,
310            // A machine principal's identity is minted fresh per request/firing and does
311            // not outlive it; a generous window keeps any TTL check from tripping
312            // mid-run (mirrors the live host's default context).
313            expires_at: now + chrono::Duration::hours(24),
314            issuer: None,
315            audience: None,
316            email: None,
317            display_name: None,
318        }
319        .with_actor_type(actor_type)
320    }
321
322    /// Check if the user has a specific role.
323    ///
324    /// # Arguments
325    ///
326    /// * `role` - Role name to check (e.g., "admin", "moderator")
327    ///
328    /// # Returns
329    ///
330    /// `true` if the user has the specified role, `false` otherwise.
331    #[must_use]
332    pub fn has_role(&self, role: &str) -> bool {
333        self.roles.iter().any(|r| r == role)
334    }
335
336    /// Check if the user has a specific scope.
337    ///
338    /// Supports wildcards: `admin:*` matches any admin scope.
339    ///
340    /// # Arguments
341    ///
342    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
343    ///
344    /// # Returns
345    ///
346    /// `true` if the user has the specified scope, `false` otherwise.
347    #[must_use]
348    pub fn has_scope(&self, scope: &str) -> bool {
349        self.scopes.iter().any(|s| {
350            if s == scope {
351                return true;
352            }
353            // Support wildcard matching: "admin:*" matches "admin:read"
354            if s.ends_with(':') {
355                scope.starts_with(s)
356            } else if s.ends_with('*') {
357                let prefix = &s[..s.len() - 1];
358                scope.starts_with(prefix)
359            } else {
360                false
361            }
362        })
363    }
364
365    /// Get a custom attribute from the JWT claims.
366    ///
367    /// # Arguments
368    ///
369    /// * `key` - Attribute name
370    ///
371    /// # Returns
372    ///
373    /// The attribute value if present, `None` otherwise.
374    #[must_use]
375    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
376        self.attributes.get(key)
377    }
378
379    /// The originating request's W3C trace id, when the server stamped one from
380    /// the inbound `traceparent` header.
381    ///
382    /// Used to populate the change-log `trace_id` column so an outbox row links
383    /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
384    /// the investigation handle. `None` when the request carried no trace context.
385    #[must_use]
386    pub fn trace_id(&self) -> Option<&str> {
387        self.attributes
388            .get(Self::TRACE_ID_ATTRIBUTE)
389            .and_then(serde_json::Value::as_str)
390    }
391
392    /// Stamp the originating request's W3C trace id onto the context (carried in
393    /// `attributes`). Read back via [`trace_id`](Self::trace_id).
394    #[must_use]
395    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
396        self.attributes.insert(
397            Self::TRACE_ID_ATTRIBUTE.to_string(),
398            serde_json::Value::String(trace_id.into()),
399        );
400        self
401    }
402
403    /// The originating request's full W3C trace context (a JSON object), when the
404    /// server stamped one from the inbound `traceparent`/`tracestate` headers.
405    ///
406    /// Used to populate the change-log `trace_context` JSONB column so an outbox
407    /// row carries enough to re-propagate the distributed trace (#375). `None` when
408    /// the request carried no valid trace context.
409    #[must_use]
410    pub fn trace_context(&self) -> Option<&serde_json::Value> {
411        self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
412    }
413
414    /// Stamp the originating request's full W3C trace context (a JSON object) onto
415    /// the context (carried in `attributes`). Read back via
416    /// [`trace_context`](Self::trace_context).
417    #[must_use]
418    pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
419        self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
420        self
421    }
422
423    /// The request's actor classification (#390). Derived at
424    /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
425    /// context built directly without derivation).
426    #[must_use]
427    pub fn actor_type(&self) -> ActorType {
428        self.attributes
429            .get(Self::ACTOR_TYPE_ATTRIBUTE)
430            .and_then(serde_json::Value::as_str)
431            .and_then(ActorType::from_token)
432            .unwrap_or_default()
433    }
434
435    /// Stamp the request's [`ActorType`] onto the context (carried in
436    /// `attributes`). Read back via [`actor_type`](Self::actor_type).
437    #[must_use]
438    pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
439        self.attributes.insert(
440            Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
441            serde_json::Value::String(actor_type.as_str().to_string()),
442        );
443        self
444    }
445
446    /// The delegated user (the human a delegated agent acts for), when the request
447    /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
448    /// or when the underlying subject was not UUID-shaped.
449    #[must_use]
450    pub fn acting_for(&self) -> Option<Uuid> {
451        self.attributes
452            .get(Self::ACTING_FOR_ATTRIBUTE)
453            .and_then(serde_json::Value::as_str)
454            .and_then(|s| Uuid::parse_str(s).ok())
455    }
456
457    /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
458    /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
459    #[must_use]
460    pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
461        match acting_for {
462            Some(uuid) => {
463                self.attributes.insert(
464                    Self::ACTING_FOR_ATTRIBUTE.to_string(),
465                    serde_json::Value::String(uuid.to_string()),
466                );
467            },
468            None => {
469                self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
470            },
471        }
472        self
473    }
474
475    /// Check if the token has expired.
476    ///
477    /// # Returns
478    ///
479    /// `true` if the JWT has expired, `false` otherwise.
480    #[must_use]
481    pub fn is_expired(&self) -> bool {
482        self.expires_at <= Utc::now()
483    }
484
485    /// Get time until expiry in seconds.
486    ///
487    /// # Returns
488    ///
489    /// Seconds until JWT expiry, negative if already expired.
490    #[must_use]
491    pub fn ttl_secs(&self) -> i64 {
492        (self.expires_at - Utc::now()).num_seconds()
493    }
494
495    /// Check if the user is an admin.
496    ///
497    /// # Returns
498    ///
499    /// `true` if the user has the "admin" role, `false` otherwise.
500    #[must_use]
501    pub fn is_admin(&self) -> bool {
502        self.has_role("admin")
503    }
504
505    /// Check if the context has a tenant ID (multi-tenancy enabled).
506    ///
507    /// # Returns
508    ///
509    /// `true` if `tenant_id` is present, `false` otherwise.
510    #[must_use]
511    pub const fn is_multi_tenant(&self) -> bool {
512        self.tenant_id.is_some()
513    }
514
515    /// Set or override a role (for testing or runtime role modification).
516    #[must_use]
517    pub fn with_role(mut self, role: String) -> Self {
518        self.roles.push(role);
519        self
520    }
521
522    /// Set or override scopes (for testing or runtime permission modification).
523    #[must_use]
524    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
525        self.scopes = scopes;
526        self
527    }
528
529    /// Set tenant ID (for multi-tenancy).
530    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
531        self.tenant_id = Some(tenant_id.into());
532        self
533    }
534
535    /// Set a custom attribute (for testing or runtime attribute addition).
536    #[must_use]
537    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
538        self.attributes.insert(key, value);
539        self
540    }
541
542    /// Check if user can access a field based on role definitions.
543    ///
544    /// Takes a required scope and checks if any of the user's roles grant that scope.
545    ///
546    /// # Arguments
547    ///
548    /// * `security_config` - Security config from compiled schema with role definitions
549    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
550    ///
551    /// # Returns
552    ///
553    /// `true` if user's roles grant the required scope, `false` otherwise.
554    ///
555    /// # Example
556    ///
557    /// ```no_run
558    /// // Requires: a SecurityConfig from a compiled schema.
559    /// // See: tests/integration/ for runnable examples.
560    /// # use fraiseql_core::security::SecurityContext;
561    /// # use fraiseql_core::schema::SecurityConfig;
562    /// # let context: SecurityContext = panic!("example");
563    /// # let config: SecurityConfig = panic!("example");
564    /// let can_access = context.can_access_scope(&config, "read:User.email");
565    /// ```
566    #[must_use]
567    pub fn can_access_scope(
568        &self,
569        security_config: &crate::schema::SecurityConfig,
570        required_scope: &str,
571    ) -> bool {
572        // Check if any of user's roles grant this scope
573        self.roles
574            .iter()
575            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
576    }
577}
578
579impl std::fmt::Display for SecurityContext {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        write!(
582            f,
583            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
584            self.user_id,
585            self.roles,
586            self.scopes.len(),
587            self.tenant_id
588        )
589    }
590}