Skip to content

Commit 100eacd

Browse files
authored
Merge pull request #22331 from MaterializeInc/roshan/internal-api-ws
Add a websocket route to the Internal HTTP API
2 parents cf61951 + 6da8c8b commit 100eacd

4 files changed

Lines changed: 195 additions & 49 deletions

File tree

src/environmentd/src/http.rs

Lines changed: 80 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub enum TlsMode {
106106
#[derive(Clone)]
107107
pub struct WsState {
108108
frontegg: Arc<Option<FronteggAuthentication>>,
109-
adapter_client: mz_adapter::Client,
109+
adapter_client_rx: Delayed<mz_adapter::Client>,
110110
active_connection_count: SharedConnectionCounter,
111111
}
112112

@@ -135,13 +135,13 @@ impl HttpServer {
135135
adapter_client_tx
136136
.send(adapter_client.clone())
137137
.expect("rx known to be live");
138-
138+
let adapter_client_rx = adapter_client_rx.shared();
139139
let base_router = base_router(BaseRouterConfig { profiling: false })
140140
.layer(middleware::from_fn(move |req, next| {
141141
let base_frontegg = Arc::clone(&base_frontegg);
142-
async move { http_auth(req, next, tls_mode, &base_frontegg).await }
142+
async move { http_auth(req, next, tls_mode, base_frontegg.as_ref().as_ref()).await }
143143
}))
144-
.layer(Extension(adapter_client_rx.shared()))
144+
.layer(Extension(adapter_client_rx.clone()))
145145
.layer(Extension(Arc::clone(&active_connection_count)))
146146
.layer(
147147
CorsLayer::new()
@@ -156,12 +156,11 @@ impl HttpServer {
156156
.expose_headers(Any)
157157
.max_age(Duration::from_secs(60) * 60),
158158
);
159-
160159
let ws_router = Router::new()
161160
.route("/api/experimental/sql", routing::get(sql::handle_sql_ws))
162161
.with_state(WsState {
163162
frontegg,
164-
adapter_client: adapter_client.clone(),
163+
adapter_client_rx,
165164
active_connection_count,
166165
});
167166

@@ -375,6 +374,7 @@ impl InternalHttpServer {
375374
"/internal-console".to_string(),
376375
));
377376

377+
let adapter_client_rx = adapter_client_rx.shared();
378378
let router = base_router(BaseRouterConfig { profiling: true })
379379
.route(
380380
"/metrics",
@@ -439,9 +439,22 @@ impl InternalHttpServer {
439439
routing::get(console::handle_internal_console),
440440
)
441441
.layer(middleware::from_fn(internal_http_auth))
442-
.layer(Extension(adapter_client_rx.shared()))
442+
.layer(Extension(adapter_client_rx.clone()))
443443
.layer(Extension(console_config))
444-
.layer(Extension(active_connection_count));
444+
.layer(Extension(Arc::clone(&active_connection_count)));
445+
446+
let ws_router = Router::new()
447+
.route("/api/experimental/sql", routing::get(sql::handle_sql_ws))
448+
// This middleware extracts the MZ user from the x-materialize-user http header.
449+
// Normally, browser-initiated websocket requests do not support headers, however for the
450+
// Internal HTTP Server the browser would be connecting through teleport, which should
451+
// attach the x-materialize-user header to all requests it proxies to this api.
452+
.layer(middleware::from_fn(internal_http_auth))
453+
.with_state(WsState {
454+
frontegg: Arc::new(None),
455+
adapter_client_rx,
456+
active_connection_count,
457+
});
445458

446459
let leader_router = Router::new()
447460
.route("/api/leader/status", routing::get(handle_leader_status))
@@ -451,7 +464,10 @@ impl InternalHttpServer {
451464
ready_to_promote,
452465
})));
453466

454-
let router = router.merge(leader_router).apply_default_layers(metrics);
467+
let router = router
468+
.merge(ws_router)
469+
.merge(leader_router)
470+
.apply_default_layers(metrics);
455471

456472
InternalHttpServer { router }
457473
}
@@ -484,7 +500,10 @@ impl Server for InternalHttpServer {
484500
let router = self.router.clone();
485501
Box::pin(async {
486502
let http = hyper::server::conn::Http::new();
487-
http.serve_connection(conn, router).err_into().await
503+
http.serve_connection(conn, router)
504+
.with_upgrades()
505+
.err_into()
506+
.await
488507
})
489508
}
490509
}
@@ -500,7 +519,7 @@ enum ConnProtocol {
500519
}
501520

502521
#[derive(Clone, Debug)]
503-
struct AuthedUser(User);
522+
pub struct AuthedUser(User);
504523

505524
pub struct AuthedClient {
506525
pub client: SessionClient,
@@ -642,7 +661,7 @@ async fn http_auth<B>(
642661
mut req: Request<B>,
643662
next: Next<B>,
644663
tls_mode: TlsMode,
645-
frontegg: &Option<FronteggAuthentication>,
664+
frontegg: Option<&FronteggAuthentication>,
646665
) -> impl IntoResponse {
647666
// First, extract the username from the certificate, validating that the
648667
// connection matches the TLS configuration along the way.
@@ -685,9 +704,10 @@ async fn http_auth<B>(
685704
async fn init_ws(
686705
WsState {
687706
frontegg,
688-
adapter_client,
707+
adapter_client_rx,
689708
active_connection_count,
690709
}: &WsState,
710+
existing_user: Option<AuthedUser>,
691711
ws: &mut WebSocket,
692712
) -> Result<AuthedClient, anyhow::Error> {
693713
// TODO: Add a timeout here to prevent resource leaks by clients that
@@ -709,33 +729,59 @@ async fn init_ws(
709729
}
710730
}
711731
};
712-
let (creds, options) = if frontegg.is_some() {
713-
match ws_auth {
732+
let (user, options) = match (frontegg.as_ref(), existing_user, ws_auth) {
733+
(Some(frontegg), None, ws_auth) => {
734+
let (creds, options) = match ws_auth {
735+
WebSocketAuth::Basic {
736+
user,
737+
password,
738+
options,
739+
} => {
740+
let creds = Credentials::Password {
741+
username: user,
742+
password,
743+
};
744+
(creds, options)
745+
}
746+
WebSocketAuth::Bearer { token, options } => {
747+
let creds = Credentials::Token { token };
748+
(creds, options)
749+
}
750+
WebSocketAuth::OptionsOnly { options: _ } => {
751+
anyhow::bail!("expected auth information");
752+
}
753+
};
754+
(auth(Some(frontegg), creds).await?, options)
755+
}
756+
(
757+
None,
758+
None,
714759
WebSocketAuth::Basic {
715760
user,
716-
password,
761+
password: _,
717762
options,
718-
} => {
719-
let creds = Credentials::Password {
720-
username: user,
721-
password,
722-
};
723-
(creds, options)
724-
}
725-
WebSocketAuth::Bearer { token, options } => {
726-
let creds = Credentials::Token { token };
727-
(creds, options)
728-
}
763+
},
764+
) => (auth(None, Credentials::User(user)).await?, options),
765+
// No frontegg, specified existing user, we only accept options only.
766+
(None, Some(existing_user), WebSocketAuth::OptionsOnly { options }) => {
767+
(existing_user, options)
768+
}
769+
// No frontegg, specified existing user, we do not expect basic or bearer auth.
770+
(None, Some(_), WebSocketAuth::Basic { .. } | WebSocketAuth::Bearer { .. }) => {
771+
warn!("Unexpected bearer or basic auth provided when using user header");
772+
anyhow::bail!("unexpected")
773+
}
774+
// Specifying both frontegg and an existing user should not be possible.
775+
(Some(_), Some(_), _) => anyhow::bail!("unexpected"),
776+
// No frontegg, no existing user, and no passed username.
777+
(None, None, WebSocketAuth::Bearer { .. } | WebSocketAuth::OptionsOnly { .. }) => {
778+
warn!("Unexpected auth type when not using frontegg or user header");
779+
anyhow::bail!("unexpected")
729780
}
730-
} else if let WebSocketAuth::Basic { user, options, .. } = ws_auth {
731-
(Credentials::User(user), options)
732-
} else {
733-
anyhow::bail!("unexpected")
734781
};
735-
let user = auth(frontegg, creds).await?;
736782

737783
let client = AuthedClient::new(
738-
adapter_client,
784+
&adapter_client_rx.clone().await?,
739785
user,
740786
Arc::clone(active_connection_count),
741787
options,
@@ -753,7 +799,7 @@ enum Credentials {
753799
}
754800

755801
async fn auth(
756-
frontegg: &Option<FronteggAuthentication>,
802+
frontegg: Option<&FronteggAuthentication>,
757803
creds: Credentials,
758804
) -> Result<AuthedUser, AuthError> {
759805
// There are three places a username may be specified:

src/environmentd/src/http/sql.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use async_trait::async_trait;
1717
use axum::extract::ws::{CloseFrame, Message, WebSocket};
1818
use axum::extract::{State, WebSocketUpgrade};
1919
use axum::response::IntoResponse;
20-
use axum::Json;
20+
use axum::{Extension, Json};
2121
use futures::future::BoxFuture;
2222
use futures::Future;
2323
use http::StatusCode;
@@ -43,7 +43,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
4343
use tracing::debug;
4444
use tungstenite::protocol::frame::coding::CloseCode;
4545

46-
use crate::http::{init_ws, AuthedClient, WsState, MAX_REQUEST_SIZE};
46+
use crate::http::{init_ws, AuthedClient, AuthedUser, WsState, MAX_REQUEST_SIZE};
4747

4848
pub async fn handle_sql(
4949
mut client: AuthedClient,
@@ -67,10 +67,13 @@ struct ErrorResponse {
6767

6868
pub async fn handle_sql_ws(
6969
State(state): State<WsState>,
70+
existing_user: Option<Extension<AuthedUser>>,
7071
ws: WebSocketUpgrade,
7172
) -> impl IntoResponse {
73+
// An upstream middleware may have already provided the user for us
74+
let user = existing_user.and_then(|Extension(user)| Some(user));
7275
ws.max_message_size(MAX_REQUEST_SIZE)
73-
.on_upgrade(|ws| async move { run_ws(&state, ws).await })
76+
.on_upgrade(|ws| async move { run_ws(&state, user, ws).await })
7477
}
7578

7679
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
@@ -87,10 +90,14 @@ pub enum WebSocketAuth {
8790
#[serde(default)]
8891
options: BTreeMap<String, String>,
8992
},
93+
OptionsOnly {
94+
#[serde(default)]
95+
options: BTreeMap<String, String>,
96+
},
9097
}
9198

92-
async fn run_ws(state: &WsState, mut ws: WebSocket) {
93-
let mut client = match init_ws(state, &mut ws).await {
99+
async fn run_ws(state: &WsState, user: Option<AuthedUser>, mut ws: WebSocket) {
100+
let mut client = match init_ws(state, user, &mut ws).await {
94101
Ok(client) => client,
95102
Err(e) => {
96103
// We omit most detail from the error message we send to the client, to

src/environmentd/tests/server.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ use std::{iter, thread};
9090
use anyhow::bail;
9191
use chrono::{DateTime, Utc};
9292
use futures::FutureExt;
93-
use http::StatusCode;
93+
use http::{Request, StatusCode};
9494
use itertools::Itertools;
9595
use mz_environmentd::http::{
9696
BecomeLeaderResponse, BecomeLeaderResult, LeaderStatus, LeaderStatusResponse,
9797
};
98-
use mz_environmentd::WebSocketResponse;
98+
use mz_environmentd::{WebSocketAuth, WebSocketResponse};
9999
use mz_ore::cast::CastFrom;
100100
use mz_ore::cast::CastLossy;
101101
use mz_ore::cast::TryCastFrom;
@@ -2110,6 +2110,80 @@ fn test_internal_http_auth() {
21102110
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{:?}", res.text());
21112111
}
21122112

2113+
#[mz_ore::test]
2114+
#[cfg_attr(miri, ignore)] // too slow
2115+
fn test_internal_ws_auth() {
2116+
let server = util::start_server(util::Config::default()).unwrap();
2117+
2118+
// Create our WebSocket.
2119+
let ws_url = server.internal_ws_addr();
2120+
let make_req = || {
2121+
Request::builder()
2122+
.uri(ws_url.as_str())
2123+
.method("GET")
2124+
.header("Host", ws_url.host_str().unwrap())
2125+
.header("Connection", "Upgrade")
2126+
.header("Upgrade", "websocket")
2127+
.header("Sec-WebSocket-Version", "13")
2128+
.header("Sec-WebSocket-Key", "foobar")
2129+
// Set our user to the mz_support user
2130+
.header("x-materialize-user", "mz_support")
2131+
.body(())
2132+
.unwrap()
2133+
};
2134+
2135+
let (mut ws, _resp) = tungstenite::connect(make_req()).unwrap();
2136+
let options = BTreeMap::from([(
2137+
"application_name".to_string(),
2138+
"billion_dollar_idea".to_string(),
2139+
)]);
2140+
// We should receive error if sending the standard bearer auth, since that is unexpected
2141+
// for the Internal HTTP API
2142+
assert_eq!(util::auth_with_ws(&mut ws, options.clone()).is_err(), true);
2143+
2144+
// Recreate the websocket
2145+
let (mut ws, _resp) = tungstenite::connect(make_req()).unwrap();
2146+
// Auth with OptionsOnly
2147+
util::auth_with_ws_impl(
2148+
&mut ws,
2149+
Message::Text(serde_json::to_string(&WebSocketAuth::OptionsOnly { options }).unwrap()),
2150+
)
2151+
.unwrap();
2152+
2153+
// Query to make sure we get back the correct user, which should be
2154+
// set from the headers passed with the websocket request.
2155+
let json = "{\"query\":\"SELECT current_user;\"}";
2156+
let json: serde_json::Value = serde_json::from_str(json).unwrap();
2157+
ws.send(Message::Text(json.to_string())).unwrap();
2158+
2159+
let mut read_msg = || -> WebSocketResponse {
2160+
let msg = ws.read().unwrap();
2161+
let msg = msg.into_text().expect("response should be text");
2162+
serde_json::from_str(&msg).unwrap()
2163+
};
2164+
let starting = read_msg();
2165+
let columns = read_msg();
2166+
let row_val = read_msg();
2167+
2168+
if !matches!(starting, WebSocketResponse::CommandStarting(_)) {
2169+
panic!("wrong message!, {starting:?}");
2170+
};
2171+
2172+
if let WebSocketResponse::Rows(rows) = columns {
2173+
let names: Vec<&str> = rows.columns.iter().map(|c| c.name.as_str()).collect();
2174+
assert_eq!(names, ["current_user"]);
2175+
} else {
2176+
panic!("wrong message!, {columns:?}");
2177+
};
2178+
2179+
if let WebSocketResponse::Row(row) = row_val {
2180+
let expected = serde_json::Value::String("mz_support".to_string());
2181+
assert_eq!(row, [expected]);
2182+
} else {
2183+
panic!("wrong message!, {row_val:?}");
2184+
}
2185+
}
2186+
21132187
#[mz_ore::test]
21142188
#[cfg_attr(miri, ignore)] // too slow
21152189
fn test_leader_promotion() {

0 commit comments

Comments
 (0)