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}