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