Skip to main content

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}