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};
33
34use crate::{
35    security::AuthenticatedUser,
36    types::{TenantId, UserId},
37};
38
39/// Security context for authorization evaluation.
40///
41/// Carries information about the authenticated user and their permissions
42/// throughout the request lifecycle.
43///
44/// # Fields
45///
46/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
47/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
48/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
49/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
50/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
51/// - `request_id`: Correlation ID for audit logging and tracing
52/// - `ip_address`: Client IP address for geolocation and fraud detection
53/// - `authenticated_at`: When the JWT was issued
54/// - `expires_at`: When the JWT expires
55/// - `issuer`: Token issuer for multi-issuer systems
56/// - `audience`: Token audience for validation
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SecurityContext {
59    /// User ID (from JWT 'sub' claim)
60    pub user_id: UserId,
61
62    /// User's roles (e.g., `["admin", "moderator"]`)
63    ///
64    /// Extracted from JWT 'roles' claim or derived from other claims.
65    /// Used for role-based access control (RBAC) decisions.
66    pub roles: Vec<String>,
67
68    /// Tenant/organization ID (for multi-tenancy)
69    ///
70    /// When present, RLS policies can enforce tenant isolation.
71    /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
72    pub tenant_id: Option<TenantId>,
73
74    /// OAuth/permission scopes
75    ///
76    /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
77    /// Examples:
78    /// - `read:user`
79    /// - `write:post`
80    /// - `read:User.email`
81    /// - `admin:*`
82    ///
83    /// Extracted from JWT 'scope' claim.
84    pub scopes: Vec<String>,
85
86    /// Custom attributes from JWT claims
87    ///
88    /// Arbitrary key-value pairs from JWT payload.
89    /// Examples: "department", "region", "tier", "country"
90    ///
91    /// Used by custom RLS policies that need domain-specific attributes.
92    pub attributes: HashMap<String, serde_json::Value>,
93
94    /// Request correlation ID for audit trails
95    ///
96    /// Extracted from X-Request-Id header or generated.
97    /// Used for tracing and audit logging across services.
98    pub request_id: String,
99
100    /// Client IP address
101    ///
102    /// Extracted from X-Forwarded-For or connection socket.
103    /// Used for geolocation and fraud detection in RLS policies.
104    pub ip_address: Option<String>,
105
106    /// When the JWT was issued
107    pub authenticated_at: DateTime<Utc>,
108
109    /// When the JWT expires
110    pub expires_at: DateTime<Utc>,
111
112    /// Token issuer (for multi-issuer systems)
113    pub issuer: Option<String>,
114
115    /// Token audience (for audience validation)
116    pub audience: Option<String>,
117
118    /// Normalised email address from the JWT `email` claim.
119    ///
120    /// Available as `jwt:email` in session variable mappings for RLS policies.
121    pub email: Option<String>,
122
123    /// Normalised display name from the JWT `name` claim.
124    ///
125    /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
126    pub display_name: Option<String>,
127}
128
129impl SecurityContext {
130    /// Attribute key under which the originating request's full W3C trace context
131    /// is carried (a JSON object), used to populate the change-log `trace_context`
132    /// JSONB column (#375).
133    pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
134    /// Attribute key under which the originating request's W3C trace id is
135    /// stamped. Set by the server's request pipeline from the inbound
136    /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
137    pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
138
139    /// Create a security context from an authenticated user and request metadata.
140    ///
141    /// # Arguments
142    ///
143    /// * `user` - Authenticated user from JWT validation
144    /// * `request_id` - Correlation ID for this request
145    ///
146    /// # Example
147    ///
148    /// ```no_run
149    /// // Requires: a live AuthenticatedUser from JWT validation.
150    /// // See: tests/integration/ for runnable examples.
151    /// # use fraiseql_core::security::SecurityContext;
152    /// # use fraiseql_core::security::AuthenticatedUser;
153    /// # let authenticated_user: AuthenticatedUser = panic!("example");
154    /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
155    /// ```
156    #[must_use]
157    pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
158        SecurityContext {
159            user_id: user.user_id.clone(),
160            roles: vec![], // Will be populated from JWT claims
161            tenant_id: None,
162            scopes: user.scopes.clone(),
163            attributes: HashMap::new(),
164            request_id,
165            ip_address: None,
166            authenticated_at: Utc::now(),
167            expires_at: user.expires_at,
168            issuer: None,
169            audience: None,
170            email: user.email.clone(),
171            display_name: user.display_name.clone(),
172        }
173    }
174
175    /// Check if the user has a specific role.
176    ///
177    /// # Arguments
178    ///
179    /// * `role` - Role name to check (e.g., "admin", "moderator")
180    ///
181    /// # Returns
182    ///
183    /// `true` if the user has the specified role, `false` otherwise.
184    #[must_use]
185    pub fn has_role(&self, role: &str) -> bool {
186        self.roles.iter().any(|r| r == role)
187    }
188
189    /// Check if the user has a specific scope.
190    ///
191    /// Supports wildcards: `admin:*` matches any admin scope.
192    ///
193    /// # Arguments
194    ///
195    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
196    ///
197    /// # Returns
198    ///
199    /// `true` if the user has the specified scope, `false` otherwise.
200    #[must_use]
201    pub fn has_scope(&self, scope: &str) -> bool {
202        self.scopes.iter().any(|s| {
203            if s == scope {
204                return true;
205            }
206            // Support wildcard matching: "admin:*" matches "admin:read"
207            if s.ends_with(':') {
208                scope.starts_with(s)
209            } else if s.ends_with('*') {
210                let prefix = &s[..s.len() - 1];
211                scope.starts_with(prefix)
212            } else {
213                false
214            }
215        })
216    }
217
218    /// Get a custom attribute from the JWT claims.
219    ///
220    /// # Arguments
221    ///
222    /// * `key` - Attribute name
223    ///
224    /// # Returns
225    ///
226    /// The attribute value if present, `None` otherwise.
227    #[must_use]
228    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
229        self.attributes.get(key)
230    }
231
232    /// The originating request's W3C trace id, when the server stamped one from
233    /// the inbound `traceparent` header.
234    ///
235    /// Used to populate the change-log `trace_id` column so an outbox row links
236    /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
237    /// the investigation handle. `None` when the request carried no trace context.
238    #[must_use]
239    pub fn trace_id(&self) -> Option<&str> {
240        self.attributes
241            .get(Self::TRACE_ID_ATTRIBUTE)
242            .and_then(serde_json::Value::as_str)
243    }
244
245    /// Stamp the originating request's W3C trace id onto the context (carried in
246    /// `attributes`). Read back via [`trace_id`](Self::trace_id).
247    #[must_use]
248    pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
249        self.attributes.insert(
250            Self::TRACE_ID_ATTRIBUTE.to_string(),
251            serde_json::Value::String(trace_id.into()),
252        );
253        self
254    }
255
256    /// The originating request's full W3C trace context (a JSON object), when the
257    /// server stamped one from the inbound `traceparent`/`tracestate` headers.
258    ///
259    /// Used to populate the change-log `trace_context` JSONB column so an outbox
260    /// row carries enough to re-propagate the distributed trace (#375). `None` when
261    /// the request carried no valid trace context.
262    #[must_use]
263    pub fn trace_context(&self) -> Option<&serde_json::Value> {
264        self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
265    }
266
267    /// Stamp the originating request's full W3C trace context (a JSON object) onto
268    /// the context (carried in `attributes`). Read back via
269    /// [`trace_context`](Self::trace_context).
270    #[must_use]
271    pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
272        self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
273        self
274    }
275
276    /// Check if the token has expired.
277    ///
278    /// # Returns
279    ///
280    /// `true` if the JWT has expired, `false` otherwise.
281    #[must_use]
282    pub fn is_expired(&self) -> bool {
283        self.expires_at <= Utc::now()
284    }
285
286    /// Get time until expiry in seconds.
287    ///
288    /// # Returns
289    ///
290    /// Seconds until JWT expiry, negative if already expired.
291    #[must_use]
292    pub fn ttl_secs(&self) -> i64 {
293        (self.expires_at - Utc::now()).num_seconds()
294    }
295
296    /// Check if the user is an admin.
297    ///
298    /// # Returns
299    ///
300    /// `true` if the user has the "admin" role, `false` otherwise.
301    #[must_use]
302    pub fn is_admin(&self) -> bool {
303        self.has_role("admin")
304    }
305
306    /// Check if the context has a tenant ID (multi-tenancy enabled).
307    ///
308    /// # Returns
309    ///
310    /// `true` if `tenant_id` is present, `false` otherwise.
311    #[must_use]
312    pub const fn is_multi_tenant(&self) -> bool {
313        self.tenant_id.is_some()
314    }
315
316    /// Set or override a role (for testing or runtime role modification).
317    #[must_use]
318    pub fn with_role(mut self, role: String) -> Self {
319        self.roles.push(role);
320        self
321    }
322
323    /// Set or override scopes (for testing or runtime permission modification).
324    #[must_use]
325    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
326        self.scopes = scopes;
327        self
328    }
329
330    /// Set tenant ID (for multi-tenancy).
331    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
332        self.tenant_id = Some(tenant_id.into());
333        self
334    }
335
336    /// Set a custom attribute (for testing or runtime attribute addition).
337    #[must_use]
338    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
339        self.attributes.insert(key, value);
340        self
341    }
342
343    /// Check if user can access a field based on role definitions.
344    ///
345    /// Takes a required scope and checks if any of the user's roles grant that scope.
346    ///
347    /// # Arguments
348    ///
349    /// * `security_config` - Security config from compiled schema with role definitions
350    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
351    ///
352    /// # Returns
353    ///
354    /// `true` if user's roles grant the required scope, `false` otherwise.
355    ///
356    /// # Example
357    ///
358    /// ```no_run
359    /// // Requires: a SecurityConfig from a compiled schema.
360    /// // See: tests/integration/ for runnable examples.
361    /// # use fraiseql_core::security::SecurityContext;
362    /// # use fraiseql_core::schema::SecurityConfig;
363    /// # let context: SecurityContext = panic!("example");
364    /// # let config: SecurityConfig = panic!("example");
365    /// let can_access = context.can_access_scope(&config, "read:User.email");
366    /// ```
367    #[must_use]
368    pub fn can_access_scope(
369        &self,
370        security_config: &crate::schema::SecurityConfig,
371        required_scope: &str,
372    ) -> bool {
373        // Check if any of user's roles grant this scope
374        self.roles
375            .iter()
376            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
377    }
378}
379
380impl std::fmt::Display for SecurityContext {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        write!(
383            f,
384            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
385            self.user_id,
386            self.roles,
387            self.scopes.len(),
388            self.tenant_id
389        )
390    }
391}