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