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}