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/// Extract the user's roles from the (signature-verified) JWT custom claims.
41///
42/// Reads, in order, the scalar `role` claim, the `roles` array, and the
43/// `fraiseql_roles` array, then sorts and de-duplicates. This mirrors the
44/// claim names honoured by `fraiseql_auth::operation_rbac` so the GraphQL
45/// `requires_role` gate and the observer-admin RBAC engine agree on what a
46/// token grants. Without this, `SecurityContext.roles` was always empty and
47/// every `requires_role` operation was unreachable over HTTP (#503).
48fn roles_from_claims(extra_claims: &HashMap<String, serde_json::Value>) -> Vec<String> {
49 let mut roles = Vec::new();
50
51 if let Some(serde_json::Value::String(role)) = extra_claims.get("role") {
52 roles.push(role.clone());
53 }
54
55 for key in ["roles", "fraiseql_roles"] {
56 if let Some(serde_json::Value::Array(values)) = extra_claims.get(key) {
57 roles.extend(values.iter().filter_map(|v| match v {
58 serde_json::Value::String(role) => Some(role.clone()),
59 _ => None,
60 }));
61 }
62 }
63
64 roles.sort();
65 roles.dedup();
66 roles
67}
68
69/// Security context for authorization evaluation.
70///
71/// Carries information about the authenticated user and their permissions
72/// throughout the request lifecycle.
73///
74/// # Fields
75///
76/// - `user_id`: Unique identifier for the authenticated user (from JWT 'sub' claim)
77/// - `roles`: User's roles (e.g., `["admin", "moderator"]`, from JWT 'roles' claim)
78/// - `tenant_id`: Organization/tenant identifier for multi-tenant systems
79/// - `scopes`: OAuth/permission scopes (e.g., `["read:user", "write:post"]`)
80/// - `attributes`: Custom claims from JWT (e.g., department, region, tier)
81/// - `request_id`: Correlation ID for audit logging and tracing
82/// - `ip_address`: Client IP address for geolocation and fraud detection
83/// - `authenticated_at`: When the JWT was issued
84/// - `expires_at`: When the JWT expires
85/// - `issuer`: Token issuer for multi-issuer systems
86/// - `audience`: Token audience for validation
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SecurityContext {
89 /// User ID (from JWT 'sub' claim)
90 pub user_id: UserId,
91
92 /// User's roles (e.g., `["admin", "moderator"]`)
93 ///
94 /// Extracted from JWT 'roles' claim or derived from other claims.
95 /// Used for role-based access control (RBAC) decisions.
96 pub roles: Vec<String>,
97
98 /// Tenant/organization ID (for multi-tenancy)
99 ///
100 /// When present, RLS policies can enforce tenant isolation.
101 /// Extracted from JWT '`tenant_id`' or X-Tenant-Id header.
102 pub tenant_id: Option<TenantId>,
103
104 /// OAuth/permission scopes
105 ///
106 /// Format: `{action}:{resource}` or `{action}:{type}.{field}`
107 /// Examples:
108 /// - `read:user`
109 /// - `write:post`
110 /// - `read:User.email`
111 /// - `admin:*`
112 ///
113 /// Extracted from JWT 'scope' claim.
114 pub scopes: Vec<String>,
115
116 /// Custom attributes from JWT claims
117 ///
118 /// Arbitrary key-value pairs from JWT payload.
119 /// Examples: "department", "region", "tier", "country"
120 ///
121 /// Used by custom RLS policies that need domain-specific attributes.
122 pub attributes: HashMap<String, serde_json::Value>,
123
124 /// Request correlation ID for audit trails
125 ///
126 /// Extracted from X-Request-Id header or generated.
127 /// Used for tracing and audit logging across services.
128 pub request_id: String,
129
130 /// Client IP address
131 ///
132 /// Extracted from X-Forwarded-For or connection socket.
133 /// Used for geolocation and fraud detection in RLS policies.
134 pub ip_address: Option<String>,
135
136 /// When the JWT was issued
137 pub authenticated_at: DateTime<Utc>,
138
139 /// When the JWT expires
140 pub expires_at: DateTime<Utc>,
141
142 /// Token issuer (for multi-issuer systems)
143 pub issuer: Option<String>,
144
145 /// Token audience (for audience validation)
146 pub audience: Option<String>,
147
148 /// Normalised email address from the JWT `email` claim.
149 ///
150 /// Available as `jwt:email` in session variable mappings for RLS policies.
151 pub email: Option<String>,
152
153 /// Normalised display name from the JWT `name` claim.
154 ///
155 /// Available as `jwt:name` or `jwt:display_name` in session variable mappings.
156 pub display_name: Option<String>,
157}
158
159impl SecurityContext {
160 /// Attribute key under which the delegated user's UUID is carried (string
161 /// form), for an agent request acting on behalf of a human. Read back via
162 /// [`acting_for`](Self::acting_for) (#390).
163 pub const ACTING_FOR_ATTRIBUTE: &'static str = "fraiseql.acting_for";
164 /// Attribute key under which the request's [`ActorType`] is carried (the
165 /// `snake_case` token), derived at [`from_user`](Self::from_user) and read
166 /// back via [`actor_type`](Self::actor_type) (#390).
167 pub const ACTOR_TYPE_ATTRIBUTE: &'static str = "fraiseql.actor_type";
168 /// Attribute key under which the originating request's full W3C trace context
169 /// is carried (a JSON object), used to populate the change-log `trace_context`
170 /// JSONB column (#375).
171 pub const TRACE_CONTEXT_ATTRIBUTE: &'static str = "fraiseql.trace_context";
172 /// Attribute key under which the originating request's W3C trace id is
173 /// stamped. Set by the server's request pipeline from the inbound
174 /// `traceparent` header; read back via [`trace_id`](Self::trace_id).
175 pub const TRACE_ID_ATTRIBUTE: &'static str = "fraiseql.trace_id";
176
177 /// Create a security context from an authenticated user and request metadata.
178 ///
179 /// # Arguments
180 ///
181 /// * `user` - Authenticated user from JWT validation
182 /// * `request_id` - Correlation ID for this request
183 ///
184 /// # Example
185 ///
186 /// ```no_run
187 /// // Requires: a live AuthenticatedUser from JWT validation.
188 /// // See: tests/integration/ for runnable examples.
189 /// # use fraiseql_core::security::SecurityContext;
190 /// # use fraiseql_core::security::AuthenticatedUser;
191 /// # let authenticated_user: AuthenticatedUser = panic!("example");
192 /// let context = SecurityContext::from_user(&authenticated_user, "req-123".to_string());
193 /// ```
194 #[must_use]
195 pub fn from_user(user: &AuthenticatedUser, request_id: String) -> Self {
196 // Classify the actor from the (signature-verified) JWT material at
197 // construction time, so every auth path (OIDC, HS256, gRPC, MCP) that
198 // builds a context from a user gets it. The API-key path overrides to
199 // ServiceAccount at its own construction site (#390).
200 let (actor_type, acting_for) =
201 derive_actor(user.user_id.as_str(), &user.scopes, &user.extra_claims);
202 SecurityContext {
203 user_id: user.user_id.clone(),
204 roles: roles_from_claims(&user.extra_claims),
205 tenant_id: None,
206 scopes: user.scopes.clone(),
207 attributes: HashMap::new(),
208 request_id,
209 ip_address: None,
210 authenticated_at: Utc::now(),
211 expires_at: user.expires_at,
212 issuer: None,
213 audience: None,
214 email: user.email.clone(),
215 display_name: user.display_name.clone(),
216 }
217 .with_actor_type(actor_type)
218 .with_acting_for(acting_for)
219 }
220
221 /// Construct the context for an internal scheduled / system job (#573 D6).
222 ///
223 /// Unlike [`from_user`](Self::from_user), this carries **no JWT and no request**
224 /// — it is the server acting *as itself* for background work: a scheduled
225 /// [`Source`](crate::schema::SourceDefinition), and (in future) `after:ingest`
226 /// handlers and saga steps. It is the first and, at introduction, only
227 /// construction of [`ActorType::SystemJob`].
228 ///
229 /// Its authority is **exactly** the `roles` + `scopes` the caller grants (the
230 /// source's `run_as` ceiling). With neither, it is **fail-closed**: no roles and
231 /// no scopes mean every RBAC / field-authz check denies and — with no `tenant` —
232 /// tenant-scoped RLS admits nothing. A source therefore cannot write until an
233 /// operator grants it a ceiling.
234 ///
235 /// `tenant` scopes writes for a single-tenant or global source (`None` = global
236 /// / NULL tenant); a multi-tenant source leaves it `None` and re-scopes each
237 /// write per message via [`with_tenant`](Self::with_tenant). `job_id` names the
238 /// job (the source name) and becomes the `system_job:<id>` principal for the
239 /// audit envelope; `request_id` correlates a single firing.
240 ///
241 /// The [`ActorType`] is *recorded* for audit, never an authorization input — the
242 /// authority comes solely from `roles`/`scopes`.
243 #[must_use]
244 pub fn system_job(
245 job_id: impl Into<String>,
246 request_id: impl Into<String>,
247 roles: Vec<String>,
248 scopes: Vec<String>,
249 tenant: Option<TenantId>,
250 ) -> Self {
251 let now = Utc::now();
252 SecurityContext {
253 user_id: UserId(format!("system_job:{}", job_id.into())),
254 roles,
255 tenant_id: tenant,
256 scopes,
257 attributes: HashMap::new(),
258 request_id: request_id.into(),
259 ip_address: None,
260 authenticated_at: now,
261 // A background job's identity is minted fresh per firing and does not
262 // outlive the run; a generous window keeps any TTL check from tripping
263 // mid-poll (mirrors the live host's default context).
264 expires_at: now + chrono::Duration::hours(24),
265 issuer: None,
266 audience: None,
267 email: None,
268 display_name: None,
269 }
270 .with_actor_type(ActorType::SystemJob)
271 }
272
273 /// Check if the user has a specific role.
274 ///
275 /// # Arguments
276 ///
277 /// * `role` - Role name to check (e.g., "admin", "moderator")
278 ///
279 /// # Returns
280 ///
281 /// `true` if the user has the specified role, `false` otherwise.
282 #[must_use]
283 pub fn has_role(&self, role: &str) -> bool {
284 self.roles.iter().any(|r| r == role)
285 }
286
287 /// Check if the user has a specific scope.
288 ///
289 /// Supports wildcards: `admin:*` matches any admin scope.
290 ///
291 /// # Arguments
292 ///
293 /// * `scope` - Scope to check (e.g., "read:user", "write:post")
294 ///
295 /// # Returns
296 ///
297 /// `true` if the user has the specified scope, `false` otherwise.
298 #[must_use]
299 pub fn has_scope(&self, scope: &str) -> bool {
300 self.scopes.iter().any(|s| {
301 if s == scope {
302 return true;
303 }
304 // Support wildcard matching: "admin:*" matches "admin:read"
305 if s.ends_with(':') {
306 scope.starts_with(s)
307 } else if s.ends_with('*') {
308 let prefix = &s[..s.len() - 1];
309 scope.starts_with(prefix)
310 } else {
311 false
312 }
313 })
314 }
315
316 /// Get a custom attribute from the JWT claims.
317 ///
318 /// # Arguments
319 ///
320 /// * `key` - Attribute name
321 ///
322 /// # Returns
323 ///
324 /// The attribute value if present, `None` otherwise.
325 #[must_use]
326 pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
327 self.attributes.get(key)
328 }
329
330 /// The originating request's W3C trace id, when the server stamped one from
331 /// the inbound `traceparent` header.
332 ///
333 /// Used to populate the change-log `trace_id` column so an outbox row links
334 /// back to its distributed trace (#375); the #392 perf tooling surfaces it as
335 /// the investigation handle. `None` when the request carried no trace context.
336 #[must_use]
337 pub fn trace_id(&self) -> Option<&str> {
338 self.attributes
339 .get(Self::TRACE_ID_ATTRIBUTE)
340 .and_then(serde_json::Value::as_str)
341 }
342
343 /// Stamp the originating request's W3C trace id onto the context (carried in
344 /// `attributes`). Read back via [`trace_id`](Self::trace_id).
345 #[must_use]
346 pub fn with_trace_id(mut self, trace_id: impl Into<String>) -> Self {
347 self.attributes.insert(
348 Self::TRACE_ID_ATTRIBUTE.to_string(),
349 serde_json::Value::String(trace_id.into()),
350 );
351 self
352 }
353
354 /// The originating request's full W3C trace context (a JSON object), when the
355 /// server stamped one from the inbound `traceparent`/`tracestate` headers.
356 ///
357 /// Used to populate the change-log `trace_context` JSONB column so an outbox
358 /// row carries enough to re-propagate the distributed trace (#375). `None` when
359 /// the request carried no valid trace context.
360 #[must_use]
361 pub fn trace_context(&self) -> Option<&serde_json::Value> {
362 self.attributes.get(Self::TRACE_CONTEXT_ATTRIBUTE)
363 }
364
365 /// Stamp the originating request's full W3C trace context (a JSON object) onto
366 /// the context (carried in `attributes`). Read back via
367 /// [`trace_context`](Self::trace_context).
368 #[must_use]
369 pub fn with_trace_context(mut self, trace_context: serde_json::Value) -> Self {
370 self.attributes.insert(Self::TRACE_CONTEXT_ATTRIBUTE.to_string(), trace_context);
371 self
372 }
373
374 /// The request's actor classification (#390). Derived at
375 /// [`from_user`](Self::from_user); [`ActorType::HumanUser`] when unset (e.g. a
376 /// context built directly without derivation).
377 #[must_use]
378 pub fn actor_type(&self) -> ActorType {
379 self.attributes
380 .get(Self::ACTOR_TYPE_ATTRIBUTE)
381 .and_then(serde_json::Value::as_str)
382 .and_then(ActorType::from_token)
383 .unwrap_or_default()
384 }
385
386 /// Stamp the request's [`ActorType`] onto the context (carried in
387 /// `attributes`). Read back via [`actor_type`](Self::actor_type).
388 #[must_use]
389 pub fn with_actor_type(mut self, actor_type: ActorType) -> Self {
390 self.attributes.insert(
391 Self::ACTOR_TYPE_ATTRIBUTE.to_string(),
392 serde_json::Value::String(actor_type.as_str().to_string()),
393 );
394 self
395 }
396
397 /// The delegated user (the human a delegated agent acts for), when the request
398 /// carried an RFC 8693 `act` claim (#390). `None` for a non-delegated request,
399 /// or when the underlying subject was not UUID-shaped.
400 #[must_use]
401 pub fn acting_for(&self) -> Option<Uuid> {
402 self.attributes
403 .get(Self::ACTING_FOR_ATTRIBUTE)
404 .and_then(serde_json::Value::as_str)
405 .and_then(|s| Uuid::parse_str(s).ok())
406 }
407
408 /// Stamp the delegated user's UUID onto the context (carried in `attributes`).
409 /// `None` removes any prior stamp. Read back via [`acting_for`](Self::acting_for).
410 #[must_use]
411 pub fn with_acting_for(mut self, acting_for: Option<Uuid>) -> Self {
412 match acting_for {
413 Some(uuid) => {
414 self.attributes.insert(
415 Self::ACTING_FOR_ATTRIBUTE.to_string(),
416 serde_json::Value::String(uuid.to_string()),
417 );
418 },
419 None => {
420 self.attributes.remove(Self::ACTING_FOR_ATTRIBUTE);
421 },
422 }
423 self
424 }
425
426 /// Check if the token has expired.
427 ///
428 /// # Returns
429 ///
430 /// `true` if the JWT has expired, `false` otherwise.
431 #[must_use]
432 pub fn is_expired(&self) -> bool {
433 self.expires_at <= Utc::now()
434 }
435
436 /// Get time until expiry in seconds.
437 ///
438 /// # Returns
439 ///
440 /// Seconds until JWT expiry, negative if already expired.
441 #[must_use]
442 pub fn ttl_secs(&self) -> i64 {
443 (self.expires_at - Utc::now()).num_seconds()
444 }
445
446 /// Check if the user is an admin.
447 ///
448 /// # Returns
449 ///
450 /// `true` if the user has the "admin" role, `false` otherwise.
451 #[must_use]
452 pub fn is_admin(&self) -> bool {
453 self.has_role("admin")
454 }
455
456 /// Check if the context has a tenant ID (multi-tenancy enabled).
457 ///
458 /// # Returns
459 ///
460 /// `true` if `tenant_id` is present, `false` otherwise.
461 #[must_use]
462 pub const fn is_multi_tenant(&self) -> bool {
463 self.tenant_id.is_some()
464 }
465
466 /// Set or override a role (for testing or runtime role modification).
467 #[must_use]
468 pub fn with_role(mut self, role: String) -> Self {
469 self.roles.push(role);
470 self
471 }
472
473 /// Set or override scopes (for testing or runtime permission modification).
474 #[must_use]
475 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
476 self.scopes = scopes;
477 self
478 }
479
480 /// Set tenant ID (for multi-tenancy).
481 pub fn with_tenant(mut self, tenant_id: impl Into<TenantId>) -> Self {
482 self.tenant_id = Some(tenant_id.into());
483 self
484 }
485
486 /// Set a custom attribute (for testing or runtime attribute addition).
487 #[must_use]
488 pub fn with_attribute(mut self, key: String, value: serde_json::Value) -> Self {
489 self.attributes.insert(key, value);
490 self
491 }
492
493 /// Check if user can access a field based on role definitions.
494 ///
495 /// Takes a required scope and checks if any of the user's roles grant that scope.
496 ///
497 /// # Arguments
498 ///
499 /// * `security_config` - Security config from compiled schema with role definitions
500 /// * `required_scope` - Scope required to access the field (e.g., "read:User.email")
501 ///
502 /// # Returns
503 ///
504 /// `true` if user's roles grant the required scope, `false` otherwise.
505 ///
506 /// # Example
507 ///
508 /// ```no_run
509 /// // Requires: a SecurityConfig from a compiled schema.
510 /// // See: tests/integration/ for runnable examples.
511 /// # use fraiseql_core::security::SecurityContext;
512 /// # use fraiseql_core::schema::SecurityConfig;
513 /// # let context: SecurityContext = panic!("example");
514 /// # let config: SecurityConfig = panic!("example");
515 /// let can_access = context.can_access_scope(&config, "read:User.email");
516 /// ```
517 #[must_use]
518 pub fn can_access_scope(
519 &self,
520 security_config: &crate::schema::SecurityConfig,
521 required_scope: &str,
522 ) -> bool {
523 // Check if any of user's roles grant this scope
524 self.roles
525 .iter()
526 .any(|role_name| security_config.role_has_scope(role_name, required_scope))
527 }
528}
529
530impl std::fmt::Display for SecurityContext {
531 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532 write!(
533 f,
534 "SecurityContext(user_id={}, roles={:?}, scopes={}, tenant={:?})",
535 self.user_id,
536 self.roles,
537 self.scopes.len(),
538 self.tenant_id
539 )
540 }
541}