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 //
100 // Framework-reserved `fraiseql.`-namespaced attributes (the derived
101 // actor classification, trace context, etc.) are NOT overwritable by
102 // a JWT claim — a token that carried a claim literally named
103 // `fraiseql.actor_type` must not be able to forge the recorded actor
104 // (#390). Such a claim is skipped here.
105 for (key, value) in &authenticated_user.extra_claims {
106 if key.starts_with("fraiseql.") {
107 continue;
108 }
109 context.attributes.insert(key.clone(), value.clone());
110 }
111
112 // Set tenant_id from org_id JWT claim when not already set from headers.
113 // This is the standard multi-tenant pattern: the JWT org_id claim identifies
114 // which tenant's data the authenticated user may access.
115 if context.tenant_id.is_none() {
116 if let Some(org_id) =
117 authenticated_user.extra_claims.get("org_id").and_then(|v| v.as_str())
118 {
119 context.tenant_id = Some(fraiseql_core::types::TenantId::new(org_id));
120 }
121 }
122
123 context
124 });
125
126 Ok(OptionalSecurityContext(security_context))
127 }
128 }
129}
130
131/// Extract request ID from headers or generate a new one.
132pub(crate) fn extract_request_id(headers: &axum::http::HeaderMap) -> String {
133 headers
134 .get("x-request-id")
135 .and_then(|v| v.to_str().ok())
136 .map_or_else(|| format!("req-{}", uuid::Uuid::new_v4()), |s| s.to_string())
137}
138
139/// Extract client IP address.
140///
141/// # Security
142///
143/// Does NOT trust X-Forwarded-For or X-Real-IP headers from clients, as these
144/// are trivially spoofable. IP address should be set from `ConnectInfo<SocketAddr>`
145/// at the handler level, or via `ProxyConfig::extract_client_ip()` which validates
146/// the proxy chain before trusting forwarding headers.
147pub(crate) const fn extract_ip_address(_headers: &axum::http::HeaderMap) -> Option<String> {
148 // SECURITY: IP extraction from headers removed. User-supplied X-Forwarded-For
149 // and X-Real-IP headers are trivially spoofable and must not be trusted without
150 // proxy chain validation. Use ConnectInfo<SocketAddr> or ProxyConfig instead.
151 None
152}
153
154/// Extract tenant ID.
155///
156/// # Security
157///
158/// Does NOT trust the X-Tenant-ID header directly. An authenticated user could
159/// set an arbitrary tenant ID to access another organization's data. Tenant ID
160/// should be set from `TenantContext` (populated by the secured `tenant_middleware`
161/// which requires authentication) or from JWT claims.
162pub(crate) const fn extract_tenant_id(_headers: &axum::http::HeaderMap) -> Option<String> {
163 // SECURITY: Tenant ID extraction from headers removed. The X-Tenant-ID header
164 // is user-controlled and could be used for tenant isolation bypass. Tenant context
165 // should come from the authenticated tenant_middleware or JWT claims.
166 None
167}