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