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};
33use uuid::Uuid;
34
35use crate::{
36 security::{ActorType, AuthenticatedUser, derive_actor},
37 types::{TenantId, UserId},
38};
39
40/// Security context for authorization evaluation.
41///
42/// Carries information about the authenticated user and their permissions
43/// throughout the request lifecycle.
44///
45/// # Fields
46///
47/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
48/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
49/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
50/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
51/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
52/// - `request_id`: Correlation ID for audit logging and tracing
53/// - `ip_address`: Client IP address for geolocation and fraud detection
54/// - `authenticated_at`: When the JWT was issued
55/// - `expires_at`: When the JWT expires
56/// - `issuer`: Token issuer for multi-issuer systems
57/// - `audience`: Token audience for validation
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct SecurityContext {
60 /// User ID (from JWT 'sub' claim)
61 pub user_id: UserId,
62
63 /// User's roles (e.g., `["admin", "moderator"]`)
64 ///
65 /// Extracted from JWT 'roles' claim or derived from other claims.
66 /// Used for role-based access control (RBAC) decisions.
67 pub roles: Vec<String>,
68
69 /// Tenant/organization ID (for multi-tenancy)
70 ///
71 /// When present, RLS policies can enforce tenant isolation.
72 /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
73 pub tenant_id: Option<TenantId>,
74
75 /// OAuth/permission scopes
76 ///
77 /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
78 /// Examples:
79 /// - `read:user`
80 /// - `write:post`
81 /// - `read:User.email`
82 /// - `admin:*`
83 ///
84 /// Extracted from JWT 'scope' claim.
85 pub scopes: Vec<String>,
86
87 /// Custom attributes from JWT claims
88 ///
89 /// Arbitrary key-value pairs from JWT payload.
90 /// Examples: "department", "region", "tier", "country"
91 ///
92 /// Used by custom RLS policies that need domain-specific attributes.
93 pub attributes: HashMap<String, serde_json::Value>,
94
95 /// Request correlation ID for audit trails
96 ///
97 /// Extracted from X-Request-Id header or generated.
98 /// Used for tracing and audit logging across services.
99 pub request_id: String,
100
101 /// Client IP address
102 ///
103 /// Extracted from X-Forwarded-For or connection socket.
104 /// Used for geolocation and fraud detection in RLS policies.
105 pub ip_address: Option<String>,
106
107 /// When the JWT was issued
108 pub authenticated_at: DateTime<Utc>,
109
110 /// When the JWT expires
111 pub expires_at: DateTime<Utc>,
112
113 /// Token issuer (for multi-issuer systems)
114 pub issuer: Option<String>,
115
116 /// Token audience (for audience validation)
117 pub audience: Option<String>,
118
119 /// Normalised email address from the JWT `email` claim.
120 ///
121 /// Available as `jwt:email` in session variable mappings for RLS policies.
122 pub email: Option<String>,
123
124 /// Normalised display name from the JWT `name` claim.
125 ///
126 /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
127 pub display_name: Option<String>,
128}
129
130impl SecurityContext {
131 /// Attribute key under which the delegated user's UUID is carried (string
132 /// form), for an agent request acting on behalf of a human. Read back via
133 /// [`acting_for`](Self::acting_for) (#390).
134 pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
135 /// Attribute key under which the request's [`ActorType`] is carried (the
136 /// `snake_case` token), derived at [`from_user`](Self::from_user) and read
137 /// back via [`actor_type`](Self::actor_type) (#390).
138 pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
139 /// Attribute key under which the originating request's full W3C trace context
140 /// is carried (a JSON object), used to populate the change-log `trace_context`
141 /// JSONB column (#375).
142 pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
143 /// Attribute key under which the originating request's W3C trace id is
144 /// stamped. Set by the server's request pipeline from the inbound
145 /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
146 pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
147
148 /// Create a security context from an authenticated user and request metadata.
149 ///
150 /// # Arguments
151 ///
152 /// * `user` - Authenticated user from JWT validation
153 /// * `request_id` - Correlation ID for this request
154 ///
155 /// # Example
156 ///
157 /// ```no_run
158 /// // Requires: a live AuthenticatedUser from JWT validation.
159 /// // See: tests/integration/ for runnable examples.
160 /// # use fraiseql_core::security::SecurityContext;
161 /// # use fraiseql_core::security::AuthenticatedUser;
162 /// # let authenticated_user: AuthenticatedUser = panic!("example");
163 /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
164 /// ```
165 #[must_use]
166 pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
167 // Classify the actor from the (signature-verified) JWT material at
168 // construction time, so every auth path (OIDC, HS256, gRPC, MCP) that
169 // builds a context from a user gets it. The API-key path overrides to
170 // ServiceAccount at its own construction site (#390).
171 let (actor_type, acting_for) =
172 derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
173 SecurityContext {
174 user_id: user.user_id.clone(),
175 roles: vec![], // Will be populated from JWT claims
176 tenant_id: None,
177 scopes: user.scopes.clone(),
178 attributes: HashMap::new(),
179 request_id,
180 ip_address: None,
181 authenticated_at: Utc::now(),
182 expires_at: user.expires_at,
183 issuer: None,
184 audience: None,
185 email: user.email.clone(),
186 display_name: user.display_name.clone(),
187 }
188 .with_actor_type(actor_type)
189 .with_acting_for(acting_for)
190 }
191
192 /// Check if the user has a specific role.
193 ///
194 /// # Arguments
195 ///
196 /// * `role` - Role name to check (e.g., "admin", "moderator")
197 ///
198 /// # Returns
199 ///
200 /// `true` if the user has the specified role, `false` otherwise.
201 #[must_use]
202 pub fn has_role(&self, role: &str) -> bool {
203 self.roles.iter().any(|r| r == role)
204 }
205
206 /// Check if the user has a specific scope.
207 ///
208 /// Supports wildcards: `admin:*` matches any admin scope.
209 ///
210 /// # Arguments
211 ///
212 /// * `scope` - Scope to check (e.g., "read:user", "write:post")
213 ///
214 /// # Returns
215 ///
216 /// `true` if the user has the specified scope, `false` otherwise.
217 #[must_use]
218 pub fn has_scope(&self, scope: &str) -> bool {
219 self.scopes.iter().any(|s| {
220 if s == scope {
221 return true;
222 }
223 // Support wildcard matching: "admin:*" matches "admin:read"
224 if s.ends_with(':') {
225 scope.starts_with(s)
226 } else if s.ends_with('*') {
227 let prefix = &s[..s.len() - 1];
228 scope.starts_with(prefix)
229 } else {
230 false
231 }
232 })
233 }
234
235 /// Get a custom attribute from the JWT claims.
236 ///
237 /// # Arguments
238 ///
239 /// * `key` - Attribute name
240 ///
241 /// # Returns
242 ///
243 /// The attribute value if present, `None` otherwise.
244 #[must_use]
245 pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
246 self.attributes.get(key)
247 }
248
249 /// The originating request's W3C trace id, when the server stamped one from
250 /// the inbound `traceparent` header.
251 ///
252 /// Used to populate the change-log `trace_id` column so an outbox row links
253 /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
254 /// the investigation handle. `None` when the request carried no trace context.
255 #[must_use]
256 pub fn trace_id(&self) -> Option<&str> {
257 self.attributes
258 .get(Self::TRACE_ID_ATTRIBUTE)
259 .and_then(serde_json::Value::as_str)
260 }
261
262 /// Stamp the originating request's W3C trace id onto the context (carried in
263 /// `attributes`). Read back via [`trace_id`](Self::trace_id).
264 #[must_use]
265 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
266 self.attributes.insert(
267 Self::TRACE_ID_ATTRIBUTE.to_string(),
268 serde_json::Value::String(trace_id.into()),
269 );
270 self
271 }
272
273 /// The originating request's full W3C trace context (a JSON object), when the
274 /// server stamped one from the inbound `traceparent`/`tracestate` headers.
275 ///
276 /// Used to populate the change-log `trace_context` JSONB column so an outbox
277 /// row carries enough to re-propagate the distributed trace (#375). `None` when
278 /// the request carried no valid trace context.
279 #[must_use]
280 pub fn trace_context(&self) -> Option<&serde_json::Value> {
281 self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
282 }
283
284 /// Stamp the originating request's full W3C trace context (a JSON object) onto
285 /// the context (carried in `attributes`). Read back via
286 /// [`trace_context`](Self::trace_context).
287 #[must_use]
288 pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
289 self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
290 self
291 }
292
293 /// The request's actor classification (#390). Derived at
294 /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
295 /// context built directly without derivation).
296 #[must_use]
297 pub fn actor_type(&self) -> ActorType {
298 self.attributes
299 .get(Self::ACTOR_TYPE_ATTRIBUTE)
300 .and_then(serde_json::Value::as_str)
301 .and_then(ActorType::from_token)
302 .unwrap_or_default()
303 }
304
305 /// Stamp the request's [`ActorType`] onto the context (carried in
306 /// `attributes`). Read back via [`actor_type`](Self::actor_type).
307 #[must_use]
308 pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
309 self.attributes.insert(
310 Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
311 serde_json::Value::String(actor_type.as_str().to_string()),
312 );
313 self
314 }
315
316 /// The delegated user (the human a delegated agent acts for), when the request
317 /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
318 /// or when the underlying subject was not UUID-shaped.
319 #[must_use]
320 pub fn acting_for(&self) -> Option<Uuid> {
321 self.attributes
322 .get(Self::ACTING_FOR_ATTRIBUTE)
323 .and_then(serde_json::Value::as_str)
324 .and_then(|s| Uuid::parse_str(s).ok())
325 }
326
327 /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
328 /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
329 #[must_use]
330 pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
331 match acting_for {
332 Some(uuid) => {
333 self.attributes.insert(
334 Self::ACTING_FOR_ATTRIBUTE.to_string(),
335 serde_json::Value::String(uuid.to_string()),
336 );
337 },
338 None => {
339 self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
340 },
341 }
342 self
343 }
344
345 /// Check if the token has expired.
346 ///
347 /// # Returns
348 ///
349 /// `true` if the JWT has expired, `false` otherwise.
350 #[must_use]
351 pub fn is_expired(&self) -> bool {
352 self.expires_at <= Utc::now()
353 }
354
355 /// Get time until expiry in seconds.
356 ///
357 /// # Returns
358 ///
359 /// Seconds until JWT expiry, negative if already expired.
360 #[must_use]
361 pub fn ttl_secs(&self) -> i64 {
362 (self.expires_at - Utc::now()).num_seconds()
363 }
364
365 /// Check if the user is an admin.
366 ///
367 /// # Returns
368 ///
369 /// `true` if the user has the "admin" role, `false` otherwise.
370 #[must_use]
371 pub fn is_admin(&self) -> bool {
372 self.has_role("admin")
373 }
374
375 /// Check if the context has a tenant ID (multi-tenancy enabled).
376 ///
377 /// # Returns
378 ///
379 /// `true` if `tenant_id` is present, `false` otherwise.
380 #[must_use]
381 pub const fn is_multi_tenant(&self) -> bool {
382 self.tenant_id.is_some()
383 }
384
385 /// Set or override a role (for testing or runtime role modification).
386 #[must_use]
387 pub fn with_role(mut self, role: String) -> Self {
388 self.roles.push(role);
389 self
390 }
391
392 /// Set or override scopes (for testing or runtime permission modification).
393 #[must_use]
394 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
395 self.scopes = scopes;
396 self
397 }
398
399 /// Set tenant ID (for multi-tenancy).
400 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
401 self.tenant_id = Some(tenant_id.into());
402 self
403 }
404
405 /// Set a custom attribute (for testing or runtime attribute addition).
406 #[must_use]
407 pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
408 self.attributes.insert(key, value);
409 self
410 }
411
412 /// Check if user can access a field based on role definitions.
413 ///
414 /// Takes a required scope and checks if any of the user's roles grant that scope.
415 ///
416 /// # Arguments
417 ///
418 /// * `security_config` - Security config from compiled schema with role definitions
419 /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
420 ///
421 /// # Returns
422 ///
423 /// `true` if user's roles grant the required scope, `false` otherwise.
424 ///
425 /// # Example
426 ///
427 /// ```no_run
428 /// // Requires: a SecurityConfig from a compiled schema.
429 /// // See: tests/integration/ for runnable examples.
430 /// # use fraiseql_core::security::SecurityContext;
431 /// # use fraiseql_core::schema::SecurityConfig;
432 /// # let context: SecurityContext = panic!("example");
433 /// # let config: SecurityConfig = panic!("example");
434 /// let can_access = context.can_access_scope(&config, "read:User.email");
435 /// ```
436 #[must_use]
437 pub fn can_access_scope(
438 &self,
439 security_config: &crate::schema::SecurityConfig,
440 required_scope: &str,
441 ) -> bool {
442 // Check if any of user's roles grant this scope
443 self.roles
444 .iter()
445 .any(|role_name| security_config.role_has_scope(role_name, required_scope))
446 }
447}
448
449impl std::fmt::Display for SecurityContext {
450 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451 write!(
452 f,
453 "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
454 self.user_id,
455 self.roles,
456 self.scopes.len(),
457 self.tenant_id
458 )
459 }
460}