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                )
449                .route(
450                    "/internal-console/",
451                    routing::get(console::handle_internal_console),
452                )
453                // Cluster HTTP proxy routes.
454                .route("/clusters", routing::get(cluster::handle_clusters))
455                .route(
456                    "/api/cluster/{:cluster_id}/replica/{:replica_id}/process/{:process}/",
457                    routing::any(cluster::handle_cluster_proxy_root),
458                )
459                .route(
460                    "/api/cluster/{:cluster_id}/replica/{:replica_id}/process/{:process}/{*path}",
461                    routing::any(cluster::handle_cluster_proxy),
462                )
463                .layer(Extension(console_config))
464                .layer(Extension(Arc::clone(&cluster_proxy_config)))
465                .authorize(internal_roles);
466            base_router = base_router.merge(internal_group);
467
468            let leader_router = Router::new()
469                .route("/api/leader/status", routing::get(handle_leader_status))
470                .route("/api/leader/promote", routing::post(handle_leader_promote))
471                .route(
472                    "/api/leader/skip-catchup",
473                    routing::post(handle_leader_skip_catchup),
474                )
475                .authorize(internal_roles)
476                .layer(auth_middleware.clone())
477                .with_state(internal_route_config.deployment_state_handle.clone());
478            router = router.merge(leader_router);
479        }
480
481        if let RouteGroup::Enabled(metrics_roles) = routes_enabled.metrics {
482            // Clone into the closure so the outer `metrics_registry` binding
483            // stays available for other route blocks below (e.g. MCP metric
484            // registration).
485            let metrics_registry_for_handler = metrics_registry.clone();
486            let metrics_router = Router::new()
487                .route(
488                    "/metrics",
489                    routing::get(move |headers: HeaderMap| async move {
490                        mz_http_util::handle_prometheus(&metrics_registry_for_handler, headers)
491                            .await
492                    }),
493                )
494                .route(
495                    "/metrics/mz_usage",
496                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
497                        let registry = sql::handle_promsql(client, USAGE_METRIC_QUERIES).await;
498                        mz_http_util::handle_prometheus(&registry, headers).await
499                    }),
500                )
501                .route(
502                    "/metrics/mz_frontier",
503                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
504                        let registry = sql::handle_promsql(client, FRONTIER_METRIC_QUERIES).await;
505                        mz_http_util::handle_prometheus(&registry, headers).await
506                    }),
507                )
508                .route(
509                    "/metrics/mz_compute",
510                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
511                        let registry = sql::handle_promsql(client, COMPUTE_METRIC_QUERIES).await;
512                        mz_http_util::handle_prometheus(&registry, headers).await
513                    }),
514                )
515                .route(
516                    "/metrics/mz_storage",
517                    routing::get(|client: AuthedClient, headers: HeaderMap| async move {
518                        let registry = sql::handle_promsql(client, STORAGE_METRIC_QUERIES).await;
519                        mz_http_util::handle_prometheus(&registry, headers).await
520                    }),
521                )
522                .route(
523                    "/api/livez",
524                    routing::get(mz_http_util::handle_liveness_check),
525                )
526                .route("/api/readyz", routing::get(probe::handle_ready))
527                .authorize(metrics_roles)
528                .layer(auth_middleware.clone())
529                .layer(Extension(adapter_client_rx.clone()))
530                .layer(Extension(active_connection_counter.clone()))
531                .layer(Extension(HelmChartVersion(helm_chart_version.clone())));
532            router = router.merge(metrics_router);
533        }
534
535        if routes_enabled.console_config.is_enabled() {
536            let console_config_router = Router::new()
537                .route(
538                    "/api/console/config",
539                    routing::get(console::handle_console_config),
540                )
541                .layer(Extension(adapter_client_rx.clone()))
542                .layer(Extension(active_connection_counter.clone()));
543            router = router.merge(console_config_router);
544        }
545
546        // MCP (Model Context Protocol) endpoints
547        // Enabled via runtime `routes_enabled.mcp_agent` and `routes_enabled.mcp_developer` configuration
548        if routes_enabled.mcp_agent.is_enabled() || routes_enabled.mcp_developer.is_enabled() {
549            use tracing::info;
550
551            // RFC 9728 Protected Resource Metadata. Public route: MCP
552            // clients fetch it before they have a token. Sits on its own
553            // router so the auth middleware never runs on it. The handler
554            // 404s when the listener does not advertise OAuth (see
555            // `McpOAuthDiscovery`) or `oidc_issuer` is unset, so it is safe
556            // to enable unconditionally whenever MCP is enabled.
557            // RFC 9728 ยง3.1 lets clients look up per-resource metadata
558            // via a path-suffixed well-known URI before falling back to
559            // the bare one. The MCP endpoints share an identical
560            // metadata view today, so we serve the same handler at all
561            // three paths.
562            let oauth_metadata_router = Router::new()
563                .route(
564                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH,
565                    routing::get(oauth_metadata::handle_protected_resource_metadata),
566                )
567                .route(
568                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_AGENT,
569                    routing::get(oauth_metadata::handle_protected_resource_metadata),
570                )
571                .route(
572                    oauth_metadata::PROTECTED_RESOURCE_METADATA_PATH_DEVELOPER,
573                    routing::get(oauth_metadata::handle_protected_resource_metadata),
574                )
575                .layer(Extension(adapter_client_rx.clone()))
576                .layer(Extension(oauth_metadata::McpOAuthConfig {
577                    http_host_name: http_host_name.clone(),
578                    discovery: Arc::clone(&oauth_discovery),
579                }))
580                .layer(Extension(oauth_metadata_metrics.clone()));
581            router = router.merge(oauth_metadata_router);
582
583            let mut mcp_router = Router::new();
584
585            if let RouteGroup::Enabled(mcp_agent_roles) = routes_enabled.mcp_agent {
586                info!("Enabling MCP agent endpoint: /api/mcp/agent");
587                let agent_router = Router::new()
588                    .route(
589                        "/api/mcp/agent",
590                        routing::post(mcp::handle_mcp_agent)
591                            .get(mcp::handle_mcp_method_not_allowed),
592                    )
593                    .authorize(mcp_agent_roles);
594                mcp_router = mcp_router.merge(agent_router);
595            }
596
597            if let RouteGroup::Enabled(mcp_developer_roles) = routes_enabled.mcp_developer {
598                info!("Enabling MCP developer endpoint: /api/mcp/developer");
599                let developer_router = Router::new()
600                    .route(
601                        "/api/mcp/developer",
602                        routing::post(mcp::handle_mcp_developer)
603                            .get(mcp::handle_mcp_method_not_allowed),
604                    )
605                    .authorize(mcp_developer_roles);
606                mcp_router = mcp_router.merge(developer_router);
607            }
608
609            // The MCP handlers perform a server-side Origin check against this
610            // allowlist to defend against DNS rebinding attacks (see
611            // database-issues#11311). The CorsLayer alone is not enough: in a
612            // DNS rebinding attack the browser considers the request
613            // same-origin, so no preflight fires and CORS enforcement is
614            // bypassed.
615            let mcp_allowed_origins = Arc::new(allowed_origin_list.clone());
616            mcp_router = mcp_router
617                .layer(auth_middleware.clone())
618                .layer(Extension(oauth_metadata::McpOAuthConfig {
619                    http_host_name: http_host_name.clone(),
620                    discovery: Arc::clone(&oauth_discovery),
621                }))
622                .layer(Extension(adapter_client_rx.clone()))
623                .layer(Extension(active_connection_counter.clone()))
624                .layer(Extension(HelmChartVersion(helm_chart_version.clone())))
625                .layer(Extension(mcp_allowed_origins))
626                .layer(Extension(mcp_metrics))
627                .layer(
628                    CorsLayer::new()
629                        .allow_methods(Method::POST)
630                        .allow_origin(allowed_origin.clone())
631                        .allow_headers([AUTHORIZATION, CONTENT_TYPE]),
632                );
633            // Trust `x-materialize-user` injected by an upstream proxy on
634            // listeners without HTTP auth, matching `base_router` and
635            // `ws_router` (CLO-158).
636            if let listeners::AuthenticatorKind::None = authenticator_kind {
637                mcp_router = mcp_router.layer(middleware::from_fn(x_materialize_user_header_auth));
638            }
639            router = router.merge(mcp_router);
640        }
641
642        base_router = base_router
643            .layer(auth_middleware.clone())
644            .layer(Extension(adapter_client_rx.clone()))
645            .layer(Extension(active_connection_counter.clone()))
646            .layer(Extension(HelmChartVersion(helm_chart_version)))
647            .layer(
648                CorsLayer::new()
649                    .allow_credentials(false)
650                    .allow_headers([
651                        AUTHORIZATION,
652                        CONTENT_TYPE,
653                        HeaderName::from_static("x-materialize-version"),
654                    ])
655                    .allow_methods(Any)
656                    .allow_origin(allowed_origin)
657                    .expose_headers(Any)
658                    .max_age(Duration::from_secs(60) * 60),
659            );
660
661        match authenticator_kind {
662            listeners::AuthenticatorKind::Password | listeners::AuthenticatorKind::Oidc => {
663                base_router = base_router.layer(session_layer.clone());
664
665                let login_router = Router::new()
666                    .route("/api/login", routing::post(handle_login))
667                    .route("/api/logout", routing::post(handle_logout))
668                    .layer(Extension(adapter_client_rx));
669                router = router.merge(login_router).layer(session_layer);
670            }
671            listeners::AuthenticatorKind::None => {
672                base_router =
673                    base_router.layer(middleware::from_fn(x_materialize_user_header_auth));
674            }
675            _ => {}
676        }
677
678        router = router
679            .merge(base_router)
680            .apply_default_layers(source, metrics);
681
682        HttpServer { tls, router }
683    }
684}
685
686impl Server for HttpServer {
687    const NAME: &'static str = "http";
688
689    fn handle_connection(
690        &self,
691        conn: Connection,
692        _tokio_metrics_intervals: impl Iterator<Item = TaskMetrics> + Send + 'static,
693    ) -> ConnectionHandler {
694        let router = self.router.clone();
695        let tls_context = self.tls.clone();
696        let mut conn = TokioIo::new(conn);
697
698        Box::pin(async {
699            let direct_peer_addr = conn.inner().peer_addr().context("fetching peer addr")?;
700            let peer_addr = conn
701                .inner_mut()
702                .take_proxy_header_address()
703                .await
704                .map(|a| a.source)
705                .unwrap_or(direct_peer_addr);
706
707            let (conn, conn_protocol) = match tls_context {
708                Some(tls_context) => {
709                    let mut ssl_stream = SslStream::new(Ssl::new(&tls_context.get())?, conn)?;
710                    if let Err(e) = Pin::new(&mut ssl_stream).accept().await {
711                        let _ = ssl_stream.get_mut().inner_mut().shutdown().await;
712                        return Err(e.into());
713                    }
714                    (MaybeHttpsStream::Https(ssl_stream), ConnProtocol::Https)
715                }
716                _ => (MaybeHttpsStream::Http(conn), ConnProtocol::Http),
717            };
718            let mut make_tower_svc = router
719                .layer(Extension(conn_protocol))
720                .into_make_service_with_connect_info::<SocketAddr>();
721            let tower_svc = make_tower_svc.call(peer_addr).await.unwrap();
722            let hyper_svc = hyper::service::service_fn(|req| tower_svc.clone().call(req));
723            let http = hyper::server::conn::http1::Builder::new();
724            http.serve_connection(conn, hyper_svc)
725                .with_upgrades()
726                .err_into()
727                .await
728        })
729    }
730}
731
732pub async fn handle_leader_status(
733    State(deployment_state_handle): State<DeploymentStateHandle>,
734) -> impl IntoResponse {
735    let status = deployment_state_handle.status();
736    (StatusCode::OK, Json(json!({ "status": status })))
737}
738
739pub async fn handle_leader_promote(
740    State(deployment_state_handle): State<DeploymentStateHandle>,
741) -> impl IntoResponse {
742    match deployment_state_handle.try_promote() {
743        Ok(()) => {
744            // TODO(benesch): the body here is redundant. Should just return
745            // 204.
746            let status = StatusCode::OK;
747            let body = Json(json!({
748                "result": "Success",
749            }));
750            (status, body)
751        }
752        Err(()) => {
753            // TODO(benesch): the nesting here is redundant given the error
754            // code. Should just return the `{"message": "..."}` object.
755            let status = StatusCode::BAD_REQUEST;
756            let body = Json(json!({
757                "result": {"Failure": {"message": "cannot promote leader while initializing"}},
758            }));
759            (status, body)
760        }
761    }
762}
763
764pub async fn handle_leader_skip_catchup(
765    State(deployment_state_handle): State<DeploymentStateHandle>,
766) -> impl IntoResponse {
767    match deployment_state_handle.try_skip_catchup() {
768        Ok(()) => StatusCode::NO_CONTENT.into_response(),
769        Err(()) => {
770            let status = StatusCode::BAD_REQUEST;
771            let body = Json(json!({
772                "message": "cannot skip catchup in this phase of initialization; try again later",
773            }));
774            (status, body).into_response()
775        }
776    }
777}
778
779async fn x_materialize_user_header_auth(mut req: Request, next: Next) -> impl IntoResponse {
780    // TODO migrate teleport to basic auth and remove this.
781    if let Some(username) = req.headers().get("x-materialize-user").map(|h| h.to_str()) {
782        let username = match username {
783            Ok(name @ (SUPPORT_USER_NAME | SYSTEM_USER_NAME)) => name.to_string(),
784            _ => {
785                return Err(AuthError::MismatchedUser(format!(
786                    "user specified in x-materialize-user must be {SUPPORT_USER_NAME} or {SYSTEM_USER_NAME}"
787                )));
788            }
789        };
790        // Authorization runs later: for HTTP routes
791        // in the `http_authz` middleware, and for WebSocket connections inside
792        // `init_ws`. This middleware only resolves the injected identity.
793        req.extensions_mut().insert(AuthedUser {
794            name: username,
795            external_metadata_rx: None,
796            authenticated: Authenticated,
797            authenticator_kind: mz_auth::AuthenticatorKind::None,
798            groups: None,
799        });
800    }
801    Ok(next.run(req).await)
802}
803
804pub(crate) type Delayed<T> = Shared<oneshot::Receiver<T>>;
805
806/// Resolve the dyncfg-configured group claim path from a delayed adapter
807/// client. Callers must already have driven `adapter_client_rx` to readiness
808/// (e.g. via `get_authenticator`), so the await here is non-blocking.
809async fn group_claim_for(adapter_client_rx: &Delayed<Client>) -> String {
810    let client = adapter_client_rx
811        .clone()
812        .await
813        .expect("adapter client receiver dropped");
814    OIDC_GROUP_CLAIM.get(client.get_system_vars().await.dyncfgs())
815}
816
817#[derive(Clone)]
818enum ConnProtocol {
819    Http,
820    Https,
821}
822
823/// The `allowed_roles` policy for a route group, attached as a request
824/// extension on each authenticated route group and read by [`http_authz`].
825#[derive(Clone, Copy)]
826struct RouteAllowedRoles(AllowedRoles);
827
828/// Router extension for attaching the authorization middleware.
829trait AuthzRouterExt {
830    fn authorize(self, roles: AllowedRoles) -> Self;
831}
832
833impl<S> AuthzRouterExt for Router<S>
834where
835    S: Clone + Send + Sync + 'static,
836{
837    fn authorize(self, roles: AllowedRoles) -> Router<S> {
838        // Adds the `RouteAllowedRoles` extension to the request such that
839        // `http_authz` knows which roles to check for a RouteGroup.
840        self.layer(middleware::from_fn(http_authz))
841            .layer(Extension(RouteAllowedRoles(roles)))
842    }
843}
844
845#[derive(Clone, Debug)]
846pub struct AuthedUser {
847    name: String,
848    external_metadata_rx: Option<watch::Receiver<ExternalUserMetadata>>,
849    authenticated: Authenticated,
850    authenticator_kind: mz_auth::AuthenticatorKind,
851    /// Groups from JWT claims for OIDC group-to-role sync.
852    groups: Option<Vec<String>>,
853}
854
855pub struct AuthedClient {
856    pub client: SessionClient,
857    pub connection_guard: Option<ConnectionHandle>,
858}
859
860impl AuthedClient {
861    async fn new<F>(
862        adapter_client: &Client,
863        user: AuthedUser,
864        peer_addr: IpAddr,
865        active_connection_counter: ConnectionCounter,
866        helm_chart_version: Option<String>,
867        session_config: F,
868        options: BTreeMap<String, String>,
869        now: NowFn,
870    ) -> Result<Self, AdapterError>
871    where
872        F: FnOnce(&mut AdapterSession),
873    {
874        let conn_id = adapter_client.new_conn_id()?;
875        let mut session = adapter_client.new_session(
876            AdapterSessionConfig {
877                conn_id,
878                uuid: epoch_to_uuid_v7(&(now)()),
879                user: user.name,
880                client_ip: Some(peer_addr),
881                external_metadata_rx: user.external_metadata_rx,
882                helm_chart_version,
883                authenticator_kind: user.authenticator_kind,
884                groups: user.groups,
885            },
886            user.authenticated,
887        );
888        let connection_guard = active_connection_counter.allocate_connection(session.user())?;
889
890        session_config(&mut session);
891        let system_vars = adapter_client.get_system_vars().await;
892        for (key, val) in options {
893            const LOCAL: bool = false;
894            if let Err(err) =
895                session
896                    .vars_mut()
897                    .set(&system_vars, &key, VarInput::Flat(&val), LOCAL)
898            {
899                session.add_notice(AdapterNotice::BadStartupSetting {
900                    name: key.to_string(),
901                    reason: err.to_string(),
902                })
903            }
904        }
905        let adapter_client = adapter_client.startup(session).await?;
906        Ok(AuthedClient {
907            client: adapter_client,
908            connection_guard,
909        })
910    }
911}
912
913impl<S> FromRequestParts<S> for AuthedClient
914where
915    S: Send + Sync,
916{
917    type Rejection = Response;
918
919    async fn from_request_parts(
920        req: &mut http::request::Parts,
921        state: &S,
922    ) -> Result<Self, Self::Rejection> {
923        #[derive(Debug, Default, Deserialize)]
924        struct Params {
925            #[serde(default)]
926            options: String,
927        }
928        let params: Query<Params> = Query::from_request_parts(req, state)
929            .await
930            .unwrap_or_default();
931
932        let peer_addr = req
933            .extensions
934            .get::<ConnectInfo<SocketAddr>>()
935            .expect("ConnectInfo extension guaranteed to exist")
936            .0
937            .ip();
938
939        let user = req.extensions.get::<AuthedUser>().unwrap();
940        let adapter_client = req
941            .extensions
942            .get::<Delayed<mz_adapter::Client>>()
943            .unwrap()
944            .clone();
945        let adapter_client = adapter_client.await.map_err(|_| {
946            (StatusCode::INTERNAL_SERVER_ERROR, "adapter client missing").into_response()
947        })?;
948        let active_connection_counter = req.extensions.get::<ConnectionCounter>().unwrap();
949        let helm_chart_version = req
950            .extensions
951            .get::<HelmChartVersion>()
952            .map(|h| h.0.clone())
953            .unwrap_or(None);
954
955        let options = if params.options.is_empty() {
956            // It's possible 'options' simply wasn't provided, we don't want that to
957            // count as a failure to deserialize
958            BTreeMap::<String, String>::default()
959        } else {
960            match serde_json::from_str(&params.options) {
961                Ok(options) => options,
962                Err(_e) => {
963                    // If we fail to deserialize options, fail the request.
964                    let code = StatusCode::BAD_REQUEST;
965                    let msg = format!("Failed to deserialize {} map", "options".quoted());
966                    return Err((code, msg).into_response());
967                }
968            }
969        };
970
971        let client = AuthedClient::new(
972            &adapter_client,
973            user.clone(),
974            peer_addr,
975            active_connection_counter.clone(),
976            helm_chart_version,
977            |session| {
978                session
979                    .vars_mut()
980                    .set_default(WELCOME_MESSAGE.name(), VarInput::Flat(&false.format()))
981                    .expect("known to exist")
982            },
983            options,
984            SYSTEM_TIME.clone(),
985        )
986        .await
987        .map_err(|e| {
988            let status = match e {
989                AdapterError::UserSessionsDisallowed | AdapterError::NetworkPolicyDenied(_) => {
990                    StatusCode::FORBIDDEN
991                }
992                _ => StatusCode::INTERNAL_SERVER_ERROR,
993            };
994            (status, Json(SqlError::from(e))).into_response()
995        })?;
996
997        Ok(client)
998    }
999}
1000
1001/// Per-request decision about which `WWW-Authenticate` challenges to emit
1002/// on a 401, computed by the auth middleware.
1003///
1004/// Carries both the `Basic` toggle (today's behavior, kept for the SQL HTTP
1005/// layer and friends) and an optional `Bearer` challenge with a
1006/// `resource_metadata` URL per RFC 9728. The Bearer challenge is only set on
1007/// routes that attach an [`oauth_metadata::McpOAuthConfig`] extension; other
1008/// routes emit only `Basic` so their behavior is unchanged.
1009#[derive(Debug, Clone, Default)]
1010pub(crate) struct WwwAuthenticateChallenges {
1011    /// Whether to emit `WWW-Authenticate: Basic realm=Materialize`.
1012    pub include_basic: bool,
1013    /// If `Some`, also emit `WWW-Authenticate: Bearer
1014    /// resource_metadata="<url>"`. The URL points at this server's RFC 9728
1015    /// Protected Resource Metadata document, which advertises the
1016    /// authorization server the client should use.
1017    pub bearer_resource_metadata: Option<String>,
1018    /// If `Some`, also emit `scope="<scope>"` inside the Bearer challenge.
1019    /// Tells clients which OAuth scope to request a token with for this
1020    /// resource. Only set in conjunction with `bearer_resource_metadata`
1021    /// (a scope challenge with no resource hint would be confusing).
1022    pub bearer_scope: Option<&'static str>,
1023}
1024
1025#[derive(Debug, Error)]
1026pub(crate) enum AuthError {
1027    #[error("role dissallowed")]
1028    RoleDisallowed(String),
1029    #[error("{0}")]
1030    Frontegg(#[from] FronteggError),
1031    #[error("missing authorization header")]
1032    MissingHttpAuthentication {
1033        challenges: WwwAuthenticateChallenges,
1034    },
1035    #[error("{0}")]
1036    MismatchedUser(String),
1037    #[error("session expired")]
1038    SessionExpired,
1039    #[error("failed to update session")]
1040    FailedToUpdateSession,
1041    #[error("invalid credentials")]
1042    InvalidCredentials,
1043    /// Payload is `OidcError`'s sanitized `Display` (no expected-values leaks).
1044    #[error("{0}")]
1045    OidcFailed(String),
1046}
1047
1048impl IntoResponse for AuthError {
1049    fn into_response(self) -> Response {
1050        warn!("HTTP request failed authentication: {}", self);
1051        let mut headers = HeaderMap::new();
1052        // We omit most detail from the error message we send to the client, to
1053        // avoid giving attackers unnecessary information. `OidcFailed` is the
1054        // exception: its payload is a sanitized `OidcError::Display` that the
1055        // console embeds in the login-page error.
1056        let body = match &self {
1057            // Bearer goes first so OAuth-aware clients see it before the
1058            // Basic fallback. RFC 7235 allows emitting multiple
1059            // `WWW-Authenticate` headers; we use one per scheme so each
1060            // challenge is unambiguously framed; some parsers struggle
1061            // with multiple schemes on a single header value.
1062            AuthError::MissingHttpAuthentication { challenges } => {
1063                if let Some(resource_metadata) = &challenges.bearer_resource_metadata {
1064                    // `scope` is hard-coded to a vetted constant
1065                    // (`MCP_SCOPE`); only `resource_metadata` is derived
1066                    // from a header value, and `resolve_host` has already
1067                    // round-tripped it through the URI grammar. The quoted
1068                    // form follows RFC 6749 ยง3.3 / RFC 6750 ยง3.
1069                    let value = match &challenges.bearer_scope {
1070                        Some(scope) => format!(
1071                            "Bearer scope=\"{scope}\", resource_metadata=\"{resource_metadata}\"",
1072                        ),
1073                        None => format!("Bearer resource_metadata=\"{resource_metadata}\""),
1074                    };
1075                    match HeaderValue::from_str(&value) {
1076                        Ok(v) => {
1077                            headers.append(http::header::WWW_AUTHENTICATE, v);
1078                        }
1079                        Err(e) => {
1080                            warn!(
1081                                "skipping Bearer WWW-Authenticate challenge: invalid header \
1082                                 value derived from resource_metadata={resource_metadata:?}: {e}",
1083                            );
1084                        }
1085                    }
1086                }
1087                if challenges.include_basic {
1088                    headers.append(
1089                        http::header::WWW_AUTHENTICATE,
1090                        HeaderValue::from_static("Basic realm=Materialize"),
1091                    );
1092                }
1093                "unauthorized".to_string()
1094            }
1095            AuthError::OidcFailed(message) => message.clone(),
1096            _ => "unauthorized".to_string(),
1097        };
1098        (StatusCode::UNAUTHORIZED, headers, body).into_response()
1099    }
1100}
1101
1102// Simplified login handler
1103pub async fn handle_login(
1104    session: Option<Extension<TowerSession>>,
1105    Extension(adapter_client_rx): Extension<Delayed<Client>>,
1106    Json(LoginCredentials { username, password }): Json<LoginCredentials>,
1107) -> impl IntoResponse {
1108    // The listener's `allowed_roles` policy is not enforced here. Login only
1109    // mints a session. Authorization runs per request in the `http_authz`
1110    // middleware (and in `init_ws` for WebSocket), so a session for a
1111    // disallowed role cannot actually reach any route.
1112    let Ok(adapter_client) = adapter_client_rx.clone().await else {
1113        return StatusCode::INTERNAL_SERVER_ERROR;
1114    };
1115    let authenticated = match adapter_client.authenticate(&username, &password).await {
1116        Ok(authenticated) => authenticated,
1117        Err(err) => {
1118            warn!(?err, "HTTP login failed authentication");
1119            return StatusCode::UNAUTHORIZED;
1120        }
1121    };
1122    // Create session data
1123    let session_data = TowerSessionData {
1124        username,
1125        created_at: SystemTime::now(),
1126        last_activity: SystemTime::now(),
1127        authenticated,
1128        authenticator_kind: mz_auth::AuthenticatorKind::Password,
1129    };
1130    // Store session data
1131    let session = session.and_then(|Extension(session)| Some(session));
1132    let Some(session) = session else {
1133        return StatusCode::INTERNAL_SERVER_ERROR;
1134    };
1135    match session.insert("data", &session_data).await {
1136        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
1137        Ok(_) => StatusCode::OK,
1138    }
1139}
1140
1141// Simplified logout handler
1142pub async fn handle_logout(session: Option<Extension<TowerSession>>) -> impl IntoResponse {
1143    let session = session.and_then(|Extension(session)| Some(session));
1144    let Some(session) = session else {
1145        return StatusCode::INTERNAL_SERVER_ERROR;
1146    };
1147    // Delete session
1148    match session.delete().await {
1149        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
1150        Ok(_) => StatusCode::OK,
1151    }
1152}
1153
1154/// Authentication middleware.
1155async fn http_auth(
1156    mut req: Request,
1157    next: Next,
1158    tls_enabled: bool,
1159    authenticator_kind: listeners::AuthenticatorKind,
1160    frontegg: Option<mz_frontegg_auth::Authenticator>,
1161    oidc_rx: Delayed<mz_authenticator::GenericOidcAuthenticator>,
1162    adapter_client_rx: Delayed<Client>,
1163) -> Result<impl IntoResponse, AuthError> {
1164    let creds = if let Some(basic) = req.headers().typed_get::<Authorization<Basic>>() {
1165        Some(Credentials::Password {
1166            username: basic.username().to_owned(),
1167            password: Password(basic.password().to_owned()),
1168        })
1169    } else if let Some(bearer) = req.headers().typed_get::<Authorization<Bearer>>() {
1170        Some(Credentials::Token {
1171            token: bearer.token().to_owned(),
1172        })
1173    } else {
1174        None
1175    };
1176
1177    // Reuses an authenticated session if one already exists.
1178    // If credentials are provided, we perform a new authentication,
1179    // separate from the existing session.
1180    if creds.is_none()
1181        && let Some((session, session_data)) =
1182            maybe_get_authenticated_session(req.extensions().get::<TowerSession>()).await
1183    {
1184        let user = ensure_session_unexpired(session, session_data).await?;
1185        // Authorization runs next in the `http_authz` middleware, which reads
1186        // this `AuthedUser` and the route's `RouteAllowedRoles`.
1187        req.extensions_mut().insert(user);
1188        return Ok(next.run(req).await);
1189    }
1190
1191    // First, extract the username from the certificate, validating that the
1192    // connection matches the TLS configuration along the way.
1193    // Fall back to existing authentication methods.
1194    let conn_protocol = req.extensions().get::<ConnProtocol>().unwrap();
1195    match (tls_enabled, &conn_protocol) {
1196        (false, ConnProtocol::Http) => {}
1197        (false, ConnProtocol::Https { .. }) => unreachable!(),
1198        (true, ConnProtocol::Http) => {
1199            let mut parts = req.uri().clone().into_parts();
1200            parts.scheme = Some(Scheme::HTTPS);
1201            return Ok(Redirect::permanent(
1202                &Uri::from_parts(parts)
1203                    .expect("it was already a URI, just changed the scheme")
1204                    .to_string(),
1205            )
1206            .into_response());
1207        }
1208        (true, ConnProtocol::Https { .. }) => {}
1209    }
1210    // If we've already passed some other auth, just use that.
1211    if req.extensions().get::<AuthedUser>().is_some() {
1212        return Ok(next.run(req).await);
1213    }
1214
1215    let path = req.uri().path();
1216    // Routes that advertise OAuth opt in by attaching an `McpOAuthConfig`
1217    // extension; the middleware stays path-agnostic. Routes that opt in also
1218    // get a `Basic` challenge so existing curl/Bearer-already users still see
1219    // a usable challenge. See `crate::http::oauth_metadata` for the discovery
1220    // document; the challenge and the discovery handler read the same
1221    // `McpOAuthConfig`, so they emit the same authorization server and the
1222    // same host for a given listener.
1223    let oauth_config = req
1224        .extensions()
1225        .get::<oauth_metadata::McpOAuthConfig>()
1226        .cloned();
1227    let include_basic = path == "/"
1228        || PROFILING_API_ENDPOINTS
1229            .iter()
1230            .any(|prefix| path.starts_with(prefix))
1231        || oauth_config.is_some();
1232    let (bearer_resource_metadata, bearer_scope) = if let Some(config) = &oauth_config
1233        && config.discovery.is_enabled()
1234    {
1235        (
1236            oauth_metadata::metadata_url(&req, config.http_host_name.as_deref()),
1237            Some(config.scope()),
1238        )
1239    } else {
1240        (None, None)
1241    };
1242    let challenges = WwwAuthenticateChallenges {
1243        include_basic,
1244        bearer_resource_metadata,
1245        bearer_scope,
1246    };
1247    let authenticator = get_authenticator(
1248        authenticator_kind,
1249        creds.as_ref(),
1250        frontegg,
1251        &oidc_rx,
1252        &adapter_client_rx,
1253    )
1254    .await;
1255
1256    // Only the Frontegg arm consumes `group_claim`; resolving it requires a
1257    // coordinator round-trip (`Command::GetSystemVars`), so skip it for
1258    // None/Password/OIDC paths. Otherwise unauth'd probes like `/api/livez`,
1259    // `/api/readyz`, and `/metrics` on a `None`-auth listener would couple
1260    // liveness to coordinator health. Mirrors the pgwire path.
1261    let group_claim = if matches!(authenticator, Authenticator::Frontegg(_)) {
1262        Some(group_claim_for(&adapter_client_rx).await)
1263    } else {
1264        None
1265    };
1266    let user = auth(&authenticator, creds, &challenges, group_claim.as_deref()).await?;
1267
1268    // Add the authenticated user as an extension so downstream handlers can
1269    // inspect it if necessary.
1270    req.extensions_mut().insert(user);
1271
1272    // Run the request.
1273    Ok(next.run(req).await)
1274}
1275
1276/// Authorization middleware. Enforces the route group's `allowed_roles` policy
1277/// against the authenticated user.
1278///
1279/// Layered immediately inside [`http_auth`] on every authenticated route group,
1280/// always paired with a [`RouteAllowedRoles`] extension. By that wiring both the
1281/// `AuthedUser` (from `http_auth`) and the `RouteAllowedRoles` extension are
1282/// present whenever this runs. A missing extension is a wiring bug, so we fail
1283/// closed rather than skip the check.
1284async fn http_authz(req: Request, next: Next) -> Result<impl IntoResponse, AuthError> {
1285    match (
1286        req.extensions().get::<AuthedUser>(),
1287        req.extensions().get::<RouteAllowedRoles>().copied(),
1288    ) {
1289        (Some(user), Some(RouteAllowedRoles(allowed_roles))) => {
1290            check_role_allowed(&user.name, allowed_roles)?;
1291            Ok(next.run(req).await)
1292        }
1293        _ => {
1294            warn!("http_authz missing AuthedUser or RouteAllowedRoles extension; denying request");
1295            Err(AuthError::RoleDisallowed("<unknown>".to_string()))
1296        }
1297    }
1298}
1299
1300async fn init_ws(
1301    WsState {
1302        frontegg,
1303        oidc_rx,
1304        authenticator_kind,
1305        adapter_client_rx,
1306        active_connection_counter,
1307        helm_chart_version,
1308        allowed_roles,
1309    }: WsState,
1310    existing_user: Option<ExistingUser>,
1311    peer_addr: IpAddr,
1312    ws: &mut WebSocket,
1313) -> Result<AuthedClient, anyhow::Error> {
1314    // TODO: Add a timeout here to prevent resource leaks by clients that
1315    // connect then never send a message.
1316    let ws_auth: WebSocketAuth = loop {
1317        let init_msg = ws.recv().await.ok_or_else(|| anyhow::anyhow!("closed"))??;
1318        match init_msg {
1319            Message::Text(data) => break serde_json::from_str(&data)?,
1320            Message::Binary(data) => break serde_json::from_slice(&data)?,
1321            // Handled automatically by the server.
1322            Message::Ping(_) => {
1323                continue;
1324            }
1325            Message::Pong(_) => {
1326                continue;
1327            }
1328            Message::Close(_) => {
1329                anyhow::bail!("closed");
1330            }
1331        }
1332    };
1333
1334    // If credentials are provided, we perform a new authentication,
1335    // separate from the existing session.
1336    let (creds, options) = match ws_auth {
1337        WebSocketAuth::Basic {
1338            user,
1339            password,
1340            options,
1341        } => {
1342            let creds = Credentials::Password {
1343                username: user,
1344                password,
1345            };
1346            (Some(creds), options)
1347        }
1348        WebSocketAuth::Bearer { token, options } => {
1349            let creds = Credentials::Token { token };
1350            (Some(creds), options)
1351        }
1352        WebSocketAuth::OptionsOnly { options } => (None, options),
1353    };
1354
1355    let user = match (existing_user, creds) {
1356        (Some(ExistingUser::XMaterializeUserHeader(_)), Some(_creds)) => {
1357            warn!("Unexpected bearer or basic auth provided when using user header");
1358            anyhow::bail!("unexpected")
1359        }
1360        (Some(ExistingUser::Session(user)), None) => user,
1361        (Some(ExistingUser::XMaterializeUserHeader(user)), None) => user,
1362        (_, Some(creds)) => {
1363            let authenticator = get_authenticator(
1364                authenticator_kind,
1365                Some(&creds),
1366                frontegg,
1367                &oidc_rx,
1368                &adapter_client_rx,
1369            )
1370            .await;
1371            // WebSocket init: no 401-with-challenge contract, the
1372            // client is reading WS frames, not parsing HTTP headers, so
1373            // we just suppress challenge emission entirely.
1374            let no_challenges = WwwAuthenticateChallenges::default();
1375            // See `http_auth`: only Frontegg uses `group_claim`, and the
1376            // fetch costs a coordinator round-trip.
1377            let group_claim = if matches!(authenticator, Authenticator::Frontegg(_)) {
1378                Some(group_claim_for(&adapter_client_rx).await)
1379            } else {
1380                None
1381            };
1382            let user = auth(
1383                &authenticator,
1384                Some(creds),
1385                &no_challenges,
1386                group_claim.as_deref(),
1387            )
1388            .await?;
1389            user
1390        }
1391        (None, None) => anyhow::bail!("expected auth information"),
1392    };
1393
1394    // Authorization. WebSocket connections authenticate after the HTTP upgrade
1395    // (by reading the first frame), so the `http_authz` middleware cannot cover
1396    // them. Enforce the authorization here instead, once, for every way
1397    // the user was resolved above (session, injected header, or credentials).
1398    check_role_allowed(&user.name, allowed_roles)?;
1399
1400    let client = AuthedClient::new(
1401        &adapter_client_rx.clone().await?,
1402        user,
1403        peer_addr,
1404        active_connection_counter.clone(),
1405        helm_chart_version.clone(),
1406        |_session| (),
1407        options,
1408        SYSTEM_TIME.clone(),
1409    )
1410    .await?;
1411
1412    Ok(client)
1413}
1414
1415enum Credentials {
1416    Password {
1417        username: String,
1418        password: Password,
1419    },
1420    Token {
1421        token: String,
1422    },
1423}
1424
1425async fn get_authenticator(
1426    kind: listeners::AuthenticatorKind,
1427    creds: Option<&Credentials>,
1428    frontegg: Option<mz_frontegg_auth::Authenticator>,
1429    oidc_rx: &Delayed<mz_authenticator::GenericOidcAuthenticator>,
1430    adapter_client_rx: &Delayed<Client>,
1431) -> Authenticator {
1432    match kind {
1433        listeners::AuthenticatorKind::Frontegg => Authenticator::Frontegg(frontegg.expect(
1434            "Frontegg authenticator should exist with listeners::AuthenticatorKind::Frontegg",
1435        )),
1436        listeners::AuthenticatorKind::Password | listeners::AuthenticatorKind::Sasl => {
1437            let client = adapter_client_rx.clone().await.expect("sender not dropped");
1438            Authenticator::Password(client)
1439        }
1440        listeners::AuthenticatorKind::Oidc => match creds {
1441            // Use the password authenticator if the credentials are password-based
1442            Some(Credentials::Password { .. }) => {
1443                let client = adapter_client_rx.clone().await.expect("sender not dropped");
1444                Authenticator::Password(client)
1445            }
1446            _ => Authenticator::Oidc(oidc_rx.clone().await.expect("sender not dropped")),
1447        },
1448        listeners::AuthenticatorKind::None => Authenticator::None,
1449    }
1450}
1451
1452/// Attempts to retrieve session data from a [`TowerSession`], if available.
1453/// Session data is present only if an authenticated session has been
1454/// established via [`handle_login`].
1455pub(crate) async fn maybe_get_authenticated_session(
1456    session: Option<&TowerSession>,
1457) -> Option<(&TowerSession, TowerSessionData)> {
1458    if let Some(session) = session {
1459        if let Ok(Some(session_data)) = session.get::<TowerSessionData>("data").await {
1460            return Some((session, session_data));
1461        }
1462    }
1463    None
1464}
1465
1466/// Ensures the session is still valid by checking for expiration,
1467/// and returns the associated user if the session remains active.
1468pub(crate) async fn ensure_session_unexpired(
1469    session: &TowerSession,
1470    session_data: TowerSessionData,
1471) -> Result<AuthedUser, AuthError> {
1472    if session_data
1473        .last_activity
1474        .elapsed()
1475        .unwrap_or(Duration::MAX)
1476        > SESSION_DURATION
1477    {
1478        let _ = session.delete().await;
1479        return Err(AuthError::SessionExpired);
1480    }
1481    let mut updated_data = session_data.clone();
1482    updated_data.last_activity = SystemTime::now();
1483    session
1484        .insert("data", &updated_data)
1485        .await
1486        .map_err(|_| AuthError::FailedToUpdateSession)?;
1487
1488    Ok(AuthedUser {
1489        name: session_data.username,
1490        external_metadata_rx: None,
1491        authenticated: session_data.authenticated,
1492        authenticator_kind: session_data.authenticator_kind,
1493        groups: None,
1494    })
1495}
1496
1497async fn auth(
1498    authenticator: &Authenticator,
1499    creds: Option<Credentials>,
1500    challenges: &WwwAuthenticateChallenges,
1501    group_claim: Option<&str>,
1502) -> Result<AuthedUser, AuthError> {
1503    let (name, external_metadata_rx, authenticated, groups) = match authenticator {
1504        Authenticator::Frontegg(frontegg) => match creds {
1505            Some(Credentials::Password { username, password }) => {
1506                let (auth_session, authenticated) = frontegg
1507                    .authenticate(&username, password.as_str(), group_claim)
1508                    .await?;
1509                let name = auth_session.user().into();
1510                let groups = auth_session.groups();
1511                let external_metadata_rx = Some(auth_session.external_metadata_rx());
1512                (name, external_metadata_rx, authenticated, groups)
1513            }
1514            Some(Credentials::Token { token }) => {
1515                let (claims, authenticated) =
1516                    frontegg.validate_access_token(&token, None, group_claim)?;
1517                let (_, external_metadata_rx) = watch::channel(ExternalUserMetadata {
1518                    user_id: claims.user_id,
1519                    admin: claims.is_admin,
1520                });
1521                (
1522                    claims.user,
1523                    Some(external_metadata_rx),
1524                    authenticated,
1525                    claims.groups,
1526                )
1527            }
1528            None => {
1529                return Err(AuthError::MissingHttpAuthentication {
1530                    challenges: challenges.clone(),
1531                });
1532            }
1533        },
1534        Authenticator::Password(adapter_client) => match creds {
1535            Some(Credentials::Password { username, password }) => {
1536                let authenticated = adapter_client
1537                    .authenticate(&username, &password)
1538                    .await
1539                    .map_err(|_| AuthError::InvalidCredentials)?;
1540                (username, None, authenticated, None)
1541            }
1542            _ => {
1543                return Err(AuthError::MissingHttpAuthentication {
1544                    challenges: challenges.clone(),
1545                });
1546            }
1547        },
1548        Authenticator::Sasl(_) => {
1549            // We shouldn't ever end up here as the configuration is validated at startup.
1550            // If we do, it's a server misconfiguration.
1551            // Just in case, we return a 401 rather than panic.
1552            return Err(AuthError::MissingHttpAuthentication {
1553                challenges: challenges.clone(),
1554            });
1555        }
1556        Authenticator::Oidc(oidc) => match creds {
1557            Some(Credentials::Token { token }) => {
1558                let (mut claims, authenticated) = oidc
1559                    .authenticate(&token, None)
1560                    .await
1561                    .map_err(|e| AuthError::OidcFailed(e.to_string()))?;
1562                let name = std::mem::take(&mut claims.user);
1563                let groups = claims.groups.take();
1564                (name, None, authenticated, groups)
1565            }
1566            _ => {
1567                return Err(AuthError::MissingHttpAuthentication {
1568                    challenges: challenges.clone(),
1569                });
1570            }
1571        },
1572        Authenticator::None => {
1573            // If no authentication, use whatever is in the HTTP auth
1574            // header (without checking the password), or fall back to the
1575            // default user.
1576            let name = match creds {
1577                Some(Credentials::Password { username, .. }) => username,
1578                _ => HTTP_DEFAULT_USER.name.to_owned(),
1579            };
1580            (name, None, Authenticated, None)
1581        }
1582    };
1583
1584    Ok(AuthedUser {
1585        name,
1586        external_metadata_rx,
1587        authenticated,
1588        authenticator_kind: authenticator.kind(),
1589        groups,
1590    })
1591}
1592
1593// TODO move this somewhere it can be shared with PGWIRE
1594fn check_role_allowed(name: &str, allowed_roles: AllowedRoles) -> Result<(), AuthError> {
1595    let is_internal_user = INTERNAL_USER_NAMES.contains(name);
1596    // this is a superset of internal users
1597    let is_reserved_user = mz_adapter::catalog::is_reserved_role_name(name);
1598    let role_allowed = match allowed_roles {
1599        AllowedRoles::Normal => !is_reserved_user,
1600        AllowedRoles::Internal => is_internal_user,
1601        AllowedRoles::NormalAndInternal => !is_reserved_user || is_internal_user,
1602    };
1603    if role_allowed {
1604        Ok(())
1605    } else {
1606        Err(AuthError::RoleDisallowed(name.to_owned()))
1607    }
1608}
1609
1610/// Default layers that should be applied to all routes, and should get applied to both the
1611/// internal http and external http routers.
1612trait DefaultLayers {
1613    fn apply_default_layers(self, source: &'static str, metrics: Metrics) -> Self;
1614}
1615
1616impl DefaultLayers for Router {
1617    fn apply_default_layers(self, source: &'static str, metrics: Metrics) -> Self {
1618        self.layer(DefaultBodyLimit::max(MAX_REQUEST_SIZE))
1619            .layer(metrics::PrometheusLayer::new(source, metrics))
1620    }
1621}
1622
1623/// Glue code to make [`tower`] work with [`axum`].
1624///
1625/// `axum` requires `Layer`s not return Errors, i.e. they must be `Result<_, Infallible>`,
1626/// instead you must return a type that can be converted into a response. `tower` on the other
1627/// hand does return Errors, so to make the two work together we need to convert our `tower` errors
1628/// into responses.
1629async fn handle_load_error(error: tower::BoxError) -> impl IntoResponse {
1630    if error.is::<tower::load_shed::error::Overloaded>() {
1631        return (
1632            StatusCode::TOO_MANY_REQUESTS,
1633            Cow::from("too many requests, try again later"),
1634        );
1635    }
1636
1637    // Note: This should be unreachable because at the time of writing our only use case is a
1638    // layer that emits `tower::load_shed::error::Overloaded`, which is handled above.
1639    (
1640        StatusCode::INTERNAL_SERVER_ERROR,
1641        Cow::from(format!("Unhandled internal error: {}", error)),
1642    )
1643}
1644
1645#[derive(Debug, Deserialize, Serialize, PartialEq)]
1646pub struct LoginCredentials {
1647    username: String,
1648    password: Password,
1649}
1650
1651#[derive(Debug, Clone, Serialize, Deserialize)]
1652pub struct TowerSessionData {
1653    username: String,
1654    created_at: SystemTime,
1655    last_activity: SystemTime,
1656    authenticated: Authenticated,
1657    authenticator_kind: mz_auth::AuthenticatorKind,
1658}
1659
1660#[cfg(test)]
1661mod tests {
1662    use super::{AllowedRoles, check_role_allowed};
1663
1664    #[mz_ore::test]
1665    fn test_check_role_allowed() {
1666        // Internal user
1667        assert!(check_role_allowed("mz_system", AllowedRoles::Internal).is_ok());
1668        assert!(check_role_allowed("mz_system", AllowedRoles::NormalAndInternal).is_ok());
1669        assert!(check_role_allowed("mz_system", AllowedRoles::Normal).is_err());
1670
1671        // Internal user
1672        assert!(check_role_allowed("mz_support", AllowedRoles::Internal).is_ok());
1673        assert!(check_role_allowed("mz_support", AllowedRoles::NormalAndInternal).is_ok());
1674        assert!(check_role_allowed("mz_support", AllowedRoles::Normal).is_err());
1675
1676        // Internal user
1677        assert!(check_role_allowed("mz_analytics", AllowedRoles::Internal).is_ok());
1678        assert!(check_role_allowed("mz_analytics", AllowedRoles::NormalAndInternal).is_ok());
1679        assert!(check_role_allowed("mz_analytics", AllowedRoles::Normal).is_err());
1680
1681        // Normal user
1682        assert!(check_role_allowed("materialize", AllowedRoles::Internal).is_err());
1683        assert!(check_role_allowed("materialize", AllowedRoles::NormalAndInternal).is_ok());
1684        assert!(check_role_allowed("materialize", AllowedRoles::Normal).is_ok());
1685
1686        // Normal user
1687        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::Internal).is_err());
1688        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::NormalAndInternal).is_ok());
1689        assert!(check_role_allowed("anonymous_http_user", AllowedRoles::Normal).is_ok());
1690
1691        // Normal user
1692        assert!(check_role_allowed("alex", AllowedRoles::Internal).is_err());
1693        assert!(check_role_allowed("alex", AllowedRoles::NormalAndInternal).is_ok());
1694        assert!(check_role_allowed("alex", AllowedRoles::Normal).is_ok());
1695
1696        // Denied by reserved role prefix
1697        assert!(check_role_allowed("external_asdf", AllowedRoles::Internal).is_err());
1698        assert!(check_role_allowed("external_asdf", AllowedRoles::NormalAndInternal).is_err());
1699        assert!(check_role_allowed("external_asdf", AllowedRoles::Normal).is_err());
1700
1701        // Denied by reserved role prefix
1702        assert!(check_role_allowed("pg_somebody", AllowedRoles::Internal).is_err());
1703        assert!(check_role_allowed("pg_somebody", AllowedRoles::NormalAndInternal).is_err());
1704        assert!(check_role_allowed("pg_somebody", AllowedRoles::Normal).is_err());
1705
1706        // Denied by reserved role prefix
1707        assert!(check_role_allowed("mz_unknown", AllowedRoles::Internal).is_err());
1708        assert!(check_role_allowed("mz_unknown", AllowedRoles::NormalAndInternal).is_err());
1709        assert!(check_role_allowed("mz_unknown", AllowedRoles::Normal).is_err());
1710
1711        // Denied by literal PUBLIC
1712        assert!(check_role_allowed("PUBLIC", AllowedRoles::Internal).is_err());
1713        assert!(check_role_allowed("PUBLIC", AllowedRoles::NormalAndInternal).is_err());
1714        assert!(check_role_allowed("PUBLIC", AllowedRoles::Normal).is_err());
1715    }
1716}