fraiseql_server/extractors.rs
1//! Custom extractors for GraphQL handlers.
2//!
3//! Provides extractors for `SecurityContext` and other request-level data.
4
5#[cfg(test)]
6mod tests;
7
8use std::{convert::Infallible, future::Future, net::SocketAddr};
9
10use axum::{
11 extract::{ConnectInfo, FromRequestParts, rejection::ExtensionRejection},
12 http::request::Parts,
13};
14use fraiseql_core::security::SecurityContext;
15
16use crate::middleware::AuthUser;
17
18/// Extractor for the TCP peer IP address.
19///
20/// Reads the peer address from `ConnectInfo<SocketAddr>` in request extensions.
21/// Returns only the IP part (no port), so connections from the same client share
22/// the same rate-limit key regardless of ephemeral port churn.
23///
24/// Falls back to `"unknown"` when:
25/// - The server was not started with `into_make_service_with_connect_info`
26/// - Running in test mode (direct `oneshot` without `ConnectInfo`)
27pub struct PeerIp(pub String);
28
29impl<S> FromRequestParts<S> for PeerIp
30where
31 S: Send + Sync,
32{
33 type Rejection = Infallible;
34
35 fn from_request_parts(
36 parts: &mut Parts,
37 _state: &S,
38 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
39 let ip = parts
40 .extensions
41 .get::<ConnectInfo<SocketAddr>>()
42 .map_or_else(|| "unknown".to_string(), |ci| ci.0.ip().to_string());
43 async move { Ok(PeerIp(ip)) }
44 }
45}
46
47/// Extractor for optional `SecurityContext` from authenticated user and headers.
48///
49/// When used in a handler, automatically extracts:
50/// 1. `AuthUser` from request extensions (if present)
51/// 2. Request metadata from HTTP headers (request ID, IP, tenant ID)
52/// 3. Creates `SecurityContext` from both
53///
54/// If authentication is not present, returns `None` (optional extraction).
55///
56/// # Example
57///
58/// ```text
59/// // Requires: running Axum server with authentication middleware configured.
60/// async fn graphql_handler(
61/// State(state): State<AppState>,
62/// OptionalSecurityContext(context): OptionalSecurityContext,
63/// ) -> Result<Response> {
64/// // context is Option<SecurityContext>
65/// }
66/// ```
67#[derive(Debug, Clone)]
68pub struct OptionalSecurityContext(pub Option<SecurityContext>);
69
70impl<S> FromRequestParts<S> for OptionalSecurityContext
71where
72 S: Send + Sync + 'static,
73{
74 type Rejection = ExtensionRejection;
75
76 #[allow(clippy::manual_async_fn)] // Reason: axum's FromRequestParts requires explicit Future type in return position
77 fn from_request_parts(
78 parts: &mut Parts,
79 _state: &S,
80 ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
81 async move {
82 // Try to extract AuthUser from extensions
83 let auth_user: Option<AuthUser> = parts.extensions.get::<AuthUser>().cloned();
84
85 // Extract request headers
86 let headers = &parts.headers;
87
88 // Create SecurityContext if auth user is present
89 let security_context = auth_user.map(|auth_user| {
90 let authenticated_user = auth_user.0;
91 let request_id = extract_request_id(headers);
92 let ip_address = extract_ip_address(headers);
93 let tenant_id = extract_tenant_id(headers);
94
95 let mut context = SecurityContext::from_user(&authenticated_user, request_id);
96 context.ip_address = ip_address;
97 context.tenant_id = tenant_id.map(fraiseql_core::types::TenantId::new);
98
99 // Forward JWT extra_claims to security context attributes.
100 // This makes custom claims (org_id, roles, etc.) available to RLS policies
101 // and session variable injection.
102 //
103 // Framework-reserved `fraiseql.`-namespaced attributes (the derived
104 // actor classification, trace context, etc.) are NOT overwritable by
105 // a JWT claim — a token that carried a claim literally named
106 // `fraiseql.actor_type` must not be able to forge the recorded actor
107 // (#390). Such a claim is skipped here.
108 for (key, value) in &authenticated_user.extra_claims {
109 if key.starts_with("fraiseql.") {
110 continue;
111 }
112 context.attributes.insert(key.clone(), value.clone());
113 }
114
115 // Set tenant_id from org_id JWT claim when not already set from headers.
116 // This is the standard multi-tenant pattern: the JWT org_id claim identifies
117 // which tenant's data the authenticated user may access.
118 if context.tenant_id.is_none() {
119 if let Some(org_id) =
120 authenticated_user.extra_claims.get("org_id").and_then(|v| v.as_str())
121 {
122 context.tenant_id = Some(fraiseql_core::types::TenantId::new(org_id));
123 }
124 }
125
126 context
127 });
128
129 Ok(OptionalSecurityContext(security_context))
130 }
131 }
132}
133
134/// Extract request ID from headers or generate a new one.
135pub(crate) fn extract_request_id(headers: &axum::http::HeaderMap) -> String {
136 headers
137 .get("x-request-id")
138 .and_then(|v| v.to_str().ok())
139 .map_or_else(|| format!("req-{}", uuid::Uuid::new_v4()), |s| s.to_string())
140}
141
142/// Extract client IP address.
143///
144/// # Security
145///
146/// Does NOT trust X-Forwarded-For or X-Real-IP headers from clients, as these
147/// are trivially spoofable. IP address should be set from `ConnectInfo<SocketAddr>`
148/// at the handler level, or via `ProxyConfig::extract_client_ip()` which validates
149/// the proxy chain before trusting forwarding headers.
150pub(crate) const fn extract_ip_address(_headers: &axum::http::HeaderMap) -> Option<String> {
151 // SECURITY: IP extraction from headers removed. User-supplied X-Forwarded-For
152 // and X-Real-IP headers are trivially spoofable and must not be trusted without
153 // proxy chain validation. Use ConnectInfo<SocketAddr> or ProxyConfig instead.
154 None
155}
156
157/// Extract tenant ID.
158///
159/// # Security
160///
161/// Does NOT trust the X-Tenant-ID header directly. An authenticated user could
162/// set an arbitrary tenant ID to access another organization's data. Tenant ID
163/// should be set from `TenantContext` (populated by the secured `tenant_middleware`
164/// which requires authentication) or from JWT claims.
165pub(crate) const fn extract_tenant_id(_headers: &axum::http::HeaderMap) -> Option<String> {
166 // SECURITY: Tenant ID extraction from headers removed. The X-Tenant-ID header
167 // is user-controlled and could be used for tenant isolation bypass. Tenant context
168 // should come from the authenticated tenant_middleware or JWT claims.
169 None
170}