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}