Skip to content

Commit f672ae2

Browse files
Run client_connected hook for HTTP SQL requests (#4563)
## Summary The `/v1/database/:name_or_identity/sql` endpoint now calls the module's `client_connected` reducer before executing SQL, and `client_disconnected` after. This allows module authors to accept or reject SQL connections based on the caller's identity, matching the existing behavior of the `/call` endpoint. ## Motivation Previously, the `/sql` endpoint bypassed the module's `onConnect` hook entirely, meaning module authors had no way to restrict who could run SQL queries against their database. The `/call` endpoint already runs the connect hook, so this brings `/sql` to parity. ## Changes - `crates/client-api/src/routes/database.rs`: The `sql` handler now: 1. Generates a random connection ID 2. Calls `module.call_identity_connected()` before executing SQL 3. Executes the SQL query 4. Calls `module.call_identity_disconnected()` after 5. If `client_connected` rejects the connection, returns 403 Forbidden without executing the query - `sql_direct()` is unchanged since it is also used by the pgwire server, which has its own connection lifecycle. ## Behavior - If the module defines a `client_connected` reducer that throws/errors for a given identity, the SQL request returns `403 Forbidden` - If no `client_connected` reducer is defined, behavior is unchanged - The connection is always cleaned up via `client_disconnected` after the query completes --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com> Co-authored-by: Zeke Foppa <bfops@users.noreply.github.com> Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
1 parent c65fc31 commit f672ae2

12 files changed

Lines changed: 197 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/client-api/src/routes/database.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use futures::TryStreamExt;
2424
use http::StatusCode;
2525
use log::{info, warn};
2626
use serde::Deserialize;
27+
use spacetimedb::auth::identity::ConnectionAuthCtx;
2728
use spacetimedb::database_logger::DatabaseLogger;
2829
use spacetimedb::host::module_host::ClientConnectedError;
2930
use spacetimedb::host::{CallResult, UpdateDatabaseResult};
@@ -518,22 +519,46 @@ pub async fn sql_direct<S>(
518519
SqlParams { name_or_identity }: SqlParams,
519520
SqlQueryParams { confirmed }: SqlQueryParams,
520521
caller_identity: Identity,
522+
caller_auth: ConnectionAuthCtx,
521523
sql: String,
522524
) -> axum::response::Result<Vec<SqlStmtResult<ProductValue>>>
523525
where
524526
S: NodeDelegate + ControlStateDelegate + Authorization,
525527
{
526-
// Anyone is authorized to execute SQL queries. The SQL engine will determine
527-
// which queries this identity is allowed to execute against the database.
528+
let connection_id = generate_random_connection_id();
528529

529530
let (host, database) = find_leader_and_database(&worker_ctx, name_or_identity).await?;
530531

531-
let auth = worker_ctx
532-
.authorize_sql(caller_identity, database.database_identity)
533-
.await?;
532+
// Run the module's client_connected reducer, if any.
533+
// If it rejects the connection, bail before executing SQL.
534+
let module = host.module().await.map_err(log_and_500)?;
535+
module
536+
.call_identity_connected(caller_auth, connection_id)
537+
.await
538+
.map_err(client_connected_error_to_response)?;
539+
540+
let result = async {
541+
let sql_auth = worker_ctx
542+
.authorize_sql(caller_identity, database.database_identity)
543+
.await?;
544+
545+
host.exec_sql(
546+
sql_auth,
547+
database,
548+
confirmed.unwrap_or(crate::DEFAULT_CONFIRMED_READS),
549+
sql,
550+
)
551+
.await
552+
}
553+
.await;
534554

535-
host.exec_sql(auth, database, confirmed.unwrap_or(crate::DEFAULT_CONFIRMED_READS), sql)
555+
// Always disconnect, even if authorization or execution failed.
556+
module
557+
.call_identity_disconnected(caller_identity, connection_id)
536558
.await
559+
.map_err(client_disconnected_error_to_response)?;
560+
561+
result
537562
}
538563

539564
pub async fn sql<S>(
@@ -546,7 +571,9 @@ pub async fn sql<S>(
546571
where
547572
S: NodeDelegate + ControlStateDelegate + Authorization,
548573
{
549-
let json = sql_direct(worker_ctx, name_or_identity, params, auth.claims.identity, body).await?;
574+
let caller_identity = auth.claims.identity;
575+
let caller_auth: ConnectionAuthCtx = auth.into();
576+
let json = sql_direct(worker_ctx, name_or_identity, params, caller_identity, caller_auth, body).await?;
550577

551578
let total_duration = json.iter().fold(0, |acc, x| acc + x.total_duration_micros);
552579

crates/pg/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ license-file = "LICENSE"
77
description = "Postgres wire protocol Server support for SpacetimeDB"
88

99
[dependencies]
10+
spacetimedb-auth.workspace = true
1011
spacetimedb-client-api-messages.workspace = true
1112
spacetimedb-client-api.workspace = true
1213
spacetimedb-lib.workspace = true

crates/pg/src/pg_server.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use pgwire::messages::data::DataRow;
2222
use pgwire::messages::startup::Authentication;
2323
use pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage};
2424
use pgwire::tokio::process_socket;
25+
use spacetimedb_auth::identity::ConnectionAuthCtx;
2526
use spacetimedb_client_api::auth::validate_token;
2627
use spacetimedb_client_api::routes::database;
2728
use spacetimedb_client_api::routes::database::{SqlParams, SqlQueryParams};
@@ -64,6 +65,7 @@ impl From<PgError> for PgWireError {
6465
struct Metadata {
6566
database: String,
6667
caller_identity: Identity,
68+
caller_auth: ConnectionAuthCtx,
6769
}
6870

6971
pub(crate) fn to_rows(
@@ -162,6 +164,7 @@ where
162164
db,
163165
SqlQueryParams { confirmed: Some(true) },
164166
params.caller_identity,
167+
params.caller_auth.clone(),
165168
query.to_string(),
166169
)
167170
.await,
@@ -265,8 +268,8 @@ impl<T: Sync + Send + ControlStateReadAccess + ControlStateWriteAccess + NodeDel
265268
}
266269
};
267270

268-
let caller_identity = match validate_token(&self.ctx, &pwd.password).await {
269-
Ok(claims) => claims.identity,
271+
let claims = match validate_token(&self.ctx, &pwd.password).await {
272+
Ok(claims) => claims,
270273
Err(err) => {
271274
log::error!(
272275
"PG: Authentication failed for identity `{}` on database {database}: {err}",
@@ -276,12 +279,22 @@ impl<T: Sync + Send + ControlStateReadAccess + ControlStateWriteAccess + NodeDel
276279
return close_client(client, err).await;
277280
}
278281
};
282+
let caller_identity = claims.identity;
283+
let caller_auth = ConnectionAuthCtx::try_from(claims).map_err(|e| {
284+
PgWireError::UserError(Box::new(ErrorInfo::new(
285+
"FATAL".to_owned(),
286+
// "invalid_authorization_specification"
287+
"28000".to_owned(),
288+
e.to_string(),
289+
)))
290+
})?;
279291

280292
log::info!("PG: Connected to database: {database} using identity `{caller_identity}`");
281293

282294
let metadata = Metadata {
283295
database,
284296
caller_identity,
297+
caller_auth,
285298
};
286299
self.cached.lock().await.clone_from(&Some(metadata));
287300
finish_authentication(client, &self.parameter_provider).await?;

crates/smoketests/modules/Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/smoketests/modules/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ members = [
7575
"delete-database",
7676
"client-connection-reject",
7777
"client-connection-disconnect-panic",
78+
"sql-connect-hook",
7879

7980
# Log filtering tests
8081
"logs-level-filter",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[package]
2+
name = "smoketest-module-sql-connect-hook"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[lib]
8+
crate-type = ["cdylib"]
9+
10+
[dependencies]
11+
spacetimedb.workspace = true
12+
log.workspace = true
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use spacetimedb::{log, ReducerContext, Table};
2+
3+
#[spacetimedb::table(accessor = person, public)]
4+
pub struct Person {
5+
name: String,
6+
}
7+
8+
#[spacetimedb::reducer(init)]
9+
pub fn init(ctx: &ReducerContext) {
10+
ctx.db.person().insert(Person {
11+
name: "Alice".to_string(),
12+
});
13+
}
14+
15+
#[spacetimedb::reducer(client_connected)]
16+
pub fn connected(ctx: &ReducerContext) {
17+
log::info!("sql_connect_hook: client_connected caller={}", ctx.sender());
18+
}
19+
20+
#[spacetimedb::reducer(client_disconnected)]
21+
pub fn disconnected(ctx: &ReducerContext) {
22+
log::info!("sql_connect_hook: client_disconnected caller={}", ctx.sender());
23+
}

crates/smoketests/tests/smoketests/client_connection_errors.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ fn test_client_disconnected_error_still_deletes_st_client() {
4646
logs
4747
);
4848

49-
// Verify st_client table is empty (row was deleted despite the panic)
49+
// Verify the websocket's st_client row was deleted despite the panic.
50+
// The SQL query itself creates a temporary connection, so we may see
51+
// exactly one row (the SQL connection's own), but the websocket's row
52+
// should be gone.
5053
let sql_out = test.sql("SELECT * FROM st_client").unwrap();
54+
let row_count = sql_out.lines().filter(|l| l.contains("0x")).count();
5155
assert!(
52-
sql_out.contains("identity | connection_id") && !sql_out.contains("0x"),
53-
"Expected st_client table to be empty, got: {}",
54-
sql_out
56+
row_count <= 1,
57+
"Expected at most 1 st_client row (the SQL connection itself), got {row_count}: {sql_out}",
5558
);
5659
}

crates/smoketests/tests/smoketests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ mod rls;
3434
mod schedule_reducer;
3535
mod servers;
3636
mod sql;
37+
mod sql_connect_hook;
3738
mod templates;
3839
mod timestamp_route;
3940
mod views;

0 commit comments

Comments
 (0)