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