Skip to main content

micromegas/servers/
http_gateway.rs

1use anyhow::{Context, Result};
2use axum::{
3    Extension, Json, Router,
4    body::Body,
5    extract::ConnectInfo,
6    http::{Response, StatusCode},
7    response::IntoResponse,
8    routing::{get, post},
9};
10use chrono::{DateTime, Utc};
11use datafusion::arrow::{
12    array::RecordBatch,
13    json::{Writer, writer::JsonArray},
14};
15use http::{HeaderMap, Uri};
16use micromegas_analytics::time::TimeRange;
17use micromegas_tracing::info;
18use serde::{Deserialize, Serialize};
19use std::net::SocketAddr;
20use std::sync::Arc;
21use thiserror::Error;
22use tonic::transport::{Channel, ClientTlsConfig};
23
24use crate::client::flightsql_client::Client;
25use crate::servers::http_utils;
26
27/// Configuration for forwarding HTTP headers to FlightSQL backend
28#[derive(Debug, Clone, Deserialize)]
29pub struct HeaderForwardingConfig {
30    /// Exact header names to forward (case-insensitive)
31    pub allowed_headers: Vec<String>,
32
33    /// Header prefixes to forward (e.g., "X-Custom-")
34    pub allowed_prefixes: Vec<String>,
35
36    /// Headers to explicitly block (overrides allows)
37    pub blocked_headers: Vec<String>,
38}
39
40impl Default for HeaderForwardingConfig {
41    fn default() -> Self {
42        Self {
43            // Default safe headers to forward
44            allowed_headers: vec![
45                "Authorization".to_string(),
46                "User-Agent".to_string(),
47                "X-Client-Type".to_string(),
48                "X-Client-Agent".to_string(),
49                "X-Client-Entrypoint".to_string(),
50                "X-Client-Session".to_string(),
51                "X-Client-Notebook".to_string(),
52                "X-Client-Cell".to_string(),
53                "X-Correlation-ID".to_string(),
54                "X-Request-ID".to_string(),
55                "X-User-Email".to_string(),
56                "X-User-ID".to_string(),
57                "X-User-Name".to_string(),
58            ],
59            allowed_prefixes: vec![],
60            blocked_headers: vec![
61                "Cookie".to_string(),
62                "Set-Cookie".to_string(),
63                // SECURITY: Gateway always sets this from actual connection
64                "X-Client-IP".to_string(),
65            ],
66        }
67    }
68}
69
70impl HeaderForwardingConfig {
71    /// Load configuration from environment variable or use defaults
72    pub fn from_env() -> Result<Self> {
73        if let Ok(config_json) = std::env::var("MICROMEGAS_GATEWAY_HEADERS") {
74            serde_json::from_str(&config_json).context("Failed to parse MICROMEGAS_GATEWAY_HEADERS")
75        } else {
76            Ok(Self::default())
77        }
78    }
79
80    /// Check if a header should be forwarded based on configuration
81    pub fn should_forward(&self, header_name: &str) -> bool {
82        let name_lower = header_name.to_lowercase();
83
84        // Check blocked list first
85        if self
86            .blocked_headers
87            .iter()
88            .any(|h| h.to_lowercase() == name_lower)
89        {
90            return false;
91        }
92
93        // Check exact matches
94        if self
95            .allowed_headers
96            .iter()
97            .any(|h| h.to_lowercase() == name_lower)
98        {
99            return true;
100        }
101
102        // Check prefixes
103        self.allowed_prefixes
104            .iter()
105            .any(|prefix| name_lower.starts_with(&prefix.to_lowercase()))
106    }
107}
108
109/// Combines the CLI-derived FlightSQL URL with the env-derived header config.
110/// Built once at startup and layered as an `Extension`, so `handle_query`
111/// never reads the environment on the request path.
112#[derive(Debug, Clone)]
113pub struct GatewayConfig {
114    pub flight_url: Uri,
115    pub headers: HeaderForwardingConfig,
116}
117
118impl GatewayConfig {
119    /// Combine the CLI-derived FlightSQL URL with the env-derived header config.
120    pub fn new(flight_url: Uri) -> Result<Self> {
121        Ok(Self {
122            flight_url,
123            headers: HeaderForwardingConfig::from_env()?,
124        })
125    }
126}
127
128#[derive(Error, Debug)]
129pub enum GatewayError {
130    #[error("Bad request: {0}")]
131    BadRequest(String),
132
133    #[error("Unauthorized: {0}")]
134    Unauthorized(String),
135
136    #[error("Forbidden: {0}")]
137    Forbidden(String),
138
139    #[error("Service unavailable: {0}")]
140    ServiceUnavailable(String),
141
142    #[error("Internal server error: {0}")]
143    Internal(String),
144}
145
146impl IntoResponse for GatewayError {
147    fn into_response(self) -> Response<Body> {
148        let (status, message) = match self {
149            GatewayError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
150            GatewayError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
151            GatewayError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
152            GatewayError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg),
153            GatewayError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
154        };
155        (status, message).into_response()
156    }
157}
158
159#[derive(Debug, Deserialize)]
160pub struct QueryRequest {
161    sql: String,
162    /// Optional time range filter - begin timestamp in RFC3339 format
163    /// Example: "2024-01-01T00:00:00Z"
164    #[serde(default)]
165    time_range_begin: Option<String>,
166    /// Optional time range filter - end timestamp in RFC3339 format
167    /// Example: "2024-01-02T00:00:00Z"
168    #[serde(default)]
169    time_range_end: Option<String>,
170}
171
172/// Build origin tracking metadata for FlightSQL queries
173/// Augments the client type by appending "+gateway" to preserve the full client chain
174///
175/// This function only sets origin tracking headers that the gateway controls:
176/// - x-client-type: augmented with "+gateway"
177/// - x-request-id: generated if not present
178/// - x-client-ip: extracted from actual connection (prevents spoofing)
179///
180/// User attribution headers (x-user-id, x-user-email) are forwarded from client
181/// if present in allowed_headers config. FlightSQL validates these against the
182/// Authorization token.
183pub fn build_origin_metadata(
184    headers: &HeaderMap,
185    addr: &SocketAddr,
186) -> tonic::metadata::MetadataMap {
187    let mut metadata = tonic::metadata::MetadataMap::new();
188
189    // 1. Client Type - augment existing or set to "unknown+gateway"
190    let original_client_type = headers
191        .get("x-client-type")
192        .and_then(|v| v.to_str().ok())
193        .unwrap_or("unknown");
194    let augmented_client_type = format!("{original_client_type}+gateway");
195    if let Ok(value) = augmented_client_type.parse() {
196        metadata.insert("x-client-type", value);
197    }
198
199    // 2. Request ID - generate UUID if not present
200    let request_id = headers
201        .get("x-request-id")
202        .and_then(|v| v.to_str().ok())
203        .map(|s| s.to_string())
204        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
205    if let Ok(value) = request_id.parse() {
206        metadata.insert("x-request-id", value);
207    }
208
209    // 3. Client IP - ALWAYS extract from connection (never from client header)
210    // SECURITY: Prevents IP spoofing in audit logs
211    let mut extensions = http::Extensions::new();
212    extensions.insert(axum::extract::ConnectInfo(*addr));
213    let client_ip = http_utils::get_client_ip(headers, &extensions);
214    if let Ok(value) = client_ip.parse() {
215        metadata.insert("x-client-ip", value);
216    }
217
218    metadata
219}
220
221pub async fn handle_query(
222    Extension(config): Extension<Arc<GatewayConfig>>,
223    ConnectInfo(addr): ConnectInfo<SocketAddr>,
224    headers: HeaderMap,
225    Json(request): Json<QueryRequest>,
226) -> Result<String, GatewayError> {
227    let start_time = std::time::Instant::now();
228
229    // Build origin tracking metadata
230    let origin_metadata = build_origin_metadata(&headers, &addr);
231    let client_type_header = origin_metadata
232        .get("x-client-type")
233        .and_then(|v| v.to_str().ok())
234        .unwrap_or("unknown+gateway");
235    let request_id_header = origin_metadata
236        .get("x-request-id")
237        .and_then(|v| v.to_str().ok())
238        .unwrap_or("unknown");
239
240    // Request validation
241    let sql = request.sql.trim();
242    if sql.is_empty() {
243        return Err(GatewayError::BadRequest(
244            "SQL query cannot be empty".to_string(),
245        ));
246    }
247
248    // Basic size limit (1MB for SQL query)
249    const MAX_SQL_SIZE: usize = 1_048_576;
250    if sql.len() > MAX_SQL_SIZE {
251        return Err(GatewayError::BadRequest(format!(
252            "SQL query too large: {} bytes (max: {} bytes)",
253            sql.len(),
254            MAX_SQL_SIZE
255        )));
256    }
257
258    // Parse time range if provided
259    let time_range = match (&request.time_range_begin, &request.time_range_end) {
260        (Some(begin_str), Some(end_str)) => {
261            let begin = DateTime::parse_from_rfc3339(begin_str)
262                .map_err(|e| {
263                    GatewayError::BadRequest(format!(
264                        "Invalid time_range_begin format (expected RFC3339): {e}"
265                    ))
266                })?
267                .with_timezone(&Utc);
268            let end = DateTime::parse_from_rfc3339(end_str)
269                .map_err(|e| {
270                    GatewayError::BadRequest(format!(
271                        "Invalid time_range_end format (expected RFC3339): {e}"
272                    ))
273                })?
274                .with_timezone(&Utc);
275
276            if begin > end {
277                return Err(GatewayError::BadRequest(
278                    "time_range_begin must be before time_range_end".to_string(),
279                ));
280            }
281
282            Some(TimeRange::new(begin, end))
283        }
284        (Some(_), None) => {
285            return Err(GatewayError::BadRequest(
286                "time_range_end must be provided when time_range_begin is specified".to_string(),
287            ));
288        }
289        (None, Some(_)) => {
290            return Err(GatewayError::BadRequest(
291                "time_range_begin must be provided when time_range_end is specified".to_string(),
292            ));
293        }
294        (None, None) => None,
295    };
296
297    info!(
298        "Gateway request: request_id={}, client_type={}, time_range={:?}, sql={}",
299        request_id_header, client_type_header, time_range, sql
300    );
301
302    let tls_config = ClientTlsConfig::new().with_native_roots();
303    let channel = Channel::builder(config.flight_url.clone())
304        .tls_config(tls_config)
305        .map_err(|e| GatewayError::Internal(format!("TLS config error: {e}")))?
306        .connect()
307        .await
308        .map_err(|e| {
309            GatewayError::ServiceUnavailable(format!("Failed to connect to FlightSQL: {e}"))
310        })?;
311
312    // Create client and set headers
313    let mut client = Client::new(channel);
314
315    client
316        .inner_mut()
317        .set_header("x-client-type", client_type_header);
318    client
319        .inner_mut()
320        .set_header("x-request-id", request_id_header);
321
322    if let Some(client_ip) = origin_metadata.get("x-client-ip")
323        && let Ok(ip_str) = client_ip.to_str()
324    {
325        client.inner_mut().set_header("x-client-ip", ip_str);
326    }
327
328    // Forward allowed headers from client
329    for (name, value) in headers.iter() {
330        let header_name = name.as_str();
331
332        // Skip headers already set by origin metadata
333        if header_name.eq_ignore_ascii_case("x-client-type")
334            || header_name.eq_ignore_ascii_case("x-request-id")
335            || header_name.eq_ignore_ascii_case("x-client-ip")
336        {
337            continue; // Origin metadata takes precedence
338        }
339
340        if config.headers.should_forward(header_name)
341            && let Ok(value_str) = value.to_str()
342        {
343            client.inner_mut().set_header(header_name, value_str);
344        }
345    }
346
347    // Execute query with error handling
348    let batches = client
349        .query(sql.to_string(), time_range)
350        .await
351        .map_err(|e| {
352            // Map tonic errors to appropriate HTTP status codes
353            if let Some(status) = e.downcast_ref::<tonic::Status>() {
354                match status.code() {
355                    tonic::Code::Unauthenticated => {
356                        GatewayError::Unauthorized(status.message().to_string())
357                    }
358                    tonic::Code::PermissionDenied => {
359                        GatewayError::Forbidden(status.message().to_string())
360                    }
361                    tonic::Code::InvalidArgument => {
362                        GatewayError::BadRequest(status.message().to_string())
363                    }
364                    tonic::Code::Unavailable => {
365                        GatewayError::ServiceUnavailable(status.message().to_string())
366                    }
367                    _ => GatewayError::Internal(format!("Query failed: {}", status.message())),
368                }
369            } else {
370                GatewayError::Internal(format!("Query execution error: {e:?}"))
371            }
372        })?;
373
374    let elapsed = start_time.elapsed();
375    info!(
376        "Gateway request completed: request_id={}, duration={:?}",
377        request_id_header, elapsed
378    );
379
380    if batches.is_empty() {
381        return Ok("[]".to_string());
382    }
383
384    let mut buffer = Vec::new();
385    let mut json_writer = Writer::<_, JsonArray>::new(&mut buffer);
386    let batch_refs: Vec<&RecordBatch> = batches.iter().collect();
387    json_writer
388        .write_batches(&batch_refs)
389        .map_err(|e| GatewayError::Internal(format!("Failed to serialize results: {e}")))?;
390    json_writer
391        .finish()
392        .map_err(|e| GatewayError::Internal(format!("Failed to finish JSON output: {e}")))?;
393
394    String::from_utf8(buffer)
395        .map_err(|e| GatewayError::Internal(format!("Invalid UTF-8 in results: {e}")))
396}
397
398#[derive(Debug, Serialize)]
399pub struct HealthResponse {
400    pub status: &'static str,
401    pub timestamp: DateTime<Utc>,
402}
403
404pub async fn handle_health() -> Json<HealthResponse> {
405    Json(HealthResponse {
406        status: "healthy",
407        timestamp: Utc::now(),
408    })
409}
410
411pub fn register_routes(router: Router) -> Router {
412    router
413        .route("/gateway/query", post(handle_query))
414        .route("/gateway/health", get(handle_health))
415}