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    /// Create a security context from an authenticated user and request metadata.
131    ///
132    /// # Arguments
133    ///
134    /// * `user` - Authenticated user from JWT validation
135    /// * `request_id` - Correlation ID for this request
136    ///
137    /// # Example
138    ///
139    /// ```no_run
140    /// // Requires: a live AuthenticatedUser from JWT validation.
141    /// // See: tests/integration/ for runnable examples.
142    /// # use fraiseql_core::security::SecurityContext;
143    /// # use fraiseql_core::security::AuthenticatedUser;
144    /// # let authenticated_user: AuthenticatedUser = panic!("example");
145    /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
146    /// ```
147    #[must_use]
148    pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
149        SecurityContext {
150            user_id: user.user_id.clone(),
151            roles: vec![], // Will be populated from JWT claims
152            tenant_id: None,
153            scopes: user.scopes.clone(),
154            attributes: HashMap::new(),
155            request_id,
156            ip_address: None,
157            authenticated_at: Utc::now(),
158            expires_at: user.expires_at,
159            issuer: None,
160            audience: None,
161            email: user.email.clone(),
162            display_name: user.display_name.clone(),
163        }
164    }
165
166    /// Check if the user has a specific role.
167    ///
168    /// # Arguments
169    ///
170    /// * `role` - Role name to check (e.g., "admin", "moderator")
171    ///
172    /// # Returns
173    ///
174    /// `true` if the user has the specified role, `false` otherwise.
175    #[must_use]
176    pub fn has_role(&self, role: &str) -> bool {
177        self.roles.iter().any(|r| r == role)
178    }
179
180    /// Check if the user has a specific scope.
181    ///
182    /// Supports wildcards: `admin:*` matches any admin scope.
183    ///
184    /// # Arguments
185    ///
186    /// * `scope` - Scope to check (e.g., "read:user", "write:post")
187    ///
188    /// # Returns
189    ///
190    /// `true` if the user has the specified scope, `false` otherwise.
191    #[must_use]
192    pub fn has_scope(&self, scope: &str) -> bool {
193        self.scopes.iter().any(|s| {
194            if s == scope {
195                return true;
196            }
197            // Support wildcard matching: "admin:*" matches "admin:read"
198            if s.ends_with(':') {
199                scope.starts_with(s)
200            } else if s.ends_with('*') {
201                let prefix = &s[..s.len() - 1];
202                scope.starts_with(prefix)
203            } else {
204                false
205            }
206        })
207    }
208
209    /// Get a custom attribute from the JWT claims.
210    ///
211    /// # Arguments
212    ///
213    /// * `key` - Attribute name
214    ///
215    /// # Returns
216    ///
217    /// The attribute value if present, `None` otherwise.
218    #[must_use]
219    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
220        self.attributes.get(key)
221    }
222
223    /// Check if the token has expired.
224    ///
225    /// # Returns
226    ///
227    /// `true` if the JWT has expired, `false` otherwise.
228    #[must_use]
229    pub fn is_expired(&self) -> bool {
230        self.expires_at <= Utc::now()
231    }
232
233    /// Get time until expiry in seconds.
234    ///
235    /// # Returns
236    ///
237    /// Seconds until JWT expiry, negative if already expired.
238    #[must_use]
239    pub fn ttl_secs(&self) -> i64 {
240        (self.expires_at - Utc::now()).num_seconds()
241    }
242
243    /// Check if the user is an admin.
244    ///
245    /// # Returns
246    ///
247    /// `true` if the user has the "admin" role, `false` otherwise.
248    #[must_use]
249    pub fn is_admin(&self) -> bool {
250        self.has_role("admin")
251    }
252
253    /// Check if the context has a tenant ID (multi-tenancy enabled).
254    ///
255    /// # Returns
256    ///
257    /// `true` if `tenant_id` is present, `false` otherwise.
258    #[must_use]
259    pub const fn is_multi_tenant(&self) -> bool {
260        self.tenant_id.is_some()
261    }
262
263    /// Set or override a role (for testing or runtime role modification).
264    #[must_use]
265    pub fn with_role(mut self, role: String) -> Self {
266        self.roles.push(role);
267        self
268    }
269
270    /// Set or override scopes (for testing or runtime permission modification).
271    #[must_use]
272    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
273        self.scopes = scopes;
274        self
275    }
276
277    /// Set tenant ID (for multi-tenancy).
278    pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
279        self.tenant_id = Some(tenant_id.into());
280        self
281    }
282
283    /// Set a custom attribute (for testing or runtime attribute addition).
284    #[must_use]
285    pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
286        self.attributes.insert(key, value);
287        self
288    }
289
290    /// Check if user can access a field based on role definitions.
291    ///
292    /// Takes a required scope and checks if any of the user's roles grant that scope.
293    ///
294    /// # Arguments
295    ///
296    /// * `security_config` - Security config from compiled schema with role definitions
297    /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
298    ///
299    /// # Returns
300    ///
301    /// `true` if user's roles grant the required scope, `false` otherwise.
302    ///
303    /// # Example
304    ///
305    /// ```no_run
306    /// // Requires: a SecurityConfig from a compiled schema.
307    /// // See: tests/integration/ for runnable examples.
308    /// # use fraiseql_core::security::SecurityContext;
309    /// # use fraiseql_core::schema::SecurityConfig;
310    /// # let context: SecurityContext = panic!("example");
311    /// # let config: SecurityConfig = panic!("example");
312    /// let can_access = context.can_access_scope(&config, "read:User.email");
313    /// ```
314    #[must_use]
315    pub fn can_access_scope(
316        &self,
317        security_config: &crate::schema::SecurityConfig,
318        required_scope: &str,
319    ) -> bool {
320        // Check if any of user's roles grant this scope
321        self.roles
322            .iter()
323            .any(|role_name| security_config.role_has_scope(role_name, required_scope))
324    }
325}
326
327impl std::fmt::Display for SecurityContext {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        write!(
330            f,
331            "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
332            self.user_id,
333            self.roles,
334            self.scopes.len(),
335            self.tenant_id
336        )
337    }
338}