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::future::Future;
6
7use axum::{
8    extract::{FromRequestParts, rejection::ExtensionRejection},
9    http::request::Parts,
10};
11use fraiseql_core::security::SecurityContext;
12
13use crate::middleware::AuthUser;
14
15/// Extractor for optional `SecurityContext` from authenticated user and headers.
16///
17/// When used in a handler, automatically extracts:
18/// 1. `AuthUser` from request extensions (if present)
19/// 2. Request metadata from HTTP headers (request ID, IP, tenant ID)
20/// 3. Creates `SecurityContext` from both
21///
22/// If authentication is not present, returns `None` (optional extraction).
23///
24/// # Example
25///
26/// ```text
27/// // Requires: running Axum server with authentication middleware configured.
28/// async fn graphql_handler(
29///     State(state): State<AppState>,
30///     OptionalSecurityContext(context): OptionalSecurityContext,
31/// ) -> Result<Response> {
32///     // context is Option<SecurityContext>
33/// }
34/// ```
35#[derive(Debug, Clone)]
36pub struct OptionalSecurityContext(pub Option<SecurityContext>);
37
38impl<S> FromRequestParts<S> for OptionalSecurityContext
39where
40    S: Send + Sync + 'static,
41{
42    type Rejection = ExtensionRejection;
43
44    #[allow(clippy::manual_async_fn)] // Reason: axum's FromRequestParts requires explicit Future type in return position
45    fn from_request_parts(
46        parts: &mut Parts,
47        _state: &S,
48    ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
49        async move {
50            // Try to extract AuthUser from extensions
51            let auth_user: Option<AuthUser> = parts.extensions.get::<AuthUser>().cloned();
52
53            // Extract request headers
54            let headers = &parts.headers;
55
56            // Create SecurityContext if auth user is present
57            let security_context = auth_user.map(|auth_user| {
58                let authenticated_user = auth_user.0;
59                let request_id = extract_request_id(headers);
60                let ip_address = extract_ip_address(headers);
61                let tenant_id = extract_tenant_id(headers);
62
63                let mut context = SecurityContext::from_user(&authenticated_user, request_id);
64                context.ip_address = ip_address;
65                context.tenant_id = tenant_id;
66                context
67            });
68
69            Ok(OptionalSecurityContext(security_context))
70        }
71    }
72}
73
74/// Extract request ID from headers or generate a new one.
75fn extract_request_id(headers: &axum::http::HeaderMap) -> String {
76    headers
77        .get("x-request-id")
78        .and_then(|v| v.to_str().ok())
79        .map_or_else(|| format!("req-{}", uuid::Uuid::new_v4()), |s| s.to_string())
80}
81
82/// Extract client IP address.
83///
84/// # Security
85///
86/// Does NOT trust X-Forwarded-For or X-Real-IP headers from clients, as these
87/// are trivially spoofable. IP address should be set from `ConnectInfo<SocketAddr>`
88/// at the handler level, or via `ProxyConfig::extract_client_ip()` which validates
89/// the proxy chain before trusting forwarding headers.
90const fn extract_ip_address(_headers: &axum::http::HeaderMap) -> Option<String> {
91    // SECURITY: IP extraction from headers removed. User-supplied X-Forwarded-For
92    // and X-Real-IP headers are trivially spoofable and must not be trusted without
93    // proxy chain validation. Use ConnectInfo<SocketAddr> or ProxyConfig instead.
94    None
95}
96
97/// Extract tenant ID.
98///
99/// # Security
100///
101/// Does NOT trust the X-Tenant-ID header directly. An authenticated user could
102/// set an arbitrary tenant ID to access another organization's data. Tenant ID
103/// should be set from `TenantContext` (populated by the secured `tenant_middleware`
104/// which requires authentication) or from JWT claims.
105const fn extract_tenant_id(_headers: &axum::http::HeaderMap) -> Option<String> {
106    // SECURITY: Tenant ID extraction from headers removed. The X-Tenant-ID header
107    // is user-controlled and could be used for tenant isolation bypass. Tenant context
108    // should come from the authenticated tenant_middleware or JWT claims.
109    None
110}
111
112#[cfg(test)]
113mod tests {
114    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
115    #![allow(clippy::cast_precision_loss)] // Reason: test metrics reporting
116    #![allow(clippy::cast_sign_loss)] // Reason: test data uses small positive integers
117    #![allow(clippy::cast_possible_truncation)] // Reason: test data values are bounded
118    #![allow(clippy::cast_possible_wrap)] // Reason: test data values are bounded
119    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
120    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
121    #![allow(missing_docs)] // Reason: test code
122    #![allow(clippy::items_after_statements)] // Reason: test helpers defined near use site
123
124    use super::*;
125
126    #[test]
127    fn test_extract_request_id_from_header() {
128        let mut headers = axum::http::HeaderMap::new();
129        headers.insert("x-request-id", "req-12345".parse().unwrap());
130
131        let request_id = extract_request_id(&headers);
132        assert_eq!(request_id, "req-12345");
133    }
134
135    #[test]
136    fn test_extract_request_id_generates_default() {
137        let headers = axum::http::HeaderMap::new();
138        let request_id = extract_request_id(&headers);
139        // Should start with "req-"
140        assert!(request_id.starts_with("req-"));
141        // Should contain a UUID: "req-" (4) + UUID (36) = 40 chars
142        assert_eq!(request_id.len(), 40);
143    }
144
145    #[test]
146    fn test_extract_ip_ignores_x_forwarded_for() {
147        // SECURITY: X-Forwarded-For must NOT be trusted without proxy validation
148        let mut headers = axum::http::HeaderMap::new();
149        headers.insert("x-forwarded-for", "192.0.2.1, 10.0.0.1".parse().unwrap());
150
151        let ip = extract_ip_address(&headers);
152        assert_eq!(ip, None, "Must not trust X-Forwarded-For header");
153    }
154
155    #[test]
156    fn test_extract_ip_ignores_x_real_ip() {
157        // SECURITY: X-Real-IP must NOT be trusted without proxy validation
158        let mut headers = axum::http::HeaderMap::new();
159        headers.insert("x-real-ip", "10.0.0.2".parse().unwrap());
160
161        let ip = extract_ip_address(&headers);
162        assert_eq!(ip, None, "Must not trust X-Real-IP header");
163    }
164
165    #[test]
166    fn test_extract_ip_address_none_when_missing() {
167        let headers = axum::http::HeaderMap::new();
168        let ip = extract_ip_address(&headers);
169        assert_eq!(ip, None);
170    }
171
172    #[test]
173    fn test_extract_tenant_id_ignores_header() {
174        // SECURITY: X-Tenant-ID must NOT be trusted from headers
175        let mut headers = axum::http::HeaderMap::new();
176        headers.insert("x-tenant-id", "tenant-acme".parse().unwrap());
177
178        let tenant_id = extract_tenant_id(&headers);
179        assert_eq!(tenant_id, None, "Must not trust X-Tenant-ID header");
180    }
181
182    #[test]
183    fn test_extract_tenant_id_none_when_missing() {
184        let headers = axum::http::HeaderMap::new();
185        let tenant_id = extract_tenant_id(&headers);
186        assert_eq!(tenant_id, None);
187    }
188
189    #[test]
190    fn test_optional_security_context_creation_from_auth_user() {
191        use chrono::Utc;
192
193        // Simulate an authenticated user from the OIDC middleware
194        let auth_user = crate::middleware::AuthUser(fraiseql_core::security::AuthenticatedUser {
195            user_id:    "user123".to_string(),
196            scopes:     vec!["read:user".to_string(), "write:post".to_string()],
197            expires_at: Utc::now() + chrono::Duration::hours(1),
198        });
199
200        // Create headers with additional metadata
201        let mut headers = axum::http::HeaderMap::new();
202        headers.insert("x-request-id", "req-test-123".parse().unwrap());
203        headers.insert("x-tenant-id", "tenant-acme".parse().unwrap());
204        headers.insert("x-forwarded-for", "192.0.2.100".parse().unwrap());
205
206        // Create security context using extractor helper logic
207        let security_context = Some(auth_user).map(|auth_user| {
208            let authenticated_user = auth_user.0;
209            let request_id = extract_request_id(&headers);
210            let ip_address = extract_ip_address(&headers);
211            let tenant_id = extract_tenant_id(&headers);
212
213            let mut context = fraiseql_core::security::SecurityContext::from_user(
214                &authenticated_user,
215                request_id,
216            );
217            context.ip_address = ip_address;
218            context.tenant_id = tenant_id;
219            context
220        });
221
222        // Verify context was created correctly
223        let sec_ctx = security_context.unwrap();
224        assert_eq!(sec_ctx.user_id, "user123");
225        assert_eq!(sec_ctx.scopes, vec!["read:user".to_string(), "write:post".to_string()]);
226        // SECURITY: Tenant ID is no longer extracted from headers (spoofable).
227        // Should come from TenantContext (authenticated tenant_middleware) or JWT claims.
228        assert_eq!(sec_ctx.tenant_id, None);
229        assert_eq!(sec_ctx.request_id, "req-test-123");
230        // SECURITY: IP is no longer extracted from headers (spoofable).
231        // Should be set from ConnectInfo<SocketAddr> at handler level.
232        assert_eq!(sec_ctx.ip_address, None);
233    }
234}