Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ci/nightly/pipeline.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,17 @@ steps:
# See: <https://github.com/microsoft/mssql-docker/issues/864>
queue: hetzner-x86-64-8cpu-16gb

- id: http-auth
label: HTTP auth
depends_on: build-aarch64
timeout_in_minutes: 30
inputs: [test/http-auth]
plugins:
- ./ci/plugins/mzcompose:
composition: http-auth
agents:
queue: hetzner-aarch64-4cpu-8gb


- group: Zippy
key: zippy
Expand Down
33 changes: 30 additions & 3 deletions src/environmentd/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,8 @@ impl HttpServer {
let login_router = Router::new()
.route("/api/login", routing::post(handle_login))
.route("/api/logout", routing::post(handle_logout))
.layer(Extension(adapter_client_rx));
.layer(Extension(adapter_client_rx))
.layer(Extension(allowed_roles));
router = router.merge(login_router).layer(session_layer);
}
listeners::AuthenticatorKind::None => {
Expand Down Expand Up @@ -881,8 +882,20 @@ impl IntoResponse for AuthError {
pub async fn handle_login(
session: Option<Extension<TowerSession>>,
Extension(adapter_client_rx): Extension<Delayed<Client>>,
Extension(allowed_roles): Extension<AllowedRoles>,
Json(LoginCredentials { username, password }): Json<LoginCredentials>,
) -> impl IntoResponse {
// Enforce the listener's allowed_roles policy before doing any
// authentication work, mirroring the check performed in `auth` for
// header-based credentials. Without this, a session-based caller could
// log in as a role that header-based callers are forbidden to use.
if let Err(err) = check_role_allowed(&username, allowed_roles) {
warn!(
?err,
"HTTP login rejected: role not allowed on this listener"
);
return StatusCode::UNAUTHORIZED;
}
let Ok(adapter_client) = adapter_client_rx.clone().await else {
return StatusCode::INTERNAL_SERVER_ERROR;
};
Expand Down Expand Up @@ -956,6 +969,12 @@ async fn http_auth(
maybe_get_authenticated_session(req.extensions().get::<TowerSession>()).await
{
let user = ensure_session_unexpired(session, session_data).await?;
// Defense-in-depth: re-check the listener's `allowed_roles` policy on
// every session-authenticated request. The same check runs at
// `/api/login`, but enforcing it here too prevents a session minted
// under a more permissive configuration (or a future bug in the login
// path) from bypassing role restrictions.
check_role_allowed(&user.name, allowed_roles)?;
// Need this to set the user of the Adapter client.
req.extensions_mut().insert(user);
return Ok(next.run(req).await);
Expand Down Expand Up @@ -1075,8 +1094,16 @@ async fn init_ws(
warn!("Unexpected bearer or basic auth provided when using user header");
anyhow::bail!("unexpected")
}
(Some(ExistingUser::Session(user)), None)
| (Some(ExistingUser::XMaterializeUserHeader(user)), None) => user,
(Some(ExistingUser::Session(user)), None) => {
// Defense-in-depth: re-enforce the listener's `allowed_roles`
// policy on session-authenticated WebSocket connections. The same
// check runs at `/api/login`, but enforcing it here too prevents a
// session minted under a more permissive configuration (or a
// future bug in the login path) from bypassing role restrictions.
check_role_allowed(&user.name, allowed_roles)?;
user
}
(Some(ExistingUser::XMaterializeUserHeader(user)), None) => user,
(_, Some(creds)) => {
let authenticator = get_authenticator(
authenticator_kind,
Expand Down
50 changes: 50 additions & 0 deletions test/http-auth/listener_config_normal_only.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"sql": {
"external": {
"addr": "0.0.0.0:6875",
"authenticator_kind": "Password",
"allowed_roles": "NormalAndInternal",
"enable_tls": false
},
"internal": {
"addr": "0.0.0.0:6877",
"authenticator_kind": "None",
"allowed_roles": "Internal",
"enable_tls": false
}
},
"http": {
"external": {
"addr": "0.0.0.0:6876",
"authenticator_kind": "Password",
"allowed_roles": "Normal",
"enable_tls": false,
"routes": {
"base": true,
"webhook": false,
"internal": false,
"metrics": false,
"profiling": false,
"mcp_agent": false,
"mcp_developer": false,
"console_config": false
}
},
"metrics": {
"addr": "0.0.0.0:6878",
"authenticator_kind": "None",
"allowed_roles": "NormalAndInternal",
"enable_tls": false,
"routes": {
"base": false,
"webhook": false,
"internal": false,
"metrics": true,
"profiling": false,
"mcp_agent": false,
"mcp_developer": false,
"console_config": false
}
}
}
}
14 changes: 14 additions & 0 deletions test/http-auth/mzcompose
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash

# Copyright Materialize, Inc. and contributors. All rights reserved.
#
# Use of this software is governed by the Business Source License
# included in the LICENSE file at the root of this repository.
#
# As of the Change Date specified in that file, in accordance with
# the Business Source License, use of this software will be governed
# by the Apache License, Version 2.0.
#
# mzcompose — runs Docker Compose with Materialize customizations.

exec "$(dirname "$0")"/../../bin/pyactivate -m materialize.cli.mzcompose "$@"
45 changes: 45 additions & 0 deletions test/http-auth/mzcompose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright Materialize, Inc. and contributors. All rights reserved.
#
# Use of this software is governed by the Business Source License
# included in the LICENSE file at the root of this repository.
#
# As of the Change Date specified in that file, in accordance with
# the Business Source License, use of this software will be governed
# by the Apache License, Version 2.0.

import requests

from materialize import MZ_ROOT
from materialize.mzcompose.composition import Composition
from materialize.mzcompose.services.materialized import Materialized

SERVICES = [
Materialized(),
]


def workflow_default(c: Composition) -> None:
with c.override(
Materialized(
listeners_config_path=f"{MZ_ROOT}/test/http-auth/listener_config_normal_only.json"
)
):
c.up("materialized")
base = f"http://localhost:{c.port('materialized', 6876)}"

# Regression test for database-issues#11340. With `allowed_roles:
# Normal`, header-based Basic auth correctly rejects `mz_system`, but
# `/api/login` previously did not run the same role check — letting an
# internal role obtain a session cookie and bypass the policy on
# subsequent requests. Make sure `/api/login` enforces the listener's
# role policy directly and never mints a session for a disallowed role.
with c.test_case("session_login_rejects_disallowed_role"):
s = requests.Session()
r = s.post(
f"{base}/api/login",
json={"username": "mz_system", "password": "password"},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
assert (
"mz_session" not in s.cookies
), f"login rejection must not set a session cookie: {s.cookies}"
Loading