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