Skip to main content

mz_environmentd/
http.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Embedded HTTP server.
11//!
12//! environmentd embeds an HTTP server for introspection into the running
13//! process. At the moment, its primary exports are Prometheus metrics, heap
14//! profiles, and catalog dumps.
15//!
16//! ## Authentication/Authorization flow
17//!
18//! The server supports several authentication modes, controlled by the
19//! configured [`listeners::AuthenticatorKind`]. The general flow is:
20//!
21//! 1. **Authentication.** An authentication middleware runs on every
22//!    protected request and resolves the caller's identity via one of:
23//!    - **Credentials in headers.** The caller supplies a username/password or
24//!      token in the request headers. Supported by all [`listeners::AuthenticatorKind`]s.
25//!    - **Session reuse.** If the caller has an active authenticated session
26//!      (established via `POST /api/login`) and has not supplied credentials
27//!      in the request headers, the session is reused. Only available for
28//!      [`listeners::AuthenticatorKind::Password`] and [`listeners::AuthenticatorKind::Oidc`].
29//!    - **Trusted header injection.** A trusted upstream proxy (e.g. Teleport)
30//!      may inject the caller's identity into the request headers. Only available
31//!      for [`listeners::AuthenticatorKind::None`].
32//!
33//! 2. **Authorization.** The authentication middleware is followed by an authorization
34//!    middleware that checks if the caller's identity is allowed to access the route based on
35//!    its [`listeners::AllowedRoles`].
36//!
37//! 3. **Session initialization.** Once the caller's identity is known, an
38//!    adapter session is opened on their behalf. This happens as part of
39//!    request processing, after all middleware has run.
40//!
41//! 4. **Request handling.** The handler executes the request (e.g. runs SQL)
42//!    using the initialized adapter session.
43//!
44//! ### WebSocket
45//!
46//! The WebSocket flow is identical to the HTTP flow with two differences:
47//!
48//! - Credentials are not read from request headers. Instead, the first
49//!   message sent by the client is treated as the authentication message.
50//! - Authorization and session initialization (step 2 and 3) happen inside
51//!   the WebSocket handler itself, rather than as separate middleware steps.
52
53// Axum handlers must use async, but often don't actually use `await`.
54#![allow(clippy::unused_async)]
55
56use std::borrow::Cow;
57use std::collections::BTreeMap;
58use std::fmt::Debug;
59use std::net::{IpAddr, SocketAddr};
60use std::pin::Pin;
61use std::sync::Arc;
62use std::time::{Duration, SystemTime};
63
64use anyhow::Context;
65use axum::error_handling::HandleErrorLayer;
66use axum::extract::ws::{Message, WebSocket};
67use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, Query, Request, State};
68use axum::middleware::{self, Next};
69use axum::response::{IntoResponse, Redirect, Response};
70use axum::{Extension, Json, Router, routing};
71use futures::future::{Shared, TryFutureExt};
72use headers::authorization::{Authorization, Basic, Bearer};
73use headers::{HeaderMapExt, HeaderName};
74use http::header::{AUTHORIZATION, CONTENT_TYPE};
75use http::uri::Scheme;
76use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
77use hyper_openssl::SslStream;
78use hyper_openssl::client::legacy::MaybeHttpsStream;
79use hyper_util::rt::TokioIo;
80use mz_adapter::session::{Session as AdapterSession, SessionConfig as AdapterSessionConfig};
81use mz_adapter::{AdapterError, AdapterNotice, Client, SessionClient, WebhookAppenderCache};
82use mz_adapter_types::dyncfgs::OIDC_GROUP_CLAIM;
83use mz_auth::Authenticated;
84use mz_auth::password::Password;
85use mz_authenticator::Authenticator;
86use mz_controller::ReplicaHttpLocator;
87use mz_dyncfg::ConfigSet;
88use mz_frontegg_auth::Error as FronteggError;
89use mz_http_util::DynamicFilterTarget;
90use mz_ore::cast::u64_to_usize;
91use mz_ore::metrics::MetricsRegistry;
92use mz_ore::now::{NowFn, SYSTEM_TIME, epoch_to_uuid_v7};
93use mz_ore::str::StrExt;
94use mz_pgwire_common::{ConnectionCounter, ConnectionHandle};
95use mz_repr::user::ExternalUserMetadata;
96use mz_server_core::listeners::{self, AllowedRoles, HttpRoutesEnabled, RouteGroup};
97use mz_server_core::{Connection, ConnectionHandler, ReloadingSslContext, Server};
98use mz_sql::session::metadata::SessionMetadata;
99use mz_sql::session::user::{
100    HTTP_DEFAULT_USER, INTERNAL_USER_NAMES, SUPPORT_USER_NAME, SYSTEM_USER_NAME,
101};
102use mz_sql::session::vars::{Value, Var, VarInput, WELCOME_MESSAGE};
103use openssl::ssl::Ssl;
104use prometheus::{
105    COMPUTE_METRIC_QUERIES, FRONTIER_METRIC_QUERIES, STORAGE_METRIC_QUERIES, USAGE_METRIC_QUERIES,
106};
107use serde::{Deserialize, Serialize};
108use serde_json::json;
109use thiserror::Error;
110use tokio::io::AsyncWriteExt;
111use tokio::sync::oneshot::Receiver;
112use tokio::sync::{oneshot, watch};
113use tokio_metrics::TaskMetrics;
114use tower::limit::GlobalConcurrencyLimitLayer;
115use tower::{Service, ServiceBuilder};
116use tower_http::cors::{AllowOrigin, Any, CorsLayer};
117use tower_sessions::{
118    MemoryStore as TowerSessionMemoryStore, Session as TowerSession,
119    SessionManagerLayer as TowerSessionManagerLayer,
120};
121use tracing::warn;
122
123use crate::BUILD_INFO;
124use crate::deployment::state::DeploymentStateHandle;
125use crate::http::sql::{ExistingUser, SqlError};
126
127mod catalog;
128mod cluster;
129mod console;
130mod mcp;
131pub mod mcp_metrics;
132mod memory;
133mod metrics;
134mod metrics_public;
135mod metrics_viz;
136pub(crate) mod oauth_metadata;
137mod probe;
138mod prometheus;
139mod root;
140mod sql;
141mod webhook;
142
143pub use metrics::Metrics;
144pub use sql::{SqlResponse, WebSocketAuth, WebSocketResponse};
145
146/// Maximum allowed size for a request.
147pub const MAX_REQUEST_SIZE: usize = u64_to_usize(5 * bytesize::MIB);
148
149const SESSION_DURATION: Duration = Duration::from_secs(8 * 3600); // 8 hours
150
151const PROFILING_API_ENDPOINTS: &[&str] = &["/memory", "/hierarchical-memory", "/prof/"];
152
153#[derive(Debug)]
154pub struct HttpConfig {
155    pub source: &'static str,
156    pub tls: Option<ReloadingSslContext>,
157    pub authenticator_kind: listeners::AuthenticatorKind,
158    pub frontegg: Option<mz_frontegg_auth::Authenticator>,
159    pub oidc_rx: Delayed<mz_authenticator::GenericOidcAuthenticator>,
160    pub adapter_client_rx: Shared<Receiver<Client>>,
161    pub allowed_origin: AllowOrigin,
162    /// Raw list of allowed CORS origins, used by the MCP endpoints for
163    /// server-side Origin validation to defend against DNS rebinding.
164    pub allowed_origin_list: Vec<HeaderValue>,
165    pub active_connection_counter: ConnectionCounter,
166    pub helm_chart_version: Option<String>,
167    /// Externally-visible host name for this environment (without scheme).
168    ///
169    /// Used as the canonical host when constructing absolute URLs that the
170    /// server needs to publish (e.g. the OAuth Protected Resource Metadata
171    /// `resource` field, RFC 9728). When `None`, callers fall back to the
172    /// request's `Host` header, which is correct for unproxied dev setups
173    /// but loses fidelity behind a load balancer that rewrites Host.
174    ///
175    /// We deliberately do NOT consult `X-Forwarded-Host` or
176    /// `X-Forwarded-Proto`: there is no proxy-trust model in environmentd
177    /// today, and an attacker reaching the server directly can otherwise
178    /// poison the published metadata URLs.
179    pub http_host_name: Option<String>,
180    pub frontegg_oauth_issuer_url: Option<String>,
181    pub concurrent_webhook_req: Arc<tokio::sync::Semaphore>,
182    pub dyncfgs: Arc<ConfigSet>,
183    pub metrics: Metrics,
184    pub metrics_registry: MetricsRegistry,
185    pub mcp_metrics: mcp_metrics::McpMetrics,
186    pub oauth_metadata_metrics: oauth_metadata::OauthMetadataMetrics,
187    pub internal_route_config: Arc<InternalRouteConfig>,
188    pub routes_enabled: HttpRoutesEnabled,
189    /// Locator for cluster replica HTTP addresses, used for proxying requests.
190    pub replica_http_locator: Arc<ReplicaHttpLocator>,
191}
192
193#[derive(Debug, Clone)]
194pub struct InternalRouteConfig {
195    pub deployment_state_handle: DeploymentStateHandle,
196    pub internal_console_redirect_url: Option<String>,
197}
198
199#[derive(Clone)]
200pub struct WsState {
201    frontegg: Option<mz_frontegg_auth::Authenticator>,
202    oidc_rx: Delayed<mz_authenticator::GenericOidcAuthenticator>,
203    authenticator_kind: listeners::AuthenticatorKind,
204    adapter_client_rx: Delayed<mz_adapter::Client>,
205    active_connection_counter: ConnectionCounter,
206    helm_chart_version: Option<String>,
207    allowed_roles: AllowedRoles,
208}
209
210#[derive(Clone)]
211pub struct WebhookState {
212    adapter_client_rx: Delayed<mz_adapter::Client>,
213    webhook_cache: WebhookAppenderCache,
214    dyncfgs: Arc<ConfigSet>,
215}
216
217#[derive(Clone, Debug)]
218struct HelmChartVersion(Option<String>);
219
220#[derive(Debug)]
221pub struct HttpServer {
222    tls: Option<ReloadingSslContext>,
223    router: Router,
224}
225
226impl HttpServer {
227    pub fn new(
228        HttpConfig {
229            source,
230            tls,
231            authenticator_kind,
232            frontegg,
233            oidc_rx,
234            adapter_client_rx,
235            allowed_origin,
236            allowed_origin_list,
237            active_connection_counter,
238            helm_chart_version,
239            http_host_name,
240            frontegg_oauth_issuer_url,
241            concurrent_webhook_req,
242            dyncfgs,
243            metrics,
244            metrics_registry,
245            mcp_metrics,
246            oauth_metadata_metrics,
247            internal_route_config,
248            routes_enabled,
249            replica_http_locator,
250        }: HttpConfig,
251    ) -> HttpServer {
252        let tls_enabled = tls.is_some();
253        let webhook_cache = WebhookAppenderCache::new();
254
255        // Compute OAuth discovery once per listener so the Bearer challenge
256        // and the discovery handler always agree, and the middleware doesn't
257        // re-derive (and re-allocate the Frontegg issuer) on each request.
258        let oauth_discovery = Arc::new(oauth_metadata::McpOAuthDiscovery::for_authenticator(
259            authenticator_kind,
260            frontegg_oauth_issuer_url.as_deref(),
261        ));
262
263        // Create secure session store and manager
264        let session_store = TowerSessionMemoryStore::default();
265        let session_layer = TowerSessionManagerLayer::new(session_store)
266            .with_secure(tls_enabled) // Enforce HTTPS
267            .with_same_site(tower_sessions::cookie::SameSite::Strict) // Prevent CSRF
268            .with_http_only(true) // Prevent XSS
269            .with_name("mz_session") // Custom cookie name
270            .with_path("/"); // Set cookie path
271
272        let frontegg_middleware = frontegg.clone();
273        let oidc_middleware_rx = oidc_rx.clone();
274        let adapter_client_middleware_rx = adapter_client_rx.clone();
275        let auth_middleware = middleware::from_fn(move |req, next| {
276            let frontegg = frontegg_middleware.clone();
277            let oidc_rx = oidc_middleware_rx.clone();
278            let adapter_client_rx = adapter_client_middleware_rx.clone();
279            async move {
280                http_auth(
281                    req,
282                    next,
283                    tls_enabled,
284                    authenticator_kind,
285                    frontegg,
286                    oidc_rx,
287                    adapter_client_rx,
288                )
289                .await
290            }
291        });
292        let mut router = Router::new();
293        let mut base_router = Router::new();
294        let cluster_proxy_config = Arc::new(cluster::ClusterProxyConfig::new(Arc::clone(
295            &replica_http_locator,
296        )));
297        if let RouteGroup::Enabled(base_roles) = routes_enabled.base {
298            let base_group = Router::new()
299                .route(
300                    "/",
301                    routing::get(move || async move { root::handle_home(routes_enabled).await }),
302                )
303                .route("/api/sql", routing::post(sql::handle_sql))
304                .route("/memory", routing::get(memory::handle_memory))
305                .route(
306                    "/hierarchical-memory",
307                    routing::get(memory::handle_hierarchical_memory),
308                )
309                .route(
310                    "/metrics-viz",
311                    routing::get(metrics_viz::handle_metrics_viz),
312                )
313                .route("/static/{*path}", routing::get(root::handle_static))
314                .route(
315                    "/metrics/public",
316                    routing::get(metrics_public::handle_public_metrics),
317                )
318                .layer(Extension(metrics_registry.clone()))
319                .layer(Extension(Arc::clone(&cluster_proxy_config)))
320                .authorize(base_roles);
321            base_router = base_router.merge(base_group);
322
323            let mut ws_router = Router::new()
324                .route("/api/experimental/sql", routing::get(sql::handle_sql_ws))
325                .with_state(WsState {
326                    frontegg,
327                    oidc_rx: oidc_rx.clone(),
328                    authenticator_kind,
329                    adapter_client_rx: adapter_client_rx.clone(),
330                    active_connection_counter: active_connection_counter.clone(),
331                    helm_chart_version: helm_chart_version.clone(),
332                    // WS is gated by the base route group, so it enforces the
333                    // base group's roles
334                    allowed_roles: base_roles,
335                });
336            if let listeners::AuthenticatorKind::None = authenticator_kind {
337                ws_router = ws_router.layer(middleware::from_fn(x_materialize_user_header_auth));
338            }
339            router = router.merge(ws_router);
340        }
341        if let RouteGroup::Enabled(profiling_roles) = routes_enabled.profiling {
342            let profiling_group = Router::new()
343                .nest("/prof/", mz_prof_http::router(&BUILD_INFO))
344                .authorize(profiling_roles);
345            base_router = base_router.merge(profiling_group);
346        }
347
348        if routes_enabled.webhook.is_enabled() {
349            let webhook_router = Router::new()
350                .route(
351                    "/api/webhook/{:database}/{:schema}/{:id}",
352                    routing::post(webhook::handle_webhook),
353                )
354                .with_state(WebhookState {
355                    adapter_client_rx: adapter_client_rx.clone(),
356                    webhook_cache,
357                    dyncfgs,
358                })
359                .layer(
360                    tower_http::decompression::RequestDecompressionLayer::new()
361                        .gzip(true)
362                        .deflate(true)
363                        .br(true)
364                        .zstd(true),
365                )
366                // The webhook handler enforces WEBHOOK_MAX_REQUEST_SIZE_BYTES
367                // itself via to_bytes on a raw Body. This disable is defense-in-depth:
368                // it only matters if the handler ever returns to a Bytes extractor,
369                // which would otherwise inherit the global 5 MiB limit.
370                .layer(DefaultBodyLimit::disable())
371                .layer(
372                    CorsLayer::new()
373                        .allow_methods(Method::POST)
374                        .allow_origin(AllowOrigin::mirror_request())
375                        .allow_headers(Any),
376                )
377                .layer(
378                    ServiceBuilder::new()
379                        .layer(HandleErrorLayer::new(handle_load_error))
380                        .load_shed()
381                        .layer(GlobalConcurrencyLimitLayer::with_semaphore(
382                            concurrent_webhook_req,
383                        )),
384                );
385            router = router.merge(webhook_router);
386        }
387
388        if let RouteGroup::Enabled(internal_roles) = routes_enabled.internal {
389            let console_config = Arc::new(console::ConsoleProxyConfig::new(
390                internal_route_config.internal_console_redirect_url.clone(),
391                "/internal-console".to_string(),
392            ));
393            let internal_group = Router::new()
394                .route(
395                    "/api/opentelemetry/config",
396                    routing::put({
397                        move |_: axum::Json<DynamicFilterTarget>| async {
398                            (
399                                StatusCode::BAD_REQUEST,
400                                "This endpoint has been replaced. \
401                            Use the `opentelemetry_filter` system variable."
402                                    .to_string(),
403                            )
404                        }
405                    }),
406                )
407                .route(
408                    "/api/stderr/config",
409                    routing::put({
410                        move |_: axum::Json<DynamicFilterTarget>| async {
411                            (
412                                StatusCode::BAD_REQUEST,
413                                "This endpoint has been replaced. \
414                            Use the `log_filter` system variable."
415                                    .to_string(),
416                            )
417                        }
418                    }),
419                )
420                .route("/api/tracing", routing::get(mz_http_util::handle_tracing))
421                .route(
422                    "/api/catalog/dump",
423                    routing::get(catalog::handle_catalog_dump),
424                )
425                .route(
426                    "/api/catalog/check",
427                    routing::get(catalog::handle_catalog_check),
428                )
429                .route(
430                    "/api/catalog/inject-audit-events",
431                    routing::post(catalog::handle_inject_audit_events),
432                )
433                .route(
434                    "/api/coordinator/check",
435                    routing::get(catalog::handle_coordinator_check),
436                )
437                .route(
438                    "/api/coordinator/dump",
439                    routing::get(catalog::handle_coordinator_dump),
440                )
441                .route(
442                    "/internal-console",
443                    routing::get(|| async { Redirect::temporary("/internal-console/") }),
444                )
445                .route(
446                    "/internal-console/{*path}",
447                    routing::get(console::handle_internal_console)
448                        .post(console::handle_internal_console),
449                )
450                .route(
451                    "/internal-console/",
452                    routing::get(console::handle_internal_console)
453                        .post(console::handle_internal_console),
454                )
455                // Cluster HTTP proxy routes.
456                .route("/clusters", routing::get(cluster::handle_clusters))
457                .route(
458                    "/api/cluster/{:cluster_id}/replica/{:replica_id}/process/{:process}/",
459                    routing::any(cluster::handle_cluster_proxy_root),
460                )
461                .route(
462                    "/api/cluster/{:cluster_id}/replica/{:replica_id}/process/{:process}/{*path}",
463                    routing::any(cluster::handle_cluster_proxy),
464                )
465                .layer(Extension(console_config))
466                .layer(Extension(Arc::clone(&cluster_proxy_config)))
467                .authorize(internal_roles);
468            base_router = base_router.merge(internal_group);
469
470            let leader_router = Router::new()
471                .route("/api/leader/status", routing::get(handle_leader_status))
472                .route("/api/leader/promote", routing::post(handle_leader_promote))
473                .route(
474                    "/api/leader/skip-catchup",
475                    routing::post(handle_leader_skip_catchup),
476                )
477                .authorize(internal_roles)
478                .layer(auth_middleware.clone())
479                .with_state(internal_route_config.deployment_state_handle.clone());
480            router = router.merge(leader_router);
481        }
482
483        if let RouteGroup::Enabled(metrics_roles) = routes_enabled.metrics {
484            // Clone into the closure so the outer `metrics_registry` binding
485            // stays available for other route blocks below (e.g. MCP metric
486            // registration).
487            let metrics_registry_for_handler = metrics_registry.clone();
488            let metrics_router = Router::new()
489                .route(
490                    "/metrics",
491                    routing::get(move |headers: HeaderMap| async move {
492                        mz_http_util::handle_prometheus(&metrics_registry_for_handler, headers)
493                            .await
494                    }),
495                )
496                .route(
497                    "/metrics/mz_usage",
498                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
499                        let registry = sql::handle_promsql(client, USAGE_METRIC_QUERIES).await;
500                        mz_http_util::handle_prometheus(&registry, headers).await
501                    }),
502                )
503                .route(
504                    "/metrics/mz_frontier",
505                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
506                        let registry = sql::handle_promsql(client, FRONTIER_METRIC_QUERIES).await;
507                        mz_http_util::handle_prometheus(&registry, headers).await
508                    }),
509                )
510                .route(
511                    "/metrics/mz_compute",
512                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
513                        let registry = sql::handle_promsql(client, COMPUTE_METRIC_QUERIES).await;
514                        mz_http_util::handle_prometheus(&registry, headers).await
515                    }),
516                )
517                .route(
518                    "/metrics/mz_storage",
519                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
520                        let registry = sql::handle_promsql(client, STORAGE_METRIC_QUERIES).await;
521                        mz_http_util::handle_prometheus(&registry, headers).await
522                    }),
523                )
524                .route(
525                    "/api/livez",
526                    routing::get(mz_http_util::handle_liveness_check),
527                )
528                .route("/api/readyz", routing::get(probe::handle_ready))
529                .authorize(metrics_roles)
530                .layer(auth_middleware.clone())
531                .layer(Extension(adapter_client_rx.clone()))
532                .layer(Extension(active_connection_counter.clone()))
533                .layer(Extension(HelmChartVersion(helm_chart_version.clone())));
534            router = router.merge(metrics_router);
535        }
536
537        if routes_enabled.console_config.is_enabled() {
538            let console_config_router = Router::new()
539                .route(
540                    "/api/console/config",
541                    routing::get(console::handle_console_config),
542                )
543                .layer(Extension(adapter_client_rx.clone()))
544                .layer(Extension(active_connection_counter.clone()));
545            router = router.merge(console_config_router);
546        }
547
548        // MCP (Model Context Protocol) endpoints
549        // Enabled via runtime `routes_enabled.mcp_agent` and `routes_enabled.mcp_developer` configuration
550        if routes_enabled.mcp_agent.is_enabled() || routes_enabled.mcp_developer.is_enabled() {
551            use tracing::info;
552
553            // RFC 9728 Protected Resource Metadata. Public route: MCP
554            // clients fetch it before they have a token. Sits on its own
555            // router so the auth middleware never runs on it. The handler
556            // 404s when the listener does not advertise OAuth (see
557            // `McpOAuthDiscovery`) or `oidc_issuer` is unset, so it is safe
558            // to enable unconditionally whenever MCP is enabled.
559            // RFC 9728 ยง3.1 lets clients look up per-resource metadata
560            // via a path-suffixed well-known URI before falling back to
561            // the bare one. The MCP endpoints share an identical
562            // metadata view today, so we serve the same handler at all
563            // three paths.
564            let oauth_metadata_router = Router::new()
565                .route(
566                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH,
567                    routing::get(oauth_metadata::handle_protected_resource_metadata),
568                )
569                .route(
570                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_AGENT,
571                    routing::get(oauth_metadata::handle_protected_resource_metadata),
572                )
573                .route(
574                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_DEVELOPER,
575                    routing::get(oauth_metadata::handle_protected_resource_metadata),
576                )
577                .layer(Extension(adapter_client_rx.clone()))
578                .layer(Extension(oauth_metadata::McpOAuthConfig {
579                    http_host_name: http_host_name.clone(),
580                    discovery: Arc::clone(&oauth_discovery),
581                }))
582                .layer(Extension(oauth_metadata_metrics.clone()));
583            router = router.merge(oauth_metadata_router);
584
585            let mut mcp_router = Router::new();
586
587            if let RouteGroup::Enabled(mcp_agent_roles) = routes_enabled.mcp_agent {
588                info!("Enabling MCP agent endpoint: /api/mcp/agent");
589                let agent_router = Router::new()
590                    .route(
591                        "/api/mcp/agent",
592                        routing::post(mcp::handle_mcp_agent)
593                            .get(mcp::handle_mcp_method_not_allowed),
594                    )
595                    .authorize(mcp_agent_roles);
596                mcp_router = mcp_router.merge(agent_router);
597            }
598
599            if let RouteGroup::Enabled(mcp_developer_roles) = routes_enabled.mcp_developer {
600                info!("Enabling MCP developer endpoint: /api/mcp/developer");
601                let developer_router = Router::new()
602                    .route(
603                        "/api/mcp/developer",
604                        routing::post(mcp::handle_mcp_developer)
605                            .get(mcp::handle_mcp_method_not_allowed),
606                    )
607                    .authorize(mcp_developer_roles);
608                mcp_router = mcp_router.merge(developer_router);
609            }
610
611            // The MCP handlers perform a server-side Origin check against this
612            // allowlist to defend against DNS rebinding attacks (see
613            // database-issues#11311). The CorsLayer alone is not enough: in a
614            // DNS rebinding attack the browser considers the request
615            // same-origin, so no preflight fires and CORS enforcement is
616            // bypassed.
617            let mcp_allowed_origins = Arc::new(allowed_origin_list.clone());
618            mcp_router = mcp_router
619                .layer(auth_middleware.clone())
620                .layer(Extension(oauth_metadata::McpOAuthConfig {
621                    http_host_name: http_host_name.clone(),
622                    discovery: Arc::clone(&oauth_discovery),
623                }))
624                .layer(Extension(adapter_client_rx.clone()))
625                .layer(Extension(active_connection_counter.clone()))
626                .layer(Extension(HelmChartVersion(helm_chart_version.clone())))
627                .layer(Extension(mcp_allowed_origins))
628                .layer(Extension(mcp_metrics))
629                .layer(
630                    CorsLayer::new()
631                        .allow_methods(Method::POST)
632                        .allow_origin(allowed_origin.clone())
633                        .allow_headers([AUTHORIZATION, CONTENT_TYPE]),
634                );
635            // Trust `x-materialize-user` injected by an upstream proxy on
636            // listeners without HTTP auth, matching `base_router` and
637            // `ws_router` (CLO-158).
638            if let listeners::AuthenticatorKind::None = authenticator_kind {
639                mcp_router = mcp_router.layer(middleware::from_fn(x_materialize_user_header_auth));
640            }
641            router = router.merge(mcp_router);
642        }
643
644        base_router = base_router
645            .layer(auth_middleware.clone())
646            .layer(Extension(adapter_client_rx.clone()))
647            .layer(Extension(active_connection_counter.clone()))
648            .layer(Extension(HelmChartVersion(helm_chart_version)))
649            .layer(
650                CorsLayer::new()
651                    .allow_credentials(false)
652                    .allow_headers([
653                        AUTHORIZATION,
654                        CONTENT_TYPE,
655                        HeaderName::from_static("x-materialize-version"),
656                    ])
657                    .allow_methods(Any)
658                    .allow_origin(allowed_origin)
659                    .expose_headers(Any)
660                    .max_age(Duration::from_secs(60) * 60),
661            );
662
663        match authenticator_kind {
664            listeners::AuthenticatorKind::Password | listeners::AuthenticatorKind::Oidc => {
665                base_router = base_router.layer(session_layer.clone());
666
667                let login_router = Router::new()
668                    .route("/api/login", routing::post(handle_login))
669                    .route("/api/logout", routing::post(handle_logout))
670                    .layer(Extension(adapter_client_rx));
671                router = router.merge(login_router).layer(session_layer);
672            }
673            listeners::AuthenticatorKind::None => {
674                base_router =
675                    base_router.layer(middleware::from_fn(x_materialize_user_header_auth));
676            }
677            _ => {}
678        }
679
680        router = router
681            .merge(base_router)
682            .apply_default_layers(source, metrics);
683
684        HttpServer { tls, router }
685    }
686}
687
688impl Server for HttpServer {
689    const NAME: &'static str = "http";
690
691    fn handle_connection(
692        &self,
693        conn: Connection,
694        _tokio_metrics_intervals: impl Iterator<Item = TaskMetrics> + Send + 'static,
695    ) -> ConnectionHandler {
696        let router = self.router.clone();
697        let tls_context = self.tls.clone();
698        let mut conn = TokioIo::new(conn);
699
700        Box::pin(async {
701            let direct_peer_addr = conn.inner().peer_addr().context("fetching peer addr")?;
702            let peer_addr = conn
703                .inner_mut()
704                .take_proxy_header_address()
705                .await
706                .map(|a| a.source)
707                .unwrap_or(direct_peer_addr);
708
709            let (conn, conn_protocol) = match tls_context {
710                Some(tls_context) => {
711                    let mut ssl_stream = SslStream::new(Ssl::new(&tls_context.get())?, conn)?;
712                    if let Err(e) = Pin::new(&mut ssl_stream).accept().await {
713                        let _ = ssl_stream.get_mut().inner_mut().shutdown().await;
714                        return Err(e.into());
715                    }
716                    (MaybeHttpsStream::Https(ssl_stream), ConnProtocol::Https)
717                }
718                _ => (MaybeHttpsStream::Http(conn), ConnProtocol::Http),
719            };
720            let mut make_tower_svc = router
721                .layer(Extension(conn_protocol))
722                .into_make_service_with_connect_info::<SocketAddr>();
723            let tower_svc = make_tower_svc.call(peer_addr).await.unwrap();
724            let hyper_svc = hyper::service::service_fn(|req| tower_svc.clone().call(req));
725            let http = hyper::server::conn::http1::Builder::new();
726            http.serve_connection(conn, hyper_svc)
727                .with_upgrades()
728                .err_into()
729                .await
730        })
731    }
732}
733
734pub async fn handle_leader_status(
735    State(deployment_state_handle): State<DeploymentStateHandle>,
736) -> impl IntoResponse {
737    let status = deployment_state_handle.status();
738    (StatusCode::OK, Json(json!({ "status": status })))
739}
740
741pub async fn handle_leader_promote(
742    State(deployment_state_handle): State<DeploymentStateHandle>,
743) -> impl IntoResponse {
744    match deployment_state_handle.try_promote() {
745        Ok(()) => {
746            // TODO(benesch): the body here is redundant. Should just return
747            // 204.
748            let status = StatusCode::OK;
749            let body = Json(json!({
750                "result": "Success",
751            }));
752            (status, body)
753        }
754        Err(()) => {
755            // TODO(benesch): the nesting here is redundant given the error
756            // code. Should just return the `{"message": "..."}` object.
757            let status = StatusCode::BAD_REQUEST;
758            let body = Json(json!({
759                "result": {"Failure": {"message": "cannot promote leader while initializing"}},
760            }));
761            (status, body)
762        }
763    }
764}
765
766pub async fn handle_leader_skip_catchup(
767    State(deployment_state_handle): State<DeploymentStateHandle>,
768) -> impl IntoResponse {
769    match deployment_state_handle.try_skip_catchup() {
770        Ok(()) => StatusCode::NO_CONTENT.into_response(),
771        Err(()) => {
772            let status = StatusCode::BAD_REQUEST;
773            let body = Json(json!({
774                "message": "cannot skip catchup in this phase of initialization; try again later",
775            }));
776            (status, body).into_response()
777        }
778    }
779}
780
781async fn x_materialize_user_header_auth(mut req: Request, next: Next) -> impl IntoResponse {
782    // TODO migrate teleport to basic auth and remove this.
783    if let Some(username) = req.headers().get("x-materialize-user").map(|h| h.to_str()) {
784        let username = match username {
785            Ok(name @ (SUPPORT_USER_NAME | SYSTEM_USER_NAME)) => name.to_string(),
786            _ => {
787                return Err(AuthError::MismatchedUser(format!(
788                    "user specified in x-materialize-user must be {SUPPORT_USER_NAME} or {SYSTEM_USER_NAME}"
789                )));
790            }
791        };
792        // Authorization runs later: for HTTP routes
793        // in the `http_authz` middleware, and for WebSocket connections inside
794        // `init_ws`. This middleware only resolves the injected identity.
795        req.extensions_mut().insert(AuthedUser {
796            name: username,
797            external_metadata_rx: None,
798            authenticated: Authenticated,
799            authenticator_kind: mz_auth::AuthenticatorKind::None,
800            groups: None,
801        });
802    }
803    Ok(next.run(req).await)
804}
805
806pub(crate) type Delayed<T> = Shared<oneshot::Receiver<T>>;
807
808/// Resolve the dyncfg-configured group claim path from a delayed adapter
809/// client. Callers must already have driven `adapter_client_rx` to readiness
810/// (e.g. via `get_authenticator`), so the await here is non-blocking.
811async fn group_claim_for(adapter_client_rx: &Delayed<Client>) -> String {
812    let client = adapter_client_rx
813        .clone()
814        .await
815        .expect("adapter client receiver dropped");
816    OIDC_GROUP_CLAIM.get(client.get_system_vars().await.dyncfgs())
817}
818
819#[derive(Clone)]
820enum ConnProtocol {
821    Http,
822    Https,
823}
824
825/// The `allowed_roles` policy for a route group, attached as a request
826/// extension on each authenticated route group and read by [`http_authz`].
827#[derive(Clone, Copy)]
828struct RouteAllowedRoles(AllowedRoles);
829
830/// Router extension for attaching the authorization middleware.
831trait AuthzRouterExt {
832    fn authorize(self, roles: AllowedRoles) -> Self;
833}
834
835impl<S> AuthzRouterExt for Router<S>
836where
837    S: Clone + Send + Sync + 'static,
838{
839    fn authorize(self, roles: AllowedRoles) -> Router<S> {
840        // Adds the `RouteAllowedRoles` extension to the request such that
841        // `http_authz` knows which roles to check for a RouteGroup.
842        self.layer(middleware::from_fn(http_authz))
843            .layer(Extension(RouteAllowedRoles(roles)))
844    }
845}
846
847#[derive(Clone, Debug)]
848pub struct AuthedUser {
849    name: String,
850    external_metadata_rx: Option<watch::Receiver<ExternalUserMetadata>>,
851    authenticated: Authenticated,
852    authenticator_kind: mz_auth::AuthenticatorKind,
853    /// Groups from JWT claims for OIDC group-to-role sync.
854    groups: Option<Vec<String>>,
855}
856
857pub struct AuthedClient {
858    pub client: SessionClient,
859    pub connection_guard: Option<ConnectionHandle>,
860}
861
862impl AuthedClient {
863    async fn new<F>(
864        adapter_client: &Client,
865        user: AuthedUser,
866        peer_addr: IpAddr,
867        active_connection_counter: ConnectionCounter,
868        helm_chart_version: Option<String>,
869        session_config: F,
870        options: BTreeMap<String, String>,
871        now: NowFn,
872    ) -> Result<Self, AdapterError>
873    where
874        F: FnOnce(&mut AdapterSession),
875    {
876        let conn_id = adapter_client.new_conn_id()?;
877        let mut session = adapter_client.new_session(
878            AdapterSessionConfig {
879                conn_id,
880                uuid: epoch_to_uuid_v7(&(now)()),
881                user: user.name,
882                client_ip: Some(peer_addr),
883                external_metadata_rx: user.external_metadata_rx,
884                helm_chart_version,
885                authenticator_kind: user.authenticator_kind,
886                groups: user.groups,
887            },
888            user.authenticated,
889        );
890        let connection_guard = active_connection_counter.allocate_connection(session.user())?;
891
892        session_config(&mut session);
893        let system_vars = adapter_client.get_system_vars().await;
894        for (key, val) in options {
895            const LOCAL: bool = false;
896            if let Err(err) =
897                session
898                    .vars_mut()
899                    .set(&system_vars, &key, VarInput::Flat(&val), LOCAL)
900            {
901                session.add_notice(AdapterNotice::BadStartupSetting {
902                    name: key.to_string(),
903                    reason: err.to_string(),
904                })
905            }
906        }
907        let adapter_client = adapter_client.startup(session).await?;
908        Ok(AuthedClient {
909            client: adapter_client,
910            connection_guard,
911        })
912    }
913}
914
915impl<S> FromRequestParts<S> for AuthedClient
916where
917    S: Send + Sync,
918{
919    type Rejection = Response;
920
921    async fn from_request_parts(
922        req: &mut http::request::Parts,
923        state: &S,
924    ) -> Result<Self, Self::Rejection> {
925        #[derive(Debug, Default, Deserialize)]
926        struct Params {
927            #[serde(default)]
928            options: String,
929        }
930        let params: Query<Params> = Query::from_request_parts(req, state)
931            .await
932            .unwrap_or_default();
933
934        let peer_addr = req
935            .extensions
936            .get::<ConnectInfo<SocketAddr>>()
937            .expect("ConnectInfo extension guaranteed to exist")
938            .0
939            .ip();
940
941        let user = req.extensions.get::<AuthedUser>().unwrap();
942        let adapter_client = req
943            .extensions
944            .get::<Delayed<mz_adapter::Client>>()
945            .unwrap()
946            .clone();
947        let adapter_client = adapter_client.await.map_err(|_| {
948            (StatusCode::INTERNAL_SERVER_ERROR, "adapter client missing").into_response()
949        })?;
950        let active_connection_counter = req.extensions.get::<ConnectionCounter>().unwrap();
951        let helm_chart_version = req
952            .extensions
953            .get::<HelmChartVersion>()
954            .map(|h| h.0.clone())
955            .unwrap_or(None);
956
957        let options = if params.options.is_empty() {
958            // It's possible 'options' simply wasn't provided, we don't want that to
959            // count as a failure to deserialize
960            BTreeMap::<String, String>::default()
961        } else {
962            match serde_json::from_str(&params.options) {
963                Ok(options) => options,
964                Err(_e) => {
965                    // If we fail to deserialize options, fail the request.
966                    let code = StatusCode::BAD_REQUEST;
967                    let msg = format!("Failed to deserialize {} map", "options".quoted());
968                    return Err((code, msg).into_response());
969                }
970            }
971        };
972
973        let client = AuthedClient::new(
974            &adapter_client,
975            user.clone(),
976            peer_addr,
977            active_connection_counter.clone(),
978            helm_chart_version,
979            |session| {
980                session
981                    .vars_mut()
982                    .set_default(WELCOME_MESSAGE.name(), VarInput::Flat(&false.format()))
983                    .expect("known to exist")
984            },
985            options,
986            SYSTEM_TIME.clone(),
987        )
988        .await
989        .map_err(|e| {
990            let status = match e {
991                AdapterError::UserSessionsDisallowed | AdapterError::NetworkPolicyDenied(_) => {
992                    StatusCode::FORBIDDEN
993                }
994                _ => StatusCode::INTERNAL_SERVER_ERROR,
995            };
996            (status, Json(SqlError::from(e))).into_response()
997        })?;
998
999        Ok(client)
1000    }
1001}
1002
1003/// Per-request decision about which `WWW-Authenticate` challenges to emit
1004/// on a 401, computed by the auth middleware.
1005///
1006/// Carries both the `Basic` toggle (today's behavior, kept for the SQL HTTP
1007/// layer and friends) and an optional `Bearer` challenge with a
1008/// `resource_metadata` URL per RFC 9728. The Bearer challenge is only set on
1009/// routes that attach an [`oauth_metadata::McpOAuthConfig`] extension; other
1010/// routes emit only `Basic` so their behavior is unchanged.
1011#[derive(Debug, Clone, Default)]
1012pub(crate) struct WwwAuthenticateChallenges {
1013    /// Whether to emit `WWW-Authenticate: Basic realm=Materialize`.
1014    pub include_basic: bool,
1015    /// If `Some`, also emit `WWW-Authenticate: Bearer
1016    /// resource_metadata="<url>"`. The URL points at this server's RFC 9728
1017    /// Protected Resource Metadata document, which advertises the
1018    /// authorization server the client should use.
1019    pub bearer_resource_metadata: Option<String>,
1020    /// If `Some`, also emit `scope="<scope>"` inside the Bearer challenge.
1021    /// Tells clients which OAuth scope to request a token with for this
1022    /// resource. Only set in conjunction with `bearer_resource_metadata`
1023    /// (a scope challenge with no resource hint would be confusing).
1024    pub bearer_scope: Option<&'static str>,
1025}
1026
1027#[derive(Debug, Error)]
1028pub(crate) enum AuthError {
1029    #[error("role dissallowed")]
1030    RoleDisallowed(String),
1031    #[error("{0}")]
1032    Frontegg(#[from] FronteggError),
1033    #[error("missing authorization header")]
1034    MissingHttpAuthentication {
1035        challenges: WwwAuthenticateChallenges,
1036    },
1037    #[error("{0}")]
1038    MismatchedUser(String),
1039    #[error("session expired")]
1040    SessionExpired,
1041    #[error("failed to update session")]
1042    FailedToUpdateSession,
1043    #[error("invalid credentials")]
1044    InvalidCredentials,
1045    /// Payload is `OidcError`'s sanitized `Display` (no expected-values leaks).
1046    #[error("{0}")]
1047    OidcFailed(String),
1048}
1049
1050impl IntoResponse for AuthError {
1051    fn into_response(self) -> Response {
1052        warn!("HTTP request failed authentication: {}", self);
1053        let mut headers = HeaderMap::new();
1054        // We omit most detail from the error message we send to the client, to
1055        // avoid giving attackers unnecessary information. `OidcFailed` is the
1056        // exception: its payload is a sanitized `OidcError::Display` that the
1057        // console embeds in the login-page error.
1058        let body = match &self {
1059            // Bearer goes first so OAuth-aware clients see it before the
1060            // Basic fallback. RFC 7235 allows emitting multiple
1061            // `WWW-Authenticate` headers; we use one per scheme so each
1062            // challenge is unambiguously framed; some parsers struggle
1063            // with multiple schemes on a single header value.
1064            AuthError::MissingHttpAuthentication { challenges } => {
1065                if let Some(resource_metadata) = &challenges.bearer_resource_metadata {
1066                    // `scope` is hard-coded to a vetted constant
1067                    // (`MCP_SCOPE`); only `resource_metadata` is derived
1068                    // from a header value, and `resolve_host` has already
1069                    // round-tripped it through the URI grammar. The quoted
1070                    // form follows RFC 6749 ยง3.3 / RFC 6750 ยง3.
1071                    let value = match &challenges.bearer_scope {
1072                        Some(scope) => format!(
1073                            "Bearer scope=\"{scope}\", resource_metadata=\"{resource_metadata}\"",
1074                        ),
1075                        None => format!("Bearer resource_metadata=\"{resource_metadata}\""),
1076                    };
1077                    match HeaderValue::from_str(&value) {
1078                        Ok(v) => {
1079                            headers.append(http::header::WWW_AUTHENTICATE, v);
1080                        }
1081                        Err(e) => {
1082                            warn!(
1083                                "skipping Bearer WWW-Authenticate challenge: invalid header \
1084                                 value derived from resource_metadata={resource_metadata:?}: {e}",
1085                            );
1086                        }
1087                    }
1088                }
1089                if challenges.include_basic {
1090                    headers.append(
1091                        http::header::WWW_AUTHENTICATE,
1092                        HeaderValue::from_static("Basic realm=Materialize"),
1093                    );
1094                }
1095                "unauthorized".to_string()
1096            }
1097            AuthError::OidcFailed(message) => message.clone(),
1098            _ => "unauthorized".to_string(),
1099        };
1100        (StatusCode::UNAUTHORIZED, headers, body).into_response()
1101    }
1102}
1103
1104// Simplified login handler
1105pub async fn handle_login(
1106    session: Option<Extension<TowerSession>>,
1107    Extension(adapter_client_rx): Extension<Delayed<Client>>,
1108    Json(LoginCredentials { username, password }): Json<LoginCredentials>,
1109) -> impl IntoResponse {
1110    // The listener's `allowed_roles` policy is not enforced here. Login only
1111    // mints a session. Authorization runs per request in the `http_authz`
1112    // middleware (and in `init_ws` for WebSocket), so a session for a
1113    // disallowed role cannot actually reach any route.
1114    let Ok(adapter_client) = adapter_client_rx.clone().await else {
1115        return StatusCode::INTERNAL_SERVER_ERROR;
1116    };
1117    let authenticated = match adapter_client.authenticate(&username, &password).await {
1118        Ok(authenticated) => authenticated,
1119        Err(err) => {
1120            warn!(?err, "HTTP login failed authentication");
1121            return StatusCode::UNAUTHORIZED;
1122        }
1123    };
1124    // Create session data
1125    let session_data = TowerSessionData {
1126        username,
1127        created_at: SystemTime::now(),
1128        last_activity: SystemTime::now(),
1129        authenticated,
1130        authenticator_kind: mz_auth::AuthenticatorKind::Password,
1131    };
1132    // Store session data
1133    let session = session.and_then(|Extension(session)| Some(session));
1134    let Some(session) = session else {
1135        return StatusCode::INTERNAL_SERVER_ERROR;
1136    };
1137    match session.insert("data", &session_data).await {
1138        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
1139        Ok(_) => StatusCode::OK,
1140    }
1141}
1142
1143// Simplified logout handler
1144pub async fn handle_logout(session: Option<Extension<TowerSession>>) -> impl IntoResponse {
1145    let session = session.and_then(|Extension(session)| Some(session));
1146    let Some(session) = session else {
1147        return StatusCode::INTERNAL_SERVER_ERROR;
1148    };
1149    // Delete session
1150    match session.delete().await {
1151        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
1152        Ok(_) => StatusCode::OK,
1153    }
1154}
1155
1156/// Authentication middleware.
1157async fn http_auth(
1158    mut req: Request,
1159    next: Next,
1160    tls_enabled: bool,
1161    authenticator_kind: listeners::AuthenticatorKind,
1162    frontegg: Option<mz_frontegg_auth::Authenticator>,
1163    oidc_rx: Delayed<mz_authenticator::GenericOidcAuthenticator>,
1164    adapter_client_rx: Delayed<Client>,
1165) -> Result<impl IntoResponse, AuthError> {
1166    let creds = if let Some(basic) = req.headers().typed_get::<Authorization<Basic>>() {
1167        Some(Credentials::Password {
1168            username: basic.username().to_owned(),
1169            password: Password(basic.password().to_owned()),
1170        })
1171    } else if let Some(bearer) = req.headers().typed_get::<Authorization<Bearer>>() {
1172        Some(Credentials::Token {
1173            token: bearer.token().to_owned(),
1174        })
1175    } else {
1176        None
1177    };
1178
1179    // Reuses an authenticated session if one already exists.
1180    // If credentials are provided, we perform a new authentication,
1181    // separate from the existing session.
1182    if creds.is_none()
1183        && let Some((session, session_data)) =
1184            maybe_get_authenticated_session(req.extensions().get::<TowerSession>()).await
1185    {
1186        let user = ensure_session_unexpired(session, session_data).await?;
1187        // Authorization runs next in the `http_authz` middleware, which reads
1188        // this `AuthedUser` and the route's `RouteAllowedRoles`.
1189        req.extensions_mut().insert(user);
1190        return Ok(next.run(req).await);
1191    }
1192
1193    // First, extract the username from the certificate, validating that the
1194    // connection matches the TLS configuration along the way.
1195    // Fall back to existing authentication methods.
1196    let conn_protocol = req.extensions().get::<ConnProtocol>().unwrap();
1197    match (tls_enabled, &conn_protocol) {
1198        (false, ConnProtocol::Http) => {}
1199        (false, ConnProtocol::Https { .. }) => unreachable!(),
1200        (true, ConnProtocol::Http) => {
1201            let mut parts = req.uri().clone().into_parts();
1202            parts.scheme = Some(Scheme::HTTPS);
1203            return Ok(Redirect::permanent(
1204                &Uri::from_parts(parts)
1205                    .expect("it was already a URI, just changed the scheme")
1206                    .to_string(),
1207            )
1208            .into_response());
1209        }
1210        (true, ConnProtocol::Https { .. }) => {}
1211    }
1212    // If we've already passed some other auth, just use that.
1213    if req.extensions().get::<AuthedUser>().is_some() {
1214        return Ok(next.run(req).await);
1215    }
1216
1217    let path = req.uri().path();
1218    // Routes that advertise OAuth opt in by attaching an `McpOAuthConfig`
1219    // extension; the middleware stays path-agnostic. Routes that opt in also
1220    // get a `Basic` challenge so existing curl/Bearer-already users still see
1221    // a usable challenge. See `crate::http::oauth_metadata` for the discovery
1222    // document; the challenge and the discovery handler read the same
1223    // `McpOAuthConfig`, so they emit the same authorization server and the
1224    // same host for a given listener.
1225    let oauth_config = req
1226        .extensions()
1227        .get::<oauth_metadata::McpOAuthConfig>()
1228        .cloned();
1229    let include_basic = path == "/"
1230        || PROFILING_API_ENDPOINTS
1231            .iter()
1232            .any(|prefix| path.starts_with(prefix))
1233        || oauth_config.is_some();
1234    let (bearer_resource_metadata, bearer_scope) = if let Some(config) = &oauth_config
1235        && config.discovery.is_enabled()
1236    {
1237        (
1238            oauth_metadata::metadata_url(&req, config.http_host_name.as_deref()),
1239            Some(config.scope()),
1240        )
1241    } else {
1242        (None, None)
1243    };
1244    let challenges = WwwAuthenticateChallenges {
1245        include_basic,
1246        bearer_resource_metadata,
1247        bearer_scope,
1248    };
1249    let authenticator = get_authenticator(
1250        authenticator_kind,
1251        creds.as_ref(),
1252        frontegg,
1253        &oidc_rx,
1254        &adapter_client_rx,
1255    )
1256    .await;
1257
1258    // Only the Frontegg arm consumes `group_claim`; resolving it requires a
1259    // coordinator round-trip (`Command::GetSystemVars`), so skip it for
1260    // None/Password/OIDC paths. Otherwise unauth'd probes like `/api/livez`,
1261    // `/api/readyz`, and `/metrics` on a `None`-auth listener would couple
1262    // liveness to coordinator health. Mirrors the pgwire path.
1263    let group_claim = if matches!(authenticator, Authenticator::Frontegg(_)) {
1264        Some(group_claim_for(&adapter_client_rx).await)
1265    } else {
1266        None
1267    };
1268    let user = auth(&authenticator, creds, &challenges, group_claim.as_deref()).await?;
1269
1270    // Add the authenticated user as an extension so downstream handlers can
1271    // inspect it if necessary.
1272    req.extensions_mut().insert(user);
1273
1274    // Run the request.
1275    Ok(next.run(req).await)
1276}
1277
1278/// Authorization middleware. Enforces the route group's `allowed_roles` policy
1279/// against the authenticated user.
1280///
1281/// Layered immediately inside [`http_auth`] on every authenticated route group,
1282/// always paired with a [`RouteAllowedRoles`] extension. By that wiring both the
1283/// `AuthedUser` (from `http_auth`) and the `RouteAllowedRoles` extension are
1284/// present whenever this runs. A missing extension is a wiring bug, so we fail
1285/// closed rather than skip the check.
1286async fn http_authz(req: Request, next: Next) -> Result<impl IntoResponse, AuthError> {
1287    match (
1288        req.extensions().get::<AuthedUser>(),
1289        req.extensions().get::<RouteAllowedRoles>().copied(),
1290    ) {
1291        (Some(user), Some(RouteAllowedRoles(allowed_roles))) => {
1292            check_role_allowed(&user.name, allowed_roles)?;
1293            Ok(next.run(req).await)
1294        }
1295        _ => {
1296            warn!("http_authz missing AuthedUser or RouteAllowedRoles extension; denying request");
1297            Err(AuthError::RoleDisallowed("<unknown>".to_string()))
1298        }
1299    }
1300}
1301
1302async fn init_ws(
1303    WsState {
1304        frontegg,
1305        oidc_rx,
1306        authenticator_kind,
1307        adapter_client_rx,
1308        active_connection_counter,
1309        helm_chart_version,
1310        allowed_roles,
1311    }: WsState,
1312    existing_user: Option<ExistingUser>,
1313    peer_addr: IpAddr,
1314    ws: &mut WebSocket,
1315) -> Result<AuthedClient, anyhow::Error> {
1316    // TODO: Add a timeout here to prevent resource leaks by clients that
1317    // connect then never send a message.
1318    let ws_auth: WebSocketAuth = loop {
1319        let init_msg = ws.recv().await.ok_or_else(|| anyhow::anyhow!("closed"))??;
1320        match init_msg {
1321            Message::Text(data) => break serde_json::from_str(&data)?,
1322            Message::Binary(data) => break serde_json::from_slice(&data)?,
1323            // Handled automatically by the server.
1324            Message::Ping(_) => {
1325                continue;
1326            }
1327            Message::Pong(_) => {
1328                continue;
1329            }
1330            Message::Close(_) => {
1331                anyhow::bail!("closed");
1332            }
1333        }
1334    };
1335
1336    // If credentials are provided, we perform a new authentication,
1337    // separate from the existing session.
1338    let (creds, options) = match ws_auth {
1339        WebSocketAuth::Basic {
1340            user,
1341            password,
1342            options,
1343        } => {
1344            let creds = Credentials::Password {
1345                username: user,
1346                password,
1347            };
1348            (Some(creds), options)
1349        }
1350        WebSocketAuth::Bearer { token, options } => {
1351            let creds = Credentials::Token { token };
1352            (Some(creds), options)
1353        }
1354        WebSocketAuth::OptionsOnly { options } => (None, options),
1355    };
1356
1357    let user = match (existing_user, creds) {
1358        (Some(ExistingUser::XMaterializeUserHeader(_)), Some(_creds)) => {
1359            warn!("Unexpected bearer or basic auth provided when using user header");
1360            anyhow::bail!("unexpected")
1361        }
1362        (Some(ExistingUser::Session(user)), None) => user,
1363        (Some(ExistingUser::XMaterializeUserHeader(user)), None) => user,
1364        (_, Some(creds)) => {
1365            let authenticator = get_authenticator(
1366                authenticator_kind,
1367                Some(&creds),
1368                frontegg,
1369                &oidc_rx,
1370                &adapter_client_rx,
1371            )
1372            .await;
1373            // WebSocket init: no 401-with-challenge contract, the
1374            // client is reading WS frames, not parsing HTTP headers, so
1375            // we just suppress challenge emission entirely.
1376            let no_challenges = WwwAuthenticateChallenges::default();
1377            // See `http_auth`: only Frontegg uses `group_claim`, and the
1378            // fetch costs a coordinator round-trip.
1379            let group_claim = if matches!(authenticator, Authenticator::Frontegg(_)) {
1380                Some(group_claim_for(&adapter_client_rx).await)
1381            } else {
1382                None
1383            };
1384            let user = auth(
1385                &authenticator,
1386                Some(creds),
1387                &no_challenges,
1388                group_claim.as_deref(),
1389            )
1390            .await?;
1391            user
1392        }
1393        (None, None) => anyhow::bail!("expected auth information"),
1394    };
1395
1396    // Authorization. WebSocket connections authenticate after the HTTP upgrade
1397    // (by reading the first frame), so the `http_authz` middleware cannot cover
1398    // them. Enforce the authorization here instead, once, for every way
1399    // the user was resolved above (session, injected header, or credentials).
1400    check_role_allowed(&user.name, allowed_roles)?;
1401
1402    let client = AuthedClient::new(
1403        &adapter_client_rx.clone().await?,
1404        user,
1405        peer_addr,
1406        active_connection_counter.clone(),
1407        helm_chart_version.clone(),
1408        |_session| (),
1409        options,
1410        SYSTEM_TIME.clone(),
1411    )
1412    .await?;
1413
1414    Ok(client)
1415}
1416
1417enum Credentials {
1418    Password {
1419        username: String,
1420        password: Password,
1421    },
1422    Token {
1423        token: String,
1424    },
1425}
1426
1427async fn get_authenticator(
1428    kind: listeners::AuthenticatorKind,
1429    creds: Option<&Credentials>,
1430    frontegg: Option<mz_frontegg_auth::Authenticator>,
1431    oidc_rx: &Delayed<mz_authenticator::GenericOidcAuthenticator>,
1432    adapter_client_rx: &Delayed<Client>,
1433) -> Authenticator {
1434    match kind {
1435        listeners::AuthenticatorKind::Frontegg => Authenticator::Frontegg(frontegg.expect(
1436            "Frontegg authenticator should exist with listeners::AuthenticatorKind::Frontegg",
1437        )),
1438        listeners::AuthenticatorKind::Password | listeners::AuthenticatorKind::Sasl => {
1439            let client = adapter_client_rx.clone().await.expect("sender not dropped");
1440            Authenticator::Password(client)
1441        }
1442        listeners::AuthenticatorKind::Oidc => match creds {
1443            // Use the password authenticator if the credentials are password-based
1444            Some(Credentials::Password { .. }) => {
1445                let client = adapter_client_rx.clone().await.expect("sender not dropped");
1446                Authenticator::Password(client)
1447            }
1448            _ => Authenticator::Oidc(oidc_rx.clone().await.expect("sender not dropped")),
1449        },
1450        listeners::AuthenticatorKind::None => Authenticator::None,
1451    }
1452}
1453
1454/// Attempts to retrieve session data from a [`TowerSession`], if available.
1455/// Session data is present only if an authenticated session has been
1456/// established via [`handle_login`].
1457pub(crate) async fn maybe_get_authenticated_session(
1458    session: Option<&TowerSession>,
1459) -> Option<(&TowerSession, TowerSessionData)> {
1460    if let Some(session) = session {
1461        if let Ok(Some(session_data)) = session.get::<TowerSessionData>("data").await {
1462            return Some((session, session_data));
1463        }
1464    }
1465    None
1466}
1467
1468/// Ensures the session is still valid by checking for expiration,
1469/// and returns the associated user if the session remains active.
1470pub(crate) async fn ensure_session_unexpired(
1471    session: &TowerSession,
1472    session_data: TowerSessionData,
1473) -> Result<AuthedUser, AuthError> {
1474    if session_data
1475        .last_activity
1476        .elapsed()
1477        .unwrap_or(Duration::MAX)
1478        > SESSION_DURATION
1479    {
1480        let _ = session.delete().await;
1481        return Err(AuthError::SessionExpired);
1482    }
1483    let mut updated_data = session_data.clone();
1484    updated_data.last_activity = SystemTime::now();
1485    session
1486        .insert("data", &updated_data)
1487        .await
1488        .map_err(|_| AuthError::FailedToUpdateSession)?;
1489
1490    Ok(AuthedUser {
1491        name: session_data.username,
1492        external_metadata_rx: None,
1493        authenticated: session_data.authenticated,
1494        authenticator_kind: session_data.authenticator_kind,
1495        groups: None,
1496    })
1497}
1498
1499async fn auth(
1500    authenticator: &Authenticator,
1501    creds: Option<Credentials>,
1502    challenges: &WwwAuthenticateChallenges,
1503    group_claim: Option<&str>,
1504) -> Result<AuthedUser, AuthError> {
1505    let (name, external_metadata_rx, authenticated, groups) = match authenticator {
1506        Authenticator::Frontegg(frontegg) => match creds {
1507            Some(Credentials::Password { username, password }) => {
1508                let (auth_session, authenticated) = frontegg
1509                    .authenticate(&username, password.as_str(), group_claim)
1510                    .await?;
1511                let name = auth_session.user().into();
1512                let groups = auth_session.groups();
1513                let external_metadata_rx = Some(auth_session.external_metadata_rx());
1514                (name, external_metadata_rx, authenticated, groups)
1515            }
1516            Some(Credentials::Token { token }) => {
1517                let (claims, authenticated) =
1518                    frontegg.validate_access_token(&token, None, group_claim)?;
1519                let (_, external_metadata_rx) = watch::channel(ExternalUserMetadata {
1520                    user_id: claims.user_id,
1521                    admin: claims.is_admin,
1522                });
1523                (
1524                    claims.user,
1525                    Some(external_metadata_rx),
1526                    authenticated,
1527                    claims.groups,
1528                )
1529            }
1530            None => {
1531                return Err(AuthError::MissingHttpAuthentication {
1532                    challenges: challenges.clone(),
1533                });
1534            }
1535        },
1536        Authenticator::Password(adapter_client) => match creds {
1537            Some(Credentials::Password { username, password }) => {
1538                let authenticated = adapter_client
1539                    .authenticate(&username, &password)
1540                    .await
1541                    .map_err(|_| AuthError::InvalidCredentials)?;
1542                (username, None, authenticated, None)
1543            }
1544            _ => {
1545                return Err(AuthError::MissingHttpAuthentication {
1546                    challenges: challenges.clone(),
1547                });
1548            }
1549        },
1550        Authenticator::Sasl(_) => {
1551            // We shouldn't ever end up here as the configuration is validated at startup.
1552            // If we do, it's a server misconfiguration.
1553            // Just in case, we return a 401 rather than panic.
1554            return Err(AuthError::MissingHttpAuthentication {
1555                challenges: challenges.clone(),
1556            });
1557        }
1558        Authenticator::Oidc(oidc) => match creds {
1559            Some(Credentials::Token { token }) => {
1560                let (mut claims, authenticated) = oidc
1561                    .authenticate(&token, None)
1562                    .await
1563                    .map_err(|e| AuthError::OidcFailed(e.to_string()))?;
1564                let name = std::mem::take(&mut claims.user);
1565                let groups = claims.groups.take();
1566                (name, None, authenticated, groups)
1567            }
1568            _ => {
1569                return Err(AuthError::MissingHttpAuthentication {
1570                    challenges: challenges.clone(),
1571                });
1572            }
1573        },
1574        Authenticator::None => {
1575            // If no authentication, use whatever is in the HTTP auth
1576            // header (without checking the password), or fall back to the
1577            // default user.
1578            let name = match creds {
1579                Some(Credentials::Password { username, .. }) => username,
1580                _ => HTTP_DEFAULT_USER.name.to_owned(),
1581            };
1582            (name, None, Authenticated, None)
1583        }
1584    };
1585
1586    Ok(AuthedUser {
1587        name,
1588        external_metadata_rx,
1589        authenticated,
1590        authenticator_kind: authenticator.kind(),
1591        groups,
1592    })
1593}
1594
1595// TODO move this somewhere it can be shared with PGWIRE
1596fn check_role_allowed(name: &str, allowed_roles: AllowedRoles) -> Result<(), AuthError> {
1597    let is_internal_user = INTERNAL_USER_NAMES.contains(name);
1598    // this is a superset of internal users
1599    let is_reserved_user = mz_adapter::catalog::is_reserved_role_name(name);
1600    let role_allowed = match allowed_roles {
1601        AllowedRoles::Normal => !is_reserved_user,
1602        AllowedRoles::Internal => is_internal_user,
1603        AllowedRoles::NormalAndInternal => !is_reserved_user || is_internal_user,
1604    };
1605    if role_allowed {
1606        Ok(())
1607    } else {
1608        Err(AuthError::RoleDisallowed(name.to_owned()))
1609    }
1610}
1611
1612/// Default layers that should be applied to all routes, and should get applied to both the
1613/// internal http and external http routers.
1614trait DefaultLayers {
1615    fn apply_default_layers(self, source: &'static str, metrics: Metrics) -> Self;
1616}
1617
1618impl DefaultLayers for Router {
1619    fn apply_default_layers(self, source: &'static str, metrics: Metrics) -> Self {
1620        self.layer(DefaultBodyLimit::max(MAX_REQUEST_SIZE))
1621            .layer(metrics::PrometheusLayer::new(source, metrics))
1622    }
1623}
1624
1625/// Glue code to make [`tower`] work with [`axum`].
1626///
1627/// `axum` requires `Layer`s not return Errors, i.e. they must be `Result<_, Infallible>`,
1628/// instead you must return a type that can be converted into a response. `tower` on the other
1629/// hand does return Errors, so to make the two work together we need to convert our `tower` errors
1630/// into responses.
1631async fn handle_load_error(error: tower::BoxError) -> impl IntoResponse {
1632    if error.is::<tower::load_shed::error::Overloaded>() {
1633        return (
1634            StatusCode::TOO_MANY_REQUESTS,
1635            Cow::from("too many requests, try again later"),
1636        );
1637    }
1638
1639    // Note: This should be unreachable because at the time of writing our only use case is a
1640    // layer that emits `tower::load_shed::error::Overloaded`, which is handled above.
1641    (
1642        StatusCode::INTERNAL_SERVER_ERROR,
1643        Cow::from(format!("Unhandled internal error: {}", error)),
1644    )
1645}
1646
1647#[derive(Debug, Deserialize, Serialize, PartialEq)]
1648pub struct LoginCredentials {
1649    username: String,
1650    password: Password,
1651}
1652
1653#[derive(Debug, Clone, Serialize, Deserialize)]
1654pub struct TowerSessionData {
1655    username: String,
1656    created_at: SystemTime,
1657    last_activity: SystemTime,
1658    authenticated: Authenticated,
1659    authenticator_kind: mz_auth::AuthenticatorKind,
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::{AllowedRoles, check_role_allowed};
1665
1666    #[mz_ore::test]
1667    fn test_check_role_allowed() {
1668        // Internal user
1669        assert!(check_role_allowed("mz_system", AllowedRoles::Internal).is_ok());
1670        assert!(check_role_allowed("mz_system", AllowedRoles::NormalAndInternal).is_ok());
1671        assert!(check_role_allowed("mz_system", AllowedRoles::Normal).is_err());
1672
1673        // Internal user
1674        assert!(check_role_allowed("mz_support", AllowedRoles::Internal).is_ok());
1675        assert!(check_role_allowed("mz_support", AllowedRoles::NormalAndInternal).is_ok());
1676        assert!(check_role_allowed("mz_support", AllowedRoles::Normal).is_err());
1677
1678        // Internal user
1679        assert!(check_role_allowed("mz_analytics", AllowedRoles::Internal).is_ok());
1680        assert!(check_role_allowed("mz_analytics", AllowedRoles::NormalAndInternal).is_ok());
1681        assert!(check_role_allowed("mz_analytics", AllowedRoles::Normal).is_err());
1682
1683        // Normal user
1684        assert!(check_role_allowed("materialize", AllowedRoles::Internal).is_err());
1685        assert!(check_role_allowed("materialize", AllowedRoles::NormalAndInternal).is_ok());
1686        assert!(check_role_allowed("materialize", AllowedRoles::Normal).is_ok());
1687
1688        // Normal user
1689        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::Internal).is_err());
1690        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::NormalAndInternal).is_ok());
1691        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::Normal).is_ok());
1692
1693        // Normal user
1694        assert!(check_role_allowed("alex", AllowedRoles::Internal).is_err());
1695        assert!(check_role_allowed("alex", AllowedRoles::NormalAndInternal).is_ok());
1696        assert!(check_role_allowed("alex", AllowedRoles::Normal).is_ok());
1697
1698        // Denied by reserved role prefix
1699        assert!(check_role_allowed("external_asdf", AllowedRoles::Internal).is_err());
1700        assert!(check_role_allowed("external_asdf", AllowedRoles::NormalAndInternal).is_err());
1701        assert!(check_role_allowed("external_asdf", AllowedRoles::Normal).is_err());
1702
1703        // Denied by reserved role prefix
1704        assert!(check_role_allowed("pg_somebody", AllowedRoles::Internal).is_err());
1705        assert!(check_role_allowed("pg_somebody", AllowedRoles::NormalAndInternal).is_err());
1706        assert!(check_role_allowed("pg_somebody", AllowedRoles::Normal).is_err());
1707
1708        // Denied by reserved role prefix
1709        assert!(check_role_allowed("mz_unknown", AllowedRoles::Internal).is_err());
1710        assert!(check_role_allowed("mz_unknown", AllowedRoles::NormalAndInternal).is_err());
1711        assert!(check_role_allowed("mz_unknown", AllowedRoles::Normal).is_err());
1712
1713        // Denied by literal PUBLIC
1714        assert!(check_role_allowed("PUBLIC", AllowedRoles::Internal).is_err());
1715        assert!(check_role_allowed("PUBLIC", AllowedRoles::NormalAndInternal).is_err());
1716        assert!(check_role_allowed("PUBLIC", AllowedRoles::Normal).is_err());
1717    }
1718}