From 5470dd46dae9011db864b561fcda157c6d4a7053 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 13:30:10 +0000 Subject: [PATCH 1/6] test(security): cover SQL transport authorization --- Cargo.lock | 18 ++ .../src/native_harness/frames.rs | 19 +- nodedb-test-support/src/native_harness/mod.rs | 4 +- .../src/native_harness/server.rs | 13 +- nodedb/tests/http_query_authorization.rs | 201 ++++++++++++++ .../tests/http_query_stream_authorization.rs | 257 ++++++++++++++++++ nodedb/tests/http_ws_authorization.rs | 178 ++++++++++++ nodedb/tests/native_sql_authorization.rs | 177 ++++++++++++ 8 files changed, 863 insertions(+), 4 deletions(-) create mode 100644 nodedb/tests/http_query_authorization.rs create mode 100644 nodedb/tests/http_query_stream_authorization.rs create mode 100644 nodedb/tests/http_ws_authorization.rs create mode 100644 nodedb/tests/native_sql_authorization.rs diff --git a/Cargo.lock b/Cargo.lock index f223c0ee8..165b5b770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2272,6 +2272,8 @@ checksum = "28a80e3145d8ad11ba0995949bbcf48b9df2be62772b3d351ef017dff6ecb853" [[package]] name = "fluxbench" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323b4f98b988b9779c6aa3f93781ef7421e36e73a5c610497857b2c01398acdc" dependencies = [ "fluxbench-cli", "fluxbench-core", @@ -2286,6 +2288,8 @@ dependencies = [ [[package]] name = "fluxbench-cli" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42072e01658451938143f620a08f13b308e30a7fe41f3de87a79f351c5753328" dependencies = [ "anyhow", "chrono", @@ -2313,6 +2317,8 @@ dependencies = [ [[package]] name = "fluxbench-core" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780bc18df5d1aa766ab5394389fb989c51d5d6ac3b00e51340f97c6510268edf" dependencies = [ "fluxbench-ipc", "inventory", @@ -2325,6 +2331,8 @@ dependencies = [ [[package]] name = "fluxbench-ipc" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c44dcc5fe5705f4933b7768a4fdb552ab97c098d4d3e49b93096152527b89fe" dependencies = [ "rkyv 0.8.16", "thiserror 2.0.18", @@ -2333,6 +2341,8 @@ dependencies = [ [[package]] name = "fluxbench-logic" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4809eb2b1c33ee46c51cc28ee5d8c8ea8dbc4977151af00a03a1d1f5d487ab" dependencies = [ "evalexpr", "fluxbench-core", @@ -2347,6 +2357,8 @@ dependencies = [ [[package]] name = "fluxbench-macros" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71729e5cb94b94beb14f6a9319a129e6cf3e116112f56578182edadeaaddc79a" dependencies = [ "proc-macro2", "quote", @@ -2356,6 +2368,8 @@ dependencies = [ [[package]] name = "fluxbench-report" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bf8934a0c315e390d691e3c9993e52b59ed88ecb8993d882a69721559342d0" dependencies = [ "chrono", "fluxbench-core", @@ -2369,6 +2383,8 @@ dependencies = [ [[package]] name = "fluxbench-stats" version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec71a4355a6558d47beea9abda1fda67580e124fefdb9e93f0aaab4cc872063" dependencies = [ "rand 0.8.6", "rayon", @@ -4117,6 +4133,8 @@ dependencies = [ [[package]] name = "nexar" version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac038912d8e0f36309f3368c0980377730edd4ad5d082627227277cdd870c3a" dependencies = [ "bytes", "crossbeam-queue", diff --git a/nodedb-test-support/src/native_harness/frames.rs b/nodedb-test-support/src/native_harness/frames.rs index ace462f34..b33d89d41 100644 --- a/nodedb-test-support/src/native_harness/frames.rs +++ b/nodedb-test-support/src/native_harness/frames.rs @@ -11,8 +11,8 @@ use tokio::net::TcpStream; use nodedb_types::protocol::request_fields::RequestFields; use nodedb_types::protocol::text_fields::TextFields; use nodedb_types::protocol::{ - FRAME_HEADER_LEN, HELLO_ACK_MAGIC, HELLO_ERROR_MAGIC_U32, HelloAckFrame, HelloErrorFrame, - HelloFrame, NativeRequest, NativeResponse, OpCode, + AuthMethod, FRAME_HEADER_LEN, HELLO_ACK_MAGIC, HELLO_ERROR_MAGIC_U32, HelloAckFrame, + HelloErrorFrame, HelloFrame, NativeRequest, NativeResponse, OpCode, }; /// Perform the handshake with a custom `HelloFrame`. @@ -127,6 +127,21 @@ pub async fn send_request( sonic_rs::from_slice(&response_payload).expect("json decode NativeResponse") } +/// Authenticate a fresh native connection with an API key. The JSON Auth +/// request also selects JSON framing for the rest of the session. +pub async fn send_api_key_auth(stream: &mut TcpStream, seq: u64, token: String) -> NativeResponse { + send_request( + stream, + seq, + OpCode::Auth, + TextFields { + auth: Some(AuthMethod::ApiKey { token }), + ..Default::default() + }, + ) + .await +} + /// Send a `SHOW`/SQL statement over an established JSON-encoding session and /// decode the `NativeResponse`. Assumes the session's first frame already /// selected JSON (see `json_request_gets_json_response`) — callers that open diff --git a/nodedb-test-support/src/native_harness/mod.rs b/nodedb-test-support/src/native_harness/mod.rs index 3c43858c7..fb641d0a0 100644 --- a/nodedb-test-support/src/native_harness/mod.rs +++ b/nodedb-test-support/src/native_harness/mod.rs @@ -9,5 +9,7 @@ mod frames; mod server; -pub use frames::{do_handshake, read_frame, send_request, send_sql, write_frame}; +pub use frames::{ + do_handshake, read_frame, send_api_key_auth, send_request, send_sql, write_frame, +}; pub use server::NativeTestServer; diff --git a/nodedb-test-support/src/native_harness/server.rs b/nodedb-test-support/src/native_harness/server.rs index 783822049..bd5a0a6bd 100644 --- a/nodedb-test-support/src/native_harness/server.rs +++ b/nodedb-test-support/src/native_harness/server.rs @@ -17,6 +17,7 @@ use nodedb::wal::WalManager; /// A running native-protocol test server. pub struct NativeTestServer { pub addr: std::net::SocketAddr, + pub shared: Arc, pub(super) shutdown_bus: nodedb::control::shutdown::ShutdownBus, pub(super) poller_shutdown_tx: tokio::sync::watch::Sender, pub(super) core_stop_tx: std::sync::mpsc::Sender<()>, @@ -31,6 +32,15 @@ impl NativeTestServer { /// Spawn a single-core NodeDB server with the native listener bound to /// an ephemeral `127.0.0.1` port (trust-mode auth). pub async fn start() -> Self { + Self::start_with_auth_mode(AuthMode::Trust).await + } + + /// Spawn a single-core server that requires an explicit native Auth frame. + pub async fn start_authenticated() -> Self { + Self::start_with_auth_mode(AuthMode::Password).await + } + + async fn start_with_auth_mode(auth_mode: AuthMode) -> Self { let dir = tempfile::tempdir().expect("tempdir"); let wal_path = dir.path().join("test.wal"); let wal = Arc::new(WalManager::open_for_testing(&wal_path).expect("open wal")); @@ -128,7 +138,7 @@ impl NativeTestServer { listener .run(nodedb::control::server::listener::ListenerRunParams { state: shared_listener, - auth_mode: AuthMode::Trust, + auth_mode, tls_acceptor: None, conn_semaphore: Arc::new(tokio::sync::Semaphore::new(128)), startup_gate: test_startup_gate, @@ -145,6 +155,7 @@ impl NativeTestServer { Self { addr, + shared, shutdown_bus, poller_shutdown_tx, core_stop_tx, diff --git a/nodedb/tests/http_query_authorization.rs b/nodedb/tests/http_query_authorization.rs new file mode 100644 index 000000000..faea7a82f --- /dev/null +++ b/nodedb/tests/http_query_authorization.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Authorization parity for materialized HTTP SQL queries. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::pgwire_harness::TestServer; +use nodedb::config::auth::AuthMode; +use nodedb::control::security::apikey::CreateKeyParams; +use nodedb::control::security::identity::{Permission, Role}; +use nodedb::control::security::permission::collection_target; +use nodedb::control::state::SharedState; +use nodedb::types::{DatabaseId, TenantId}; + +struct AuthenticatedHttpEndpoint { + local_addr: std::net::SocketAddr, + _server: tokio::task::JoinHandle<()>, +} + +async fn start_authenticated_http(shared: Arc) -> AuthenticatedHttpEndpoint { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind authenticated HTTP listener"); + let local_addr = listener.local_addr().expect("authenticated HTTP address"); + let (bus, _) = nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown)); + let handle = tokio::spawn(async move { + nodedb::control::server::http::server::run_with_listener( + listener, + shared, + AuthMode::Password, + None, + bus, + ) + .await + .expect("authenticated HTTP server"); + }); + tokio::time::sleep(Duration::from_millis(40)).await; + + AuthenticatedHttpEndpoint { + local_addr, + _server: handle, + } +} + +fn create_api_key(shared: &SharedState, username: &str, roles: Vec) -> String { + let user_id = shared + .credentials + .create_service_account(username, TenantId::new(1), roles, vec![DatabaseId::DEFAULT]) + .expect("create database-scoped service account"); + shared + .api_keys + .create_key( + CreateKeyParams { + username, + user_id, + tenant_id: TenantId::new(1), + expires_secs: 0, + scope: vec![], + accessible_databases: vec![DatabaseId::DEFAULT], + }, + Some(shared.credentials.catalog()), + ) + .expect("create API key") +} + +async fn post_query(http: &AuthenticatedHttpEndpoint, token: &str, sql: &str) -> reqwest::Response { + reqwest::Client::new() + .post(format!("http://{}/v1/query", http.local_addr)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({"sql": sql})) + .send() + .await + .expect("POST authenticated query") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_rejects_database_outside_api_key_scope() { + let srv = TestServer::start().await; + srv.exec("CREATE DATABASE private_http_db") + .await + .expect("create inaccessible database"); + let token = create_api_key(&srv.shared, "http_db_reader", vec![Role::ReadOnly]); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + + let response = reqwest::Client::new() + .post(format!("http://{}/v1/query", http.local_addr)) + .header("Authorization", format!("Bearer {token}")) + .header("X-NodeDB-Database", "private_http_db") + .json(&serde_json::json!({"sql": "SELECT 1"})) + .send() + .await + .expect("POST cross-database query"); + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "HTTP queries must enforce the API key's database scope before planning or execution" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_rejects_cross_database_write_without_mutating_target() { + let srv = TestServer::start().await; + srv.exec("CREATE DATABASE private_write_db") + .await + .expect("create inaccessible write database"); + srv.exec("USE DATABASE private_write_db") + .await + .expect("switch to write database as superuser"); + srv.exec("CREATE COLLECTION private_write_rows") + .await + .expect("create private write collection"); + srv.exec("USE DATABASE default") + .await + .expect("return to default database"); + + let token = create_api_key(&srv.shared, "http_db_writer", vec![Role::ReadWrite]); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = reqwest::Client::new() + .post(format!("http://{}/v1/query", http.local_addr)) + .header("Authorization", format!("Bearer {token}")) + .header("X-NodeDB-Database", "private_write_db") + .json(&serde_json::json!({ + "sql": "INSERT INTO private_write_rows { id: 'forbidden', value: 5 }" + })) + .send() + .await + .expect("POST cross-database write"); + + srv.exec("USE DATABASE private_write_db") + .await + .expect("inspect write database"); + let rows = srv + .query_text("SELECT id FROM private_write_rows") + .await + .expect("query private write rows"); + assert!( + rows.is_empty(), + "a cross-database write must not mutate the target: {rows:?}" + ); + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "cross-database HTTP writes must be rejected" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_rejects_system_catalog_for_non_superuser() { + let srv = TestServer::start().await; + let token = create_api_key(&srv.shared, "http_catalog_reader", vec![Role::ReadOnly]); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + + let response = post_query(&http, &token, "SELECT * FROM _system.audit_log").await; + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "system catalog access must require a superuser on every SQL transport" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_honors_explicit_collection_grant_for_custom_role() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION granted_rows") + .await + .expect("create granted collection"); + srv.exec("INSERT INTO granted_rows { id: 'visible', value: 9 }") + .await + .expect("seed granted collection"); + + let username = "http_explicit_reader"; + let token = create_api_key( + &srv.shared, + username, + vec![Role::Custom("http_explicit_role".into())], + ); + srv.shared + .permissions + .grant( + &collection_target(TenantId::new(1), "granted_rows"), + &format!("user:{username}"), + Permission::Read, + "nodedb", + Some(srv.shared.credentials.catalog()), + ) + .expect("grant collection read"); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + + let response = post_query(&http, &token, "SELECT * FROM granted_rows").await; + + assert_eq!( + response.status(), + reqwest::StatusCode::OK, + "HTTP authorization must honor PermissionStore grants before built-in role fallback" + ); +} diff --git a/nodedb/tests/http_query_stream_authorization.rs b/nodedb/tests/http_query_stream_authorization.rs new file mode 100644 index 000000000..66a0bcc4a --- /dev/null +++ b/nodedb/tests/http_query_stream_authorization.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Authorization parity for lazy NDJSON HTTP SQL queries. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::pgwire_harness::TestServer; +use nodedb::config::auth::AuthMode; +use nodedb::control::security::apikey::CreateKeyParams; +use nodedb::control::security::identity::{Permission, Role}; +use nodedb::control::security::permission::collection_target; +use nodedb::control::state::SharedState; +use nodedb::types::{DatabaseId, TenantId}; + +struct AuthenticatedHttpEndpoint { + local_addr: std::net::SocketAddr, + _server: tokio::task::JoinHandle<()>, +} + +async fn start_authenticated_http(shared: Arc) -> AuthenticatedHttpEndpoint { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind authenticated HTTP listener"); + let local_addr = listener.local_addr().expect("authenticated HTTP address"); + let (bus, _) = nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown)); + let handle = tokio::spawn(async move { + nodedb::control::server::http::server::run_with_listener( + listener, + shared, + AuthMode::Password, + None, + bus, + ) + .await + .expect("authenticated HTTP server"); + }); + tokio::time::sleep(Duration::from_millis(40)).await; + + AuthenticatedHttpEndpoint { + local_addr, + _server: handle, + } +} + +fn create_api_key(shared: &SharedState, username: &str, roles: Vec) -> String { + let user_id = shared + .credentials + .create_service_account(username, TenantId::new(1), roles, vec![DatabaseId::DEFAULT]) + .expect("create database-scoped service account"); + shared + .api_keys + .create_key( + CreateKeyParams { + username, + user_id, + tenant_id: TenantId::new(1), + expires_secs: 0, + scope: vec![], + accessible_databases: vec![DatabaseId::DEFAULT], + }, + Some(shared.credentials.catalog()), + ) + .expect("create API key") +} + +async fn post_query( + http: &AuthenticatedHttpEndpoint, + token: &str, + path: &str, + sql: &str, +) -> reqwest::Response { + reqwest::Client::new() + .post(format!("http://{}{}", http.local_addr, path)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({"sql": sql})) + .send() + .await + .expect("POST authenticated streaming query") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_stream_rejects_database_outside_api_key_scope_before_lazy_execution() { + let srv = TestServer::start().await; + srv.exec("CREATE DATABASE private_stream_db") + .await + .expect("create inaccessible database"); + srv.exec("USE DATABASE private_stream_db") + .await + .expect("switch to inaccessible database as superuser"); + srv.exec("CREATE COLLECTION private_rows") + .await + .expect("create private collection"); + srv.exec("INSERT INTO private_rows { id: 'secret', value: 7 }") + .await + .expect("seed private collection"); + srv.exec("USE DATABASE default") + .await + .expect("return to default database"); + + let token = create_api_key(&srv.shared, "http_stream_reader", vec![Role::ReadOnly]); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "/v1/query/stream?database=private_stream_db", + "SELECT * FROM private_rows", + ) + .await; + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "NDJSON authorization must reject the database before opening a lazy result stream" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_stream_rejects_system_catalog_for_non_superuser() { + let srv = TestServer::start().await; + let token = create_api_key( + &srv.shared, + "http_stream_catalog_reader", + vec![Role::ReadOnly], + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + + let response = post_query( + &http, + &token, + "/v1/query/stream", + "SELECT * FROM _system.audit_log", + ) + .await; + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "system catalog access must be denied before NDJSON materialization or streaming" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_stream_rejects_join_when_only_one_collection_is_granted() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION granted_join_rows") + .await + .expect("create granted join collection"); + srv.exec("CREATE COLLECTION denied_join_rows") + .await + .expect("create denied join collection"); + srv.exec("INSERT INTO granted_join_rows { id: 'shared', value: 1 }") + .await + .expect("seed granted join collection"); + srv.exec("INSERT INTO denied_join_rows { id: 'shared', secret: 2 }") + .await + .expect("seed denied join collection"); + + let username = "http_partial_join_reader"; + let token = create_api_key( + &srv.shared, + username, + vec![Role::Custom("http_partial_join_role".into())], + ); + srv.shared + .permissions + .grant( + &collection_target(TenantId::new(1), "granted_join_rows"), + &format!("user:{username}"), + Permission::Read, + "nodedb", + Some(srv.shared.credentials.catalog()), + ) + .expect("grant only the left join collection"); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "/v1/query/stream", + "SELECT g.id FROM granted_join_rows g JOIN denied_join_rows d ON g.id = d.id", + ) + .await; + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "every collection referenced by a multi-resource plan must be authorized" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_stream_rejects_collection_without_permission() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION denied_stream_rows") + .await + .expect("create denied collection"); + srv.exec("INSERT INTO denied_stream_rows { id: 'hidden', value: 11 }") + .await + .expect("seed denied collection"); + + let token = create_api_key( + &srv.shared, + "http_ungranted_reader", + vec![Role::Custom("http_ungranted_role".into())], + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "/v1/query/stream", + "SELECT * FROM denied_stream_rows", + ) + .await; + + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "an unauthorized lazy scan must fail before response headers are committed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_stream_rejects_write_without_permission_or_mutation() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION denied_stream_writes") + .await + .expect("create denied write collection"); + let token = create_api_key( + &srv.shared, + "http_ungranted_writer", + vec![Role::Custom("http_ungranted_writer_role".into())], + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "/v1/query/stream", + "INSERT INTO denied_stream_writes { id: 'forbidden', value: 19 }", + ) + .await; + let rows = srv + .query_text("SELECT id FROM denied_stream_writes") + .await + .expect("query denied write collection"); + + assert!( + rows.is_empty(), + "an unauthorized NDJSON write must not mutate the collection: {rows:?}" + ); + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "NDJSON writes require an explicit write permission before dispatch" + ); +} diff --git a/nodedb/tests/http_ws_authorization.rs b/nodedb/tests/http_ws_authorization.rs new file mode 100644 index 000000000..129fbaada --- /dev/null +++ b/nodedb/tests/http_ws_authorization.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Authorization parity for SQL executed through WebSocket RPC. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::pgwire_harness::TestServer; +use futures::{SinkExt, StreamExt}; +use nodedb::config::auth::AuthMode; +use nodedb::control::security::apikey::CreateKeyParams; +use nodedb::control::security::identity::Role; +use nodedb::control::state::SharedState; +use nodedb::types::{DatabaseId, TenantId}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::{Message, http}; + +struct AuthenticatedWsEndpoint { + local_addr: std::net::SocketAddr, + _server: tokio::task::JoinHandle<()>, +} + +async fn start_authenticated_ws(shared: Arc) -> AuthenticatedWsEndpoint { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind authenticated WebSocket listener"); + let local_addr = listener.local_addr().expect("authenticated WS address"); + let (bus, _) = nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown)); + let handle = tokio::spawn(async move { + nodedb::control::server::http::server::run_with_listener( + listener, + shared, + AuthMode::Password, + None, + bus, + ) + .await + .expect("authenticated WebSocket server"); + }); + tokio::time::sleep(Duration::from_millis(40)).await; + AuthenticatedWsEndpoint { + local_addr, + _server: handle, + } +} + +fn create_ws_api_key(shared: &SharedState, username: &str) -> String { + let user_id = shared + .credentials + .create_service_account( + username, + TenantId::new(1), + vec![Role::Custom(format!("{username}_role"))], + vec![DatabaseId::DEFAULT], + ) + .expect("create WebSocket service account"); + shared + .api_keys + .create_key( + CreateKeyParams { + username, + user_id, + tenant_id: TenantId::new(1), + expires_secs: 0, + scope: vec![], + accessible_databases: vec![DatabaseId::DEFAULT], + }, + Some(shared.credentials.catalog()), + ) + .expect("create WebSocket API key") +} + +async fn ws_query(endpoint: &AuthenticatedWsEndpoint, token: &str, sql: &str) -> serde_json::Value { + let mut request = format!("ws://{}/v1/ws", endpoint.local_addr) + .into_client_request() + .expect("WebSocket request"); + request.headers_mut().insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_str(&format!("Bearer {token}")).expect("authorization header"), + ); + let (mut ws, _) = tokio_tungstenite::connect_async(request) + .await + .expect("authenticated WebSocket connect"); + ws.send(Message::Text( + serde_json::json!({ + "id": 77, + "method": "query", + "params": {"sql": sql} + }) + .to_string() + .into(), + )) + .await + .expect("send WebSocket query"); + let message = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .expect("timeout waiting for WS query response") + .expect("WebSocket stream ended") + .expect("WebSocket response error"); + let Message::Text(text) = message else { + panic!("expected WebSocket text response, got {message:?}"); + }; + sonic_rs::from_str(&text).expect("valid WebSocket JSON response") +} + +fn assert_permission_denied(response: &serde_json::Value, context: &str) { + let error = response + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!( + error.to_ascii_lowercase().contains("permission denied"), + "{context} must return an authorization denial: {response}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ws_query_rejects_system_catalog_for_non_superuser() { + let srv = TestServer::start().await; + let token = create_ws_api_key(&srv.shared, "ws_catalog_reader"); + let endpoint = start_authenticated_ws(Arc::clone(&srv.shared)).await; + + let response = ws_query(&endpoint, &token, "SELECT * FROM _system.audit_log").await; + + assert_permission_denied( + &response, + "WebSocket system-catalog access for a non-superuser", + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ws_query_rejects_write_without_permission_or_mutation() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION ws_denied_writes") + .await + .expect("create denied WebSocket write collection"); + let token = create_ws_api_key(&srv.shared, "ws_ungranted_writer"); + let endpoint = start_authenticated_ws(Arc::clone(&srv.shared)).await; + + let response = ws_query( + &endpoint, + &token, + "INSERT INTO ws_denied_writes { id: 'forbidden', value: 17 }", + ) + .await; + let rows = srv + .query_text("SELECT id FROM ws_denied_writes") + .await + .expect("query denied WebSocket write collection"); + + assert!( + rows.is_empty(), + "an unauthorized WebSocket write must not mutate the collection: {rows:?}" + ); + assert_permission_denied(&response, "WebSocket write without a PermissionStore grant"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ws_query_rejects_collection_without_permission() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION ws_private_rows") + .await + .expect("create private WebSocket collection"); + srv.exec("INSERT INTO ws_private_rows { id: 'hidden', value: 13 }") + .await + .expect("seed private WebSocket collection"); + let token = create_ws_api_key(&srv.shared, "ws_ungranted_reader"); + let endpoint = start_authenticated_ws(Arc::clone(&srv.shared)).await; + + let response = ws_query(&endpoint, &token, "SELECT * FROM ws_private_rows").await; + + assert_permission_denied( + &response, + "WebSocket collection read without a PermissionStore grant", + ); +} diff --git a/nodedb/tests/native_sql_authorization.rs b/nodedb/tests/native_sql_authorization.rs new file mode 100644 index 000000000..4be124392 --- /dev/null +++ b/nodedb/tests/native_sql_authorization.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Authorization parity for native-protocol SQL execution. +//! +//! Native materialized and lazy SQL paths must apply the same collection- +//! permission gates as pgwire before dispatch. + +mod common; + +use common::native_harness::{NativeTestServer, do_handshake, send_api_key_auth, send_sql}; +use nodedb::control::security::apikey::CreateKeyParams; +use nodedb::control::security::identity::Role; +use nodedb::control::state::SharedState; +use nodedb::types::{DatabaseId, TenantId}; +use nodedb_types::protocol::HelloFrame; +use nodedb_types::protocol::opcodes::ResponseStatus; + +fn create_api_key(shared: &SharedState, username: &str, roles: Vec) -> String { + let user_id = if username == "nodedb" { + shared + .credentials + .get_user(username) + .expect("harness superuser") + .user_id + } else { + shared + .credentials + .create_service_account(username, TenantId::new(1), roles, vec![DatabaseId::DEFAULT]) + .expect("create native service account") + }; + shared + .api_keys + .create_key( + CreateKeyParams { + username, + user_id, + tenant_id: TenantId::new(1), + expires_secs: 0, + scope: vec![], + accessible_databases: vec![DatabaseId::DEFAULT], + }, + Some(shared.credentials.catalog()), + ) + .expect("create native API key") +} + +async fn authenticated_stream(server: &NativeTestServer, token: String) -> tokio::net::TcpStream { + let (mut stream, _) = do_handshake(server.addr, &HelloFrame::current()) + .await + .expect("native handshake"); + let auth = send_api_key_auth(&mut stream, 1, token).await; + assert_eq!(auth.status, ResponseStatus::Ok, "native API key auth"); + stream +} + +async fn seed_private_collection(server: &NativeTestServer, collection: &str) { + let admin_token = create_api_key(&server.shared, "nodedb", vec![Role::Superuser]); + let mut admin = authenticated_stream(server, admin_token).await; + let create = send_sql(&mut admin, 2, &format!("CREATE COLLECTION {collection}")).await; + assert_eq!( + create.status, + ResponseStatus::Ok, + "create private collection" + ); + let insert = send_sql( + &mut admin, + 3, + &format!("INSERT INTO {collection} {{ id: 'hidden', value: 17 }}"), + ) + .await; + assert_eq!(insert.status, ResponseStatus::Ok, "seed private collection"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn native_materialized_sql_rejects_collection_without_permission() { + let server = NativeTestServer::start_authenticated().await; + seed_private_collection(&server, "native_materialized_private").await; + let token = create_api_key( + &server.shared, + "native_materialized_reader", + vec![Role::Custom("native_materialized_role".into())], + ); + let mut stream = authenticated_stream(&server, token).await; + + let response = send_sql( + &mut stream, + 2, + "SELECT * FROM native_materialized_private ORDER BY id", + ) + .await; + drop(stream); + server.shutdown().await; + + assert_eq!( + response.status, + ResponseStatus::Error, + "native materialized SQL must enforce PermissionStore before dispatch" + ); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("42501"), + "native materialized denial must report insufficient privilege: {response:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn native_materialized_sql_rejects_write_without_permission_or_mutation() { + let server = NativeTestServer::start_authenticated().await; + let admin_token = create_api_key(&server.shared, "nodedb", vec![Role::Superuser]); + let mut admin = authenticated_stream(&server, admin_token).await; + let create = send_sql(&mut admin, 2, "CREATE COLLECTION native_denied_writes").await; + assert_eq!(create.status, ResponseStatus::Ok, "create write target"); + + let token = create_api_key( + &server.shared, + "native_ungranted_writer", + vec![Role::Custom("native_ungranted_writer_role".into())], + ); + let mut restricted = authenticated_stream(&server, token).await; + let response = send_sql( + &mut restricted, + 2, + "INSERT INTO native_denied_writes { id: 'forbidden', value: 23 }", + ) + .await; + let observed = send_sql( + &mut admin, + 3, + "SELECT id FROM native_denied_writes ORDER BY id", + ) + .await; + drop(restricted); + drop(admin); + server.shutdown().await; + + assert!( + observed.rows.as_ref().is_some_and(Vec::is_empty), + "an unauthorized native write must not mutate the collection: {observed:?}" + ); + assert_eq!( + response.status, + ResponseStatus::Error, + "native writes require explicit PermissionStore authorization before dispatch" + ); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("42501"), + "native write denial must report insufficient privilege: {response:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn native_lazy_sql_rejects_collection_without_permission_before_stream_open() { + let server = NativeTestServer::start_authenticated().await; + seed_private_collection(&server, "native_lazy_private").await; + let token = create_api_key( + &server.shared, + "native_lazy_reader", + vec![Role::Custom("native_lazy_role".into())], + ); + let mut stream = authenticated_stream(&server, token).await; + + let response = send_sql(&mut stream, 2, "SELECT * FROM native_lazy_private").await; + drop(stream); + server.shutdown().await; + + assert_eq!( + response.status, + ResponseStatus::Error, + "native lazy SQL must reject access before opening the result stream" + ); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("42501"), + "native lazy denial must report insufficient privilege: {response:?}" + ); +} From b9f373bf6c997d4044a7a72309ff477a105d4187 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 13:36:06 +0000 Subject: [PATCH 2/6] ci: annotate ephemeral Calvin deadlines --- .../src/control/cluster/calvin/scheduler/driver/core/request.rs | 1 + nodedb/src/data/executor/handlers/transaction/sub_plan.rs | 1 + .../data/executor/handlers/transaction/undo/fts_strict_tests.rs | 1 + .../src/data/executor/handlers/transaction/undo/parity_tests.rs | 1 + 4 files changed, 4 insertions(+) diff --git a/nodedb/src/control/cluster/calvin/scheduler/driver/core/request.rs b/nodedb/src/control/cluster/calvin/scheduler/driver/core/request.rs index badc9b816..57e22d104 100644 --- a/nodedb/src/control/cluster/calvin/scheduler/driver/core/request.rs +++ b/nodedb/src/control/cluster/calvin/scheduler/driver/core/request.rs @@ -31,6 +31,7 @@ impl Scheduler { database_id: DatabaseId::DEFAULT, vshard_id: VShardId::new(self.vshard_id), plan, + // no-determinism: scheduler deadline controls waiting, not ordered state. deadline: Instant::now() + Duration::from_millis( self.config.epoch_duration_ms * u64::from(self.config.txn_deadline_multiplier), diff --git a/nodedb/src/data/executor/handlers/transaction/sub_plan.rs b/nodedb/src/data/executor/handlers/transaction/sub_plan.rs index cf645bd7e..f465d9f74 100644 --- a/nodedb/src/data/executor/handlers/transaction/sub_plan.rs +++ b/nodedb/src/data/executor/handlers/transaction/sub_plan.rs @@ -75,6 +75,7 @@ impl CoreLoop { plan: PhysicalPlan::Meta(MetaOp::Cancel { target_request_id: crate::types::RequestId::new(0), }), + // no-determinism: ephemeral deadline is not written to Calvin state. deadline: std::time::Instant::now() + std::time::Duration::from_secs(60), priority: crate::bridge::envelope::Priority::Normal, trace_id: TraceId::ZERO, diff --git a/nodedb/src/data/executor/handlers/transaction/undo/fts_strict_tests.rs b/nodedb/src/data/executor/handlers/transaction/undo/fts_strict_tests.rs index 2ace68062..cd40d5331 100644 --- a/nodedb/src/data/executor/handlers/transaction/undo/fts_strict_tests.rs +++ b/nodedb/src/data/executor/handlers/transaction/undo/fts_strict_tests.rs @@ -86,6 +86,7 @@ fn dummy_task() -> ExecutionTask { system_time: nodedb_types::SystemTimeScope::Current, valid_at_ms: None, }), + // no-determinism: test-only deadline is not written to Calvin state. deadline: Instant::now() + Duration::from_secs(30), priority: Priority::Normal, trace_id: TraceId::ZERO, diff --git a/nodedb/src/data/executor/handlers/transaction/undo/parity_tests.rs b/nodedb/src/data/executor/handlers/transaction/undo/parity_tests.rs index ff40f3070..9df32e33d 100644 --- a/nodedb/src/data/executor/handlers/transaction/undo/parity_tests.rs +++ b/nodedb/src/data/executor/handlers/transaction/undo/parity_tests.rs @@ -224,6 +224,7 @@ fn dummy_task() -> ExecutionTask { system_time: nodedb_types::SystemTimeScope::Current, valid_at_ms: None, }), + // no-determinism: test-only deadline is not written to Calvin state. deadline: Instant::now() + Duration::from_secs(30), priority: Priority::Normal, trace_id: TraceId::ZERO, From dd911abe35180a3116a9e808196a79eed38d165d Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 13:54:10 +0000 Subject: [PATCH 3/6] chore: ignore subagent runtime artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e233c4b39..a0fe3a850 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ dmypy.json .cargo/ .claude/prep/ +.pi-subagents From f89f388952effeea6979f57d7d6e19075fedc498 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 14:58:33 +0000 Subject: [PATCH 4/6] feat(security): centralize SQL task authorization --- .../server/shared/authorization/error.rs | 36 +++ .../server/shared/authorization/mod.rs | 9 + .../shared/authorization/requirements.rs | 47 ++++ .../authorization/requirements/collect.rs | 221 +++++++++++++++++ .../authorization/requirements/order.rs | 47 ++++ .../authorization/requirements/query.rs | 105 ++++++++ .../authorization/requirements/tests.rs | 92 +++++++ .../server/shared/authorization/service.rs | 229 ++++++++++++++++++ .../shared/authorization/service/tests.rs | 119 +++++++++ nodedb/src/control/server/shared/mod.rs | 1 + 10 files changed, 906 insertions(+) create mode 100644 nodedb/src/control/server/shared/authorization/error.rs create mode 100644 nodedb/src/control/server/shared/authorization/mod.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements/collect.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements/order.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements/query.rs create mode 100644 nodedb/src/control/server/shared/authorization/requirements/tests.rs create mode 100644 nodedb/src/control/server/shared/authorization/service.rs create mode 100644 nodedb/src/control/server/shared/authorization/service/tests.rs diff --git a/nodedb/src/control/server/shared/authorization/error.rs b/nodedb/src/control/server/shared/authorization/error.rs new file mode 100644 index 000000000..b3803f385 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/error.rs @@ -0,0 +1,36 @@ +//! Transport-neutral authorization failures. + +use crate::types::TenantId; + +/// A safe, transport-neutral authorization denial. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationError { + tenant_id: TenantId, + resource: String, +} + +impl AuthorizationError { + pub fn new(tenant_id: TenantId, resource: impl Into) -> Self { + Self { + tenant_id, + resource: resource.into(), + } + } + + pub fn tenant_id(&self) -> TenantId { + self.tenant_id + } + + pub fn resource(&self) -> &str { + &self.resource + } +} + +impl From for crate::Error { + fn from(error: AuthorizationError) -> Self { + Self::RejectedAuthz { + tenant_id: error.tenant_id, + resource: error.resource, + } + } +} diff --git a/nodedb/src/control/server/shared/authorization/mod.rs b/nodedb/src/control/server/shared/authorization/mod.rs new file mode 100644 index 000000000..d3a9d5d86 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/mod.rs @@ -0,0 +1,9 @@ +//! Protocol-neutral authorization for SQL physical tasks. + +pub mod error; +pub mod requirements; +pub mod service; + +pub use error::AuthorizationError; +pub use requirements::{AuthorizationRequirement, plan_requirements}; +pub use service::{authorize_database, authorize_task_set}; diff --git a/nodedb/src/control/server/shared/authorization/requirements.rs b/nodedb/src/control/server/shared/authorization/requirements.rs new file mode 100644 index 000000000..d275726b9 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/requirements.rs @@ -0,0 +1,47 @@ +//! Physical-plan authorization requirements. +//! +//! This deliberately does not use `shared::plan_util::extract_collection`: a +//! single collection cannot represent joins or source/target DML correctly. + +#![deny(clippy::wildcard_enum_match_arm)] + +use crate::control::security::identity::Permission; + +mod collect; +mod order; +mod query; + +pub use collect::plan_requirements; + +/// A protected resource and the permission needed to use it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AuthorizationRequirement { + /// A collection-scoped operation. The name is the physical-plan name and + /// may be database-qualified; the authorization service normalizes it to + /// the grant-store name before looking up grants. + Collection { + collection: String, + permission: Permission, + }, + /// An operation with no collection-level resource, such as an array or a + /// tenant-wide maintenance action. It must still be authorized at tenant + /// scope rather than silently allowed. + Tenant { permission: Permission }, +} + +impl AuthorizationRequirement { + fn collection(collection: impl Into, permission: Permission) -> Self { + Self::Collection { + collection: collection.into(), + permission, + } + } + + fn tenant(permission: Permission) -> Self { + Self::Tenant { permission } + } +} + +#[cfg(test)] +#[path = "requirements/tests.rs"] +mod tests; diff --git a/nodedb/src/control/server/shared/authorization/requirements/collect.rs b/nodedb/src/control/server/shared/authorization/requirements/collect.rs new file mode 100644 index 000000000..d116cf823 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/requirements/collect.rs @@ -0,0 +1,221 @@ +use crate::bridge::envelope::PhysicalPlan; +use crate::control::gateway::version_set::touched_collections; +use crate::control::security::identity::{Permission, required_permission}; + +use super::AuthorizationRequirement; +use super::order::requirement_order; +use super::query::collect_query_requirements; + +/// Return every collection requirement for `plan`. +/// +/// The general plan permission applies to ordinary single-resource plans. The +/// multi-resource cases below intentionally override it so sources require +/// `Read` while targets require `Write`. Nested query inputs are traversed +/// iteratively in addition to their named collection fields. +pub fn plan_requirements(plan: &PhysicalPlan) -> Vec { + let mut requirements = Vec::new(); + collect_requirements(plan, &mut requirements); + requirements.sort_by(requirement_order); + requirements.dedup(); + requirements +} + +fn collect_requirements(plan: &PhysicalPlan, out: &mut Vec) { + use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, GraphOp, KvOp, MetaOp, SpatialOp}; + + let mut pending = vec![plan]; + while let Some(plan) = pending.pop() { + let initial_len = out.len(); + match plan { + PhysicalPlan::Document(DocumentOp::InsertSelect { + target_collection, + source_collection, + .. + }) + | PhysicalPlan::Document(DocumentOp::UpdateFromJoin { + target_collection, + source_collection, + .. + }) + | PhysicalPlan::Document(DocumentOp::Merge { + target_collection, + source_collection, + .. + }) => { + out.push(AuthorizationRequirement::collection( + target_collection, + Permission::Write, + )); + out.push(AuthorizationRequirement::collection( + source_collection, + Permission::Read, + )); + } + PhysicalPlan::Kv(KvOp::TransferItem { + source_collection, + dest_collection, + .. + }) => { + out.push(AuthorizationRequirement::collection( + source_collection, + Permission::Read, + )); + out.push(AuthorizationRequirement::collection( + dest_collection, + Permission::Write, + )); + } + PhysicalPlan::Query(_) | PhysicalPlan::Vector(_) => { + if !collect_query_requirements(plan, &mut pending, out) { + add_general_requirements(plan, out); + } + } + PhysicalPlan::Spatial(SpatialOp::Insert { collection, .. }) + | PhysicalPlan::Spatial(SpatialOp::Delete { collection, .. }) + | PhysicalPlan::Crdt( + CrdtOp::ImportSnapshot { collection, .. } + | CrdtOp::SetConstraints { collection, .. } + | CrdtOp::DropConstraints { collection, .. } + | CrdtOp::ReadConstraints { collection, .. } + | CrdtOp::GetVersionVector { collection, .. } + | CrdtOp::ExportDelta { collection, .. } + | CrdtOp::CompactAtVersion { collection, .. }, + ) => add_collection_requirement(collection, required_permission(plan), out), + PhysicalPlan::Graph(GraphOp::EdgePut { collection, .. }) + | PhysicalPlan::Graph(GraphOp::EdgeDelete { collection, .. }) => { + add_collection_requirement(collection, required_permission(plan), out); + } + PhysicalPlan::Graph(GraphOp::EdgePutBatch { edges }) + | PhysicalPlan::Graph(GraphOp::EdgeDeleteBatch { edges }) => { + let permission = required_permission(plan); + for edge in edges { + add_collection_requirement(&edge.collection, permission, out); + } + } + PhysicalPlan::Meta( + MetaOp::ConvertCollection { collection, .. } + | MetaOp::EnforceTimeseriesRetention { collection, .. } + | MetaOp::TemporalPurgeEdgeStore { collection, .. } + | MetaOp::TemporalPurgeDocumentStrict { collection, .. } + | MetaOp::TemporalPurgeColumnar { collection, .. } + | MetaOp::TemporalPurgeCrdt { collection, .. } + | MetaOp::QueryLastValues { collection } + | MetaOp::QueryLastValue { collection, .. } + | MetaOp::RebuildIndex { collection, .. }, + ) => add_collection_requirement(collection, required_permission(plan), out), + PhysicalPlan::Meta( + MetaOp::UnregisterCollection { name, .. } + | MetaOp::UnregisterMaterializedView { name, .. } + | MetaOp::QueryCollectionSize { name, .. }, + ) => add_collection_requirement(name, required_permission(plan), out), + PhysicalPlan::Meta(MetaOp::RenameCollection { + old_collection, + new_collection, + .. + }) => { + let permission = required_permission(plan); + add_collection_requirement(old_collection, permission, out); + add_collection_requirement(new_collection, permission, out); + } + PhysicalPlan::Meta(MetaOp::TransactionBatch { plans, .. }) + | PhysicalPlan::Meta(MetaOp::ResolveTxn { plans, .. }) + | PhysicalPlan::Meta(MetaOp::RecordCalvinWriteVersions { plans, .. }) + | PhysicalPlan::Meta(MetaOp::CalvinExecuteStatic { plans, .. }) + | PhysicalPlan::Meta(MetaOp::CalvinExecuteActive { plans, .. }) => { + add_tenant_requirement(required_permission(plan), out); + for nested in plans { + pending.push(nested); + } + } + PhysicalPlan::Meta(MetaOp::StageWrite { plan: nested }) => { + add_tenant_requirement(required_permission(plan), out); + pending.push(nested); + } + PhysicalPlan::Kv(KvOp::Transfer { collection, .. }) => { + out.push(AuthorizationRequirement::collection( + collection, + Permission::Write, + )); + } + PhysicalPlan::Kv( + KvOp::Get { .. } + | KvOp::Put { .. } + | KvOp::Insert { .. } + | KvOp::InsertIfAbsent { .. } + | KvOp::InsertOnConflictUpdate { .. } + | KvOp::Delete { .. } + | KvOp::Scan { .. } + | KvOp::Expire { .. } + | KvOp::Persist { .. } + | KvOp::GetTtl { .. } + | KvOp::BatchGet { .. } + | KvOp::BatchPut { .. } + | KvOp::RegisterIndex { .. } + | KvOp::DropIndex { .. } + | KvOp::FieldGet { .. } + | KvOp::FieldSet { .. } + | KvOp::Truncate { .. } + | KvOp::Incr { .. } + | KvOp::IncrFloat { .. } + | KvOp::Cas { .. } + | KvOp::GetSet { .. } + | KvOp::RegisterSortedIndex { .. } + | KvOp::DropSortedIndex { .. } + | KvOp::SortedIndexRank { .. } + | KvOp::SortedIndexTopK { .. } + | KvOp::SortedIndexRange { .. } + | KvOp::SortedIndexCount { .. } + | KvOp::SortedIndexScore { .. } + | KvOp::MaterializeScan { .. }, + ) => add_general_requirements(plan, out), + PhysicalPlan::Document(_) + | PhysicalPlan::Graph(_) + | PhysicalPlan::Text(_) + | PhysicalPlan::Columnar(_) + | PhysicalPlan::Timeseries(_) + | PhysicalPlan::Spatial(_) + | PhysicalPlan::Crdt(_) + | PhysicalPlan::Meta(_) + | PhysicalPlan::Array(_) + | PhysicalPlan::ClusterArray(_) => add_general_requirements(plan, out), + } + + // A plan without a collection name is not public: it still needs the + // permission implied by its physical operation at tenant scope. This also + // covers provider-materialized and array-backed plans. + if out.len() == initial_len { + add_tenant_requirement(required_permission(plan), out); + } + } +} + +pub(super) fn add_general_requirements( + plan: &PhysicalPlan, + out: &mut Vec, +) { + let permission = required_permission(plan); + for collection in touched_collections(plan) { + out.push(AuthorizationRequirement::collection(collection, permission)); + } +} + +pub(super) fn add_collection_requirement( + collection: &str, + permission: Permission, + out: &mut Vec, +) { + if !collection.is_empty() { + out.push(AuthorizationRequirement::collection(collection, permission)); + } +} + +pub(super) fn add_tenant_requirement( + permission: Permission, + out: &mut Vec, +) { + out.push(AuthorizationRequirement::tenant(permission)); +} + +pub(super) fn add_read(collection: &str, out: &mut Vec) { + add_collection_requirement(collection, Permission::Read, out); +} diff --git a/nodedb/src/control/server/shared/authorization/requirements/order.rs b/nodedb/src/control/server/shared/authorization/requirements/order.rs new file mode 100644 index 000000000..1104da4f6 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/requirements/order.rs @@ -0,0 +1,47 @@ +use crate::control::security::identity::Permission; + +use super::AuthorizationRequirement; + +pub(super) fn requirement_order( + left: &AuthorizationRequirement, + right: &AuthorizationRequirement, +) -> std::cmp::Ordering { + match (left, right) { + ( + AuthorizationRequirement::Tenant { permission: left }, + AuthorizationRequirement::Tenant { permission: right }, + ) => permission_rank(*left).cmp(&permission_rank(*right)), + (AuthorizationRequirement::Tenant { .. }, AuthorizationRequirement::Collection { .. }) => { + std::cmp::Ordering::Less + } + (AuthorizationRequirement::Collection { .. }, AuthorizationRequirement::Tenant { .. }) => { + std::cmp::Ordering::Greater + } + ( + AuthorizationRequirement::Collection { + collection: left_collection, + permission: left_permission, + }, + AuthorizationRequirement::Collection { + collection: right_collection, + permission: right_permission, + }, + ) => left_collection.cmp(right_collection).then_with(|| { + permission_rank(*left_permission).cmp(&permission_rank(*right_permission)) + }), + } +} + +fn permission_rank(permission: Permission) -> u8 { + match permission { + Permission::Read => 0, + Permission::Write => 1, + Permission::Create => 2, + Permission::Drop => 3, + Permission::Alter => 4, + Permission::Admin => 5, + Permission::Monitor => 6, + Permission::Execute => 7, + Permission::Backup => 8, + } +} diff --git a/nodedb/src/control/server/shared/authorization/requirements/query.rs b/nodedb/src/control/server/shared/authorization/requirements/query.rs new file mode 100644 index 000000000..5e2c2154b --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/requirements/query.rs @@ -0,0 +1,105 @@ +use crate::bridge::envelope::PhysicalPlan; + +use super::AuthorizationRequirement; +use super::collect::{add_general_requirements, add_read}; + +pub(super) fn collect_query_requirements<'a>( + plan: &'a PhysicalPlan, + pending: &mut Vec<&'a PhysicalPlan>, + out: &mut Vec, +) -> bool { + use nodedb_physical::physical_plan::{QueryOp, VectorOp}; + + match plan { + PhysicalPlan::Query(QueryOp::HashJoin { + left_collection, + right_collection, + left_input, + right_input, + left_bitmap, + right_bitmap, + .. + }) => { + add_read(left_collection, out); + add_read(right_collection, out); + for nested in [left_input, right_input, left_bitmap, right_bitmap] + .into_iter() + .flatten() + { + pending.push(nested); + } + true + } + PhysicalPlan::Query(QueryOp::NestedLoopJoin { + left_collection, + right_collection, + .. + }) + | PhysicalPlan::Query(QueryOp::SortMergeJoin { + left_collection, + right_collection, + .. + }) => { + add_read(left_collection, out); + add_read(right_collection, out); + true + } + PhysicalPlan::Query(QueryOp::Aggregate { input, .. }) + | PhysicalPlan::Query(QueryOp::PartialAggregateState { input, .. }) => { + add_general_requirements(plan, out); + if let Some(nested) = input { + pending.push(nested); + } + true + } + PhysicalPlan::Query(QueryOp::LateralTopK { + outer_plan, + inner_collection, + .. + }) + | PhysicalPlan::Query(QueryOp::LateralLoop { + outer_plan, + inner_collection, + .. + }) => { + add_read(inner_collection, out); + pending.push(outer_plan); + true + } + PhysicalPlan::Query(QueryOp::Exchange(exchange)) => { + pending.push(&exchange.child); + true + } + PhysicalPlan::Query(QueryOp::ProviderScan { + provider: Some(provider), + .. + }) => { + add_read(provider, out); + true + } + PhysicalPlan::Query(QueryOp::ProviderScan { provider: None, .. }) => true, + PhysicalPlan::Vector(VectorOp::Search { + inline_prefilter_plan, + .. + }) => { + add_general_requirements(plan, out); + if let Some(nested) = inline_prefilter_plan { + pending.push(nested); + } + true + } + PhysicalPlan::Vector(_) + | PhysicalPlan::Graph(_) + | PhysicalPlan::Document(_) + | PhysicalPlan::Kv(_) + | PhysicalPlan::Text(_) + | PhysicalPlan::Columnar(_) + | PhysicalPlan::Timeseries(_) + | PhysicalPlan::Spatial(_) + | PhysicalPlan::Crdt(_) + | PhysicalPlan::Query(_) + | PhysicalPlan::Meta(_) + | PhysicalPlan::Array(_) + | PhysicalPlan::ClusterArray(_) => false, + } +} diff --git a/nodedb/src/control/server/shared/authorization/requirements/tests.rs b/nodedb/src/control/server/shared/authorization/requirements/tests.rs new file mode 100644 index 000000000..8ff9c6d0f --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/requirements/tests.rs @@ -0,0 +1,92 @@ +use super::*; +use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, KvOp, MetaOp, QueryOp}; + +#[test] +fn insert_select_requires_source_read_and_target_write() { + let plan = crate::bridge::envelope::PhysicalPlan::Document(DocumentOp::InsertSelect { + target_collection: "target".into(), + source_collection: "source".into(), + source_filters: Vec::new(), + source_limit: 0, + }); + + assert_eq!( + plan_requirements(&plan), + vec![ + AuthorizationRequirement::collection("source", Permission::Read), + AuthorizationRequirement::collection("target", Permission::Write), + ] + ); +} + +#[test] +fn provider_scan_preserves_only_named_provider() { + let named = crate::bridge::envelope::PhysicalPlan::Query(QueryOp::ProviderScan { + provider: Some("_system.audit_log".into()), + rows: Vec::new(), + filters: Vec::new(), + projection: Vec::new(), + sort_keys: Vec::new(), + limit: None, + offset: 0, + distinct: false, + }); + let materialized = crate::bridge::envelope::PhysicalPlan::Query(QueryOp::ProviderScan { + provider: None, + rows: Vec::new(), + filters: Vec::new(), + projection: Vec::new(), + sort_keys: Vec::new(), + limit: None, + offset: 0, + distinct: false, + }); + + assert_eq!( + plan_requirements(&named), + vec![AuthorizationRequirement::collection( + "_system.audit_log", + Permission::Read, + )] + ); + assert_eq!( + plan_requirements(&materialized), + vec![AuthorizationRequirement::tenant(Permission::Read)] + ); +} + +#[test] +fn crdt_constraint_reads_remain_collection_scoped() { + let plan = crate::bridge::envelope::PhysicalPlan::Crdt(CrdtOp::ReadConstraints { + collection: "documents".into(), + }); + + assert_eq!( + plan_requirements(&plan), + vec![AuthorizationRequirement::collection( + "documents", + Permission::Read, + )] + ); +} + +#[test] +fn transaction_batch_checks_its_tenant_scope_and_nested_resources() { + let plan = crate::bridge::envelope::PhysicalPlan::Meta(MetaOp::TransactionBatch { + plans: vec![crate::bridge::envelope::PhysicalPlan::Kv(KvOp::Get { + collection: "orders".into(), + key: Vec::new(), + rls_filters: Vec::new(), + surrogate_ceiling: None, + })], + txn_id: None, + }); + + assert_eq!( + plan_requirements(&plan), + vec![ + AuthorizationRequirement::tenant(Permission::Write), + AuthorizationRequirement::collection("orders", Permission::Read), + ] + ); +} diff --git a/nodedb/src/control/server/shared/authorization/service.rs b/nodedb/src/control/server/shared/authorization/service.rs new file mode 100644 index 000000000..fb4907829 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/service.rs @@ -0,0 +1,229 @@ +//! Authorization evaluation for fully-planned physical tasks. + +use nodedb_physical::physical_task::PhysicalTask; +use nodedb_types::DatabaseId; + +use crate::control::security::audit::{ + AuditEmitContext, AuditEmitter, AuditEvent, NoopAuditEmitter, +}; +use crate::control::security::identity::{AuthenticatedIdentity, Permission, Role}; +use crate::control::security::permission::PermissionStore; +use crate::control::security::role::RoleStore; +use crate::control::target_identity::bare_collection_name; +use crate::types::TenantId; + +use super::error::AuthorizationError; +use super::requirements::{AuthorizationRequirement, plan_requirements}; + +/// Ensure an identity may select `database_id`. +pub fn authorize_database( + identity: &AuthenticatedIdentity, + database_id: DatabaseId, + emitter: &dyn AuditEmitter, +) -> Result<(), AuthorizationError> { + if identity.can_access_database(database_id) { + return Ok(()); + } + + deny( + identity, + emitter, + format!( + "permission denied for database: user '{}' does not have access to {}", + identity.username, + database_id.as_u64() + ), + ) +} + +/// Authorize an entire physical task set before any task is dispatched. +/// +/// Every task must belong to the authenticated tenant and selected database. +/// A plan without a collection target is checked at tenant scope rather than +/// being silently allowed. +pub fn authorize_task_set( + identity: &AuthenticatedIdentity, + tasks: &[PhysicalTask], + permissions: &PermissionStore, + roles: &RoleStore, + emitter: &dyn AuditEmitter, +) -> Result<(), AuthorizationError> { + for task in tasks { + if task.tenant_id != identity.tenant_id && !identity.is_superuser { + return deny( + identity, + emitter, + format!( + "permission denied: task tenant {} is outside authenticated tenant", + task.tenant_id.as_u64() + ), + ); + } + authorize_database(identity, task.database_id, emitter)?; + } + + for task in tasks { + let requirements = plan_requirements(&task.plan); + if requirements.is_empty() { + authorize_tenant_permission( + identity, + task.tenant_id, + task.database_id, + crate::control::security::identity::required_permission(&task.plan), + permissions, + roles, + emitter, + )?; + continue; + } + for requirement in requirements { + match requirement { + AuthorizationRequirement::Collection { + collection, + permission, + } => authorize_collection_requirement( + identity, + task.database_id, + &collection, + permission, + permissions, + roles, + emitter, + )?, + AuthorizationRequirement::Tenant { permission } => authorize_tenant_permission( + identity, + task.tenant_id, + task.database_id, + permission, + permissions, + roles, + emitter, + )?, + } + } + } + Ok(()) +} + +fn authorize_collection_requirement( + identity: &AuthenticatedIdentity, + database_id: DatabaseId, + collection: &str, + permission: Permission, + permissions: &PermissionStore, + roles: &RoleStore, + emitter: &dyn AuditEmitter, +) -> Result<(), AuthorizationError> { + // Physical plans prefix non-default-database collection names, whereas + // grants and ownership use the unqualified collection name. + let grant_name = bare_collection_name(database_id, collection); + if !identity.is_superuser && is_system_collection(&grant_name) { + return deny( + identity, + emitter, + format!("permission denied: system catalog access requires superuser ({collection})"), + ); + } + + // PermissionStore's built-in role check is intentionally retained for + // ownership, grants, and custom-role inheritance. Filter database-scoped + // roles first because its legacy collection target has no database field. + let scoped_identity = identity_for_database(identity, database_id); + if permissions.check( + &scoped_identity, + permission, + &grant_name, + roles, + &NoopAuditEmitter, + ) { + return Ok(()); + } + + deny( + identity, + emitter, + format!( + "permission denied: user '{}' lacks {:?} permission on '{}'", + identity.username, permission, collection + ), + ) +} + +fn is_system_collection(collection: &str) -> bool { + collection + .get(.."_system".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("_system")) +} + +fn authorize_tenant_permission( + identity: &AuthenticatedIdentity, + tenant_id: TenantId, + database_id: DatabaseId, + permission: Permission, + permissions: &PermissionStore, + roles: &RoleStore, + emitter: &dyn AuditEmitter, +) -> Result<(), AuthorizationError> { + let scoped_identity = identity_for_database(identity, database_id); + if permissions.check_tenant( + &scoped_identity, + permission, + tenant_id, + roles, + &NoopAuditEmitter, + ) { + return Ok(()); + } + deny( + identity, + emitter, + format!( + "permission denied: user '{}' lacks {:?} permission on tenant {}", + identity.username, + permission, + tenant_id.as_u64() + ), + ) +} + +fn identity_for_database( + identity: &AuthenticatedIdentity, + database_id: DatabaseId, +) -> AuthenticatedIdentity { + let mut scoped = identity.clone(); + scoped.roles.retain(|role| match role { + Role::DatabaseOwner(role_database) + | Role::DatabaseEditor(role_database) + | Role::DatabaseReader(role_database) => *role_database == database_id, + Role::Superuser + | Role::ClusterAdmin + | Role::TenantAdmin + | Role::ReadWrite + | Role::ReadOnly + | Role::Monitor + | Role::Custom(_) => true, + }); + scoped +} + +fn deny( + identity: &AuthenticatedIdentity, + emitter: &dyn AuditEmitter, + detail: String, +) -> Result { + emitter.emit( + AuditEvent::PermissionDenied, + &identity.username, + &detail, + AuditEmitContext::new( + Some(identity.tenant_id), + &identity.user_id.to_string(), + &identity.username, + ), + ); + Err(AuthorizationError::new(identity.tenant_id, detail)) +} + +#[cfg(test)] +#[path = "service/tests.rs"] +mod tests; diff --git a/nodedb/src/control/server/shared/authorization/service/tests.rs b/nodedb/src/control/server/shared/authorization/service/tests.rs new file mode 100644 index 000000000..3db96e064 --- /dev/null +++ b/nodedb/src/control/server/shared/authorization/service/tests.rs @@ -0,0 +1,119 @@ +use super::*; +use crate::bridge::envelope::PhysicalPlan; +use crate::control::security::identity::{AuthMethod, DatabaseSet, Permission}; +use crate::types::VShardId; +use nodedb_physical::physical_plan::KvOp; + +fn identity(roles: Vec, databases: DatabaseSet) -> AuthenticatedIdentity { + AuthenticatedIdentity { + user_id: 7, + username: "reader".into(), + tenant_id: TenantId::new(9), + auth_method: AuthMethod::Trust, + roles, + is_superuser: false, + default_database: None, + accessible_databases: databases, + } +} + +#[test] +fn database_scope_denial_is_typed() { + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + let error = authorize_database(&id, DatabaseId::new(2), &NoopAuditEmitter) + .expect_err("database outside identity scope must be denied"); + assert!(error.resource().contains("database")); +} + +#[test] +fn explicit_collection_grant_is_accepted() { + let permissions = PermissionStore::new(); + let roles = RoleStore::new(); + permissions + .grant( + "collection:9:orders", + "user:reader", + Permission::Read, + "admin", + None, + ) + .expect("in-memory grant must succeed"); + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + assert!( + authorize_collection_requirement( + &id, + DatabaseId::DEFAULT, + "orders", + Permission::Read, + &permissions, + &roles, + &NoopAuditEmitter, + ) + .is_ok() + ); +} + +#[test] +fn task_set_fails_closed_when_a_resource_is_missing_permission() { + let permissions = PermissionStore::new(); + let roles = RoleStore::new(); + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + let tasks = vec![PhysicalTask { + tenant_id: TenantId::new(9), + database_id: DatabaseId::DEFAULT, + vshard_id: VShardId::new(0), + plan: PhysicalPlan::Kv(KvOp::Get { + collection: "orders".into(), + key: Vec::new(), + rls_filters: Vec::new(), + surrogate_ceiling: None, + }), + post_set_op: nodedb_physical::physical_task::PostSetOp::None, + txn_id: None, + }]; + + assert!(authorize_task_set(&id, &tasks, &permissions, &roles, &NoopAuditEmitter).is_err()); +} + +#[test] +fn system_collection_and_wrong_database_role_are_denied() { + let permissions = PermissionStore::new(); + let roles = RoleStore::new(); + let id = identity( + vec![Role::DatabaseReader(DatabaseId::new(3))], + DatabaseSet::Some(smallvec::smallvec![DatabaseId::new(3), DatabaseId::new(4)]), + ); + assert!( + authorize_collection_requirement( + &id, + DatabaseId::new(3), + "_SyStEm.audit_log", + Permission::Read, + &permissions, + &roles, + &NoopAuditEmitter, + ) + .is_err() + ); + assert!( + authorize_collection_requirement( + &id, + DatabaseId::new(4), + "orders", + Permission::Read, + &permissions, + &roles, + &NoopAuditEmitter, + ) + .is_err() + ); +} diff --git a/nodedb/src/control/server/shared/mod.rs b/nodedb/src/control/server/shared/mod.rs index abebe9977..29f7a09e4 100644 --- a/nodedb/src/control/server/shared/mod.rs +++ b/nodedb/src/control/server/shared/mod.rs @@ -1,4 +1,5 @@ //! Protocol-neutral machinery shared by every server entrypoint (pgwire, native, http). +pub mod authorization; pub mod check_constraint; pub mod ddl; pub mod plan_util; From b6dc2c2a966f232787dca41e52d2ed71f084f7bf Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 22:41:10 +0000 Subject: [PATCH 5/6] feat(security): enforce HTTP and pgwire SQL authorization --- .../src/pgwire_harness/query.rs | 16 +- .../src/control/server/http/routes/query.rs | 609 +----------------- .../server/http/routes/query/materialized.rs | 425 ++++++++++++ .../server/http/routes/query/ndjson.rs | 227 +++++++ .../src/control/server/pgwire/handler/auth.rs | 111 ++++ .../src/control/server/pgwire/handler/core.rs | 351 +--------- .../pgwire/handler/core/simple_query.rs | 137 ++++ .../server/pgwire/handler/cursor_query.rs | 2 + .../control/server/pgwire/handler/facet.rs | 30 +- .../server/pgwire/handler/live_select.rs | 21 +- .../src/control/server/pgwire/handler/mod.rs | 1 + .../src/control/server/pgwire/handler/plan.rs | 147 +++-- .../server/pgwire/handler/prepared/execute.rs | 132 ++-- .../server/pgwire/handler/prepared/parser.rs | 155 +++-- .../pgwire/handler/routing/calvin_dispatch.rs | 4 +- .../routing/clone_dispatch/dispatch.rs | 13 +- .../routing/clone_write_dispatch/document.rs | 17 + .../routing/clone_write_dispatch/entry.rs | 8 +- .../routing/clone_write_dispatch/kv.rs | 33 +- .../pgwire/handler/routing/cluster_array.rs | 2 +- .../server/pgwire/handler/routing/execute.rs | 24 +- .../handler/routing/execute_dml_hooks.rs | 7 +- .../handler/routing/gateway_dispatch.rs | 5 +- .../server/pgwire/handler/routing/mod.rs | 1 + .../server/pgwire/handler/routing/planning.rs | 14 +- .../pgwire/handler/routing/result_shaping.rs | 13 + .../server/pgwire/handler/routing/set_ops.rs | 4 +- .../server/pgwire/handler/session_explain.rs | 2 + .../control/server/session_auth/identity.rs | 49 +- .../server/shared/authorization/mod.rs | 2 +- .../shared/authorization/requirements.rs | 98 ++- .../authorization/requirements/collect.rs | 51 +- .../authorization/requirements/tests.rs | 92 --- .../server/shared/authorization/service.rs | 151 ++++- .../shared/authorization/service/tests.rs | 119 ---- .../ddl/neutral/collection/dml/insert.rs | 12 +- .../ddl/neutral/collection/dml/parse.rs | 598 +---------------- .../neutral/collection/dml/parse/dispatch.rs | 226 +++++++ .../neutral/collection/dml/parse/encoding.rs | 128 ++++ .../neutral/collection/dml/parse/response.rs | 30 + .../neutral/collection/dml/parse/statement.rs | 153 +++++ .../ddl/neutral/collection/dml/parse/types.rs | 45 ++ .../ddl/neutral/collection/dml/upsert.rs | 10 +- nodedb/tests/http_query_authorization.rs | 129 +++- nodedb/tests/pgwire_database_authorization.rs | 185 ++++++ 45 files changed, 2585 insertions(+), 2004 deletions(-) create mode 100644 nodedb/src/control/server/http/routes/query/materialized.rs create mode 100644 nodedb/src/control/server/http/routes/query/ndjson.rs create mode 100644 nodedb/src/control/server/pgwire/handler/auth.rs create mode 100644 nodedb/src/control/server/pgwire/handler/core/simple_query.rs create mode 100644 nodedb/src/control/server/pgwire/handler/routing/result_shaping.rs delete mode 100644 nodedb/src/control/server/shared/authorization/requirements/tests.rs delete mode 100644 nodedb/src/control/server/shared/authorization/service/tests.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/dispatch.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/encoding.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/response.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/statement.rs create mode 100644 nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/types.rs create mode 100644 nodedb/tests/pgwire_database_authorization.rs diff --git a/nodedb-test-support/src/pgwire_harness/query.rs b/nodedb-test-support/src/pgwire_harness/query.rs index 32b482003..f986c2b84 100644 --- a/nodedb-test-support/src/pgwire_harness/query.rs +++ b/nodedb-test-support/src/pgwire_harness/query.rs @@ -123,14 +123,24 @@ impl TestServer { &self, user: &str, password: &str, + ) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), String> { + self.connect_as_database(user, password, "nodedb").await + } + + /// Open a second pgwire connection under a user-selected database. + pub async fn connect_as_database( + &self, + user: &str, + password: &str, + database: &str, ) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), String> { let conn_str = format!( - "host=127.0.0.1 port={} user={} password={} dbname=nodedb", - self.pg_port, user, password + "host=127.0.0.1 port={} user={} password={} dbname={}", + self.pg_port, user, password, database ); let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls) .await - .map_err(|e| pg_error_detail(&e))?; + .map_err(|error| pg_error_detail(&error))?; let handle = tokio::spawn(async move { let _ = connection.await; }); diff --git a/nodedb/src/control/server/http/routes/query.rs b/nodedb/src/control/server/http/routes/query.rs index 270da552f..c24928447 100644 --- a/nodedb/src/control/server/http/routes/query.rs +++ b/nodedb/src/control/server/http/routes/query.rs @@ -1,31 +1,17 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Query endpoint — execute SQL/DDL via HTTP POST. -//! -//! POST /v1/query { "sql": "SELECT * FROM users LIMIT 10" } -//! Authorization: Bearer ndb_... -//! -//! Supports both DDL commands (SHOW USERS, CREATE COLLECTION, etc.) and -//! full SQL queries (SELECT, INSERT, UPDATE, DELETE) via DataFusion. +//! Query endpoint helpers shared by materialized and NDJSON SQL execution. -use axum::extract::{Query as QueryParams, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::IntoResponse; +use axum::http::HeaderMap; use serde::Deserialize; -use crate::bridge::envelope::Status; -use crate::control::gateway::GatewayErrorMap; -use crate::control::gateway::core::QueryContext; -use crate::control::security::identity::{required_permission, role_grants_permission}; -use crate::control::server::response_shape::types::describe_plan; +use super::super::auth::{ApiError, AppState}; -use super::super::auth::{ApiError, AppState, resolve_identity}; -use super::super::types::HttpQueryRequest; -use super::super::types::HttpQueryResponse; -use super::result_shape::{ - HttpShaped, ddl_results_to_json, passthrough_json_row, passthrough_to_ndjson, - shape_http_payload, -}; +mod materialized; +mod ndjson; + +pub use materialized::query; +pub use ndjson::query_ndjson; /// Query string parameters for `/v1/query` and `/v1/query/stream`. /// @@ -44,7 +30,7 @@ pub struct DatabaseQueryParam { /// (`3D000 database '' does not exist`). Silently falling back to /// DEFAULT would mask client mistakes and run queries against the wrong /// database; that is a correctness bug, not a usability convenience. -fn resolve_database_id( +pub(super) fn resolve_database_id( headers: &HeaderMap, param: &DatabaseQueryParam, state: &AppState, @@ -70,580 +56,3 @@ fn resolve_database_id( Err(e) => Err(ApiError::Internal(format!("catalog lookup failed: {e}"))), } } - -/// POST /v1/query — execute a SQL/DDL statement. -/// -/// Request body: `{ "sql": "..." }` -/// Response: `{ "status": "ok", "rows": [...] }` or `{ "error": "..." }` -/// -/// Database context (optional): -/// - `X-NodeDB-Database: ` header (highest priority) -/// - `?database=` query parameter (fallback) -pub async fn query( - headers: HeaderMap, - QueryParams(db_param): QueryParams, - State(state): State, - axum::Json(body): axum::Json, -) -> Result { - let identity = resolve_identity(&headers, &state, "http")?; - let database_id = resolve_database_id(&headers, &db_param, &state)?; - let trace_id = crate::control::trace_context::extract_from_headers(&headers); - - let sql = body.sql.as_str(); - - // HTTP is stateless — there is no BEGIN/COMMIT session concept over this - // transport, so a session-less scope satisfies the DDL dispatch signature. - // A fresh store reports "not in a transaction block" for any address, so - // the staging gate inside `plan_and_dispatch` always takes the immediate - // autocommit branch here, unchanged from before the gate existed. - let http_scope = crate::control::server::shared::session::DetachedTxnScope::new(); - let txn_ctx = http_scope.ctx(); - - // Try DDL commands first (same as pgwire handler). - if let Some(result) = crate::control::server::shared::ddl::dispatch( - &state.shared, - &identity, - sql.trim(), - database_id, - &txn_ctx, - ) - .await - { - return match result { - Ok(results) => { - let json_rows = ddl_results_to_json(results); - Ok(axum::Json(HttpQueryResponse::ok(json_rows))) - } - Err(e) => Err(ApiError::BadRequest(e.message)), - }; - } - - // Extract per-query ON DENY override + plan SQL with RLS injection. - let tenant_id = identity.tenant_id; - - // Quota enforcement — reject before any planning or dispatch. - state - .shared - .check_tenant_quota(tenant_id) - .map_err(|e| ApiError::RateLimited { - message: e.to_string(), - retry_after_secs: 1, - })?; - - let mut auth_ctx = crate::control::server::session_auth::build_auth_context(&identity); - let clean_sql = - crate::control::server::session_auth::extract_and_apply_on_deny(sql, &mut auth_ctx); - let perm_cache = state.shared.permission_cache.read().await; - let sec = crate::control::planner::context::PlanSecurityContext { - identity: &identity, - auth: &auth_ctx, - rls_store: &state.shared.rls, - permissions: &state.shared.permissions, - roles: &state.shared.roles, - permission_cache: Some(&*perm_cache), - }; - let (tasks, output_schema) = state - .query_ctx - .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { - sql: &clean_sql, - tenant_id, - database_id, - sec: &sec, - }) - .await - .map_err(|e| ApiError::BadRequest(format!("SQL planning failed: {e}")))?; - - if tasks.is_empty() { - return Ok(axum::Json(HttpQueryResponse::ok(vec![]))); - } - - // Track active request for quota accounting. - state.shared.tenant_request_start(tenant_id); - - // Execute each task via the SPSC bridge. - let mut result_rows = Vec::new(); - - let result = async { - for task in tasks { - // Permission check. - let required = required_permission(&task.plan); - if !identity.is_superuser - && !identity - .roles - .iter() - .any(|r| role_grants_permission(r, required)) - { - return Err(ApiError::Forbidden(format!( - "insufficient permissions for this operation (requires {required:?})" - ))); - } - - // Tenant isolation check. - if task.tenant_id != tenant_id { - return Err(ApiError::Forbidden("tenant isolation violation".into())); - } - - // `INSERT ... SELECT` is orchestrated on the Control Plane: the - // source is scanned, each target row gets its OWN fresh, registered - // surrogate, and the rows are written via an atomic `BatchInsert`. - // The orchestrator issues its own WAL-backed writes, so the outer - // per-task WAL append below is skipped for it. - if let crate::bridge::envelope::PhysicalPlan::Document( - nodedb_physical::physical_plan::DocumentOp::InsertSelect { - target_collection, - source_collection, - source_filters, - source_limit, - }, - ) = &task.plan - { - let plan_kind = describe_plan(&task.plan); - let plan_for_shape = task.plan.clone(); - let resp = crate::control::insert_select::run_insert_select( - &state.shared, - task.tenant_id, - task.database_id, - target_collection, - source_collection, - source_filters, - *source_limit, - ) - .await - .map_err(|e| { - let (status, msg) = GatewayErrorMap::to_http(&e); - ApiError::HttpStatus(status, msg) - })?; - if resp.status != Status::Ok { - let detail = resp - .error_code - .as_ref() - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "unknown error".into()); - return Err(ApiError::Internal(detail)); - } - let payload = resp.payload.to_vec(); - if !payload.is_empty() { - match shape_http_payload( - &payload, - &plan_for_shape, - plan_kind, - Some(&output_schema), - &state.shared, - database_id, - tenant_id, - ) { - Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), - Ok(HttpShaped::Passthrough) => { - result_rows.push(passthrough_json_row(&payload)); - } - Err(e) => return Err(ApiError::Internal(e.message().to_string())), - } - } - continue; - } - - // Autocommit `MERGE` is orchestrated on the Control Plane: each - // NOT-MATCHED insert row gets its OWN fresh, registered surrogate - // and all arms apply atomically. The orchestrator issues its own - // writes, so the per-task WAL append below is skipped for it. - if let crate::bridge::envelope::PhysicalPlan::Document( - nodedb_physical::physical_plan::DocumentOp::Merge { - target_collection, - source_collection, - source_alias, - target_join_col, - source_join_col, - clauses, - returning: _, - resolve_only: false, - resolved_inserts: None, - source_rows: _, - }, - ) = &task.plan - { - let plan_kind = describe_plan(&task.plan); - let plan_for_shape = task.plan.clone(); - let resp = crate::control::merge_orchestrator::run_merge( - &state.shared, - crate::control::merge_orchestrator::MergeArgs { - tenant_id: task.tenant_id, - database_id: task.database_id, - target_collection, - source_collection, - source_alias, - target_join_col, - source_join_col, - clauses, - }, - ) - .await - .map_err(|e| { - let (status, msg) = GatewayErrorMap::to_http(&e); - ApiError::HttpStatus(status, msg) - })?; - if resp.status != Status::Ok { - let detail = resp - .error_code - .as_ref() - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "unknown error".into()); - return Err(ApiError::Internal(detail)); - } - let payload = resp.payload.to_vec(); - if !payload.is_empty() { - match shape_http_payload( - &payload, - &plan_for_shape, - plan_kind, - Some(&output_schema), - &state.shared, - database_id, - tenant_id, - ) { - Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), - Ok(HttpShaped::Passthrough) => { - result_rows.push(passthrough_json_row(&payload)); - } - Err(e) => return Err(ApiError::Internal(e.message().to_string())), - } - } - continue; - } - - // Autocommit `UPDATE ... FROM ` is orchestrated on the - // Control Plane: the source is scanned on its OWN core and shipped - // into the plan so the target-core handler joins against it instead - // of a local read. The orchestrator issues its own write, so the - // per-task WAL append below is skipped for it. - if let crate::bridge::envelope::PhysicalPlan::Document( - nodedb_physical::physical_plan::DocumentOp::UpdateFromJoin { - target_collection, - source_collection, - source_alias, - target_join_col, - source_join_col, - updates, - target_filters, - returning, - resolve_only: false, - source_rows: None, - }, - ) = &task.plan - { - let plan_kind = describe_plan(&task.plan); - let plan_for_shape = task.plan.clone(); - let resp = crate::control::update_from_join_orchestrator::run_update_from_join( - &state.shared, - crate::control::update_from_join_orchestrator::UpdateFromJoinArgs { - tenant_id: task.tenant_id, - database_id: task.database_id, - target_collection, - source_collection, - source_alias, - target_join_col, - source_join_col, - updates, - target_filters, - returning: returning.as_ref(), - }, - ) - .await - .map_err(|e| { - let (status, msg) = GatewayErrorMap::to_http(&e); - ApiError::HttpStatus(status, msg) - })?; - if resp.status != Status::Ok { - let detail = resp - .error_code - .as_ref() - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "unknown error".into()); - return Err(ApiError::Internal(detail)); - } - let payload = resp.payload.to_vec(); - if !payload.is_empty() { - match shape_http_payload( - &payload, - &plan_for_shape, - plan_kind, - Some(&output_schema), - &state.shared, - database_id, - tenant_id, - ) { - Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), - Ok(HttpShaped::Passthrough) => { - result_rows.push(passthrough_json_row(&payload)); - } - Err(e) => return Err(ApiError::Internal(e.message().to_string())), - } - } - continue; - } - - // Captured before dispatch moves `task.plan` — needed by the - // protocol-neutral shaping core below. - let plan_kind = describe_plan(&task.plan); - let plan_for_shape = task.plan.clone(); - - // Dispatch: prefer gateway when available (cluster-aware routing — - // the gateway owns WAL durability on the target node), fall back to - // direct local SPSC dispatch on single-node boot. On the local path - // the WAL append is performed inside the dispatch core, under the - // write-admission guard and just before the enqueue, so LSN order - // matches apply order. - let payloads = match state.shared.gateway.as_ref() { - Some(gw) => { - let gw_ctx = QueryContext { - tenant_id: task.tenant_id, - trace_id, - database_id, - }; - gw.execute(&gw_ctx, task.plan).await.map_err(|e| { - let (status, msg) = GatewayErrorMap::to_http(&e); - ApiError::HttpStatus(status, msg) - })? - } - None => { - // Single-node boot: gateway not yet initialised — dispatch locally. - let response = - crate::control::server::dispatch_utils::dispatch_autocommit_write( - &state.shared, - crate::control::server::dispatch_utils::AutocommitWrite { - tenant_id: task.tenant_id, - database_id: task.database_id, - vshard_id: task.vshard_id, - plan: task.plan, - trace_id, - event_source: crate::event::EventSource::User, - txn_id: None, - }, - ) - .await - .map_err(|e| { - let (status, msg) = GatewayErrorMap::to_http(&e); - ApiError::HttpStatus(status, msg) - })?; - if response.status != Status::Ok { - let detail = response - .error_code - .as_ref() - .map(|c| format!("{c:?}")) - .unwrap_or_else(|| "unknown error".into()); - return Err(ApiError::Internal(detail)); - } - vec![response.payload.to_vec()] - } - }; - - for payload in &payloads { - if payload.is_empty() { - continue; - } - match shape_http_payload( - payload, - &plan_for_shape, - plan_kind, - Some(&output_schema), - &state.shared, - database_id, - tenant_id, - ) { - Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), - Ok(HttpShaped::Passthrough) => { - result_rows.push(passthrough_json_row(payload)); - } - Err(e) => return Err(ApiError::Internal(e.message().to_string())), - } - } - } - - Ok(axum::Json(HttpQueryResponse::ok(result_rows))) - } - .await; - - state.shared.tenant_request_end(tenant_id); - result -} - -/// POST /v1/query/stream — execute SQL and return results as NDJSON (newline-delimited JSON). -/// -/// Each result row is a separate JSON line terminated by `\n`. -/// Content-Type: application/x-ndjson -/// -/// This is suitable for streaming large result sets without buffering -/// the entire response. Clients can process each line as it arrives. -pub async fn query_ndjson( - State(state): State, - headers: HeaderMap, - QueryParams(db_param): QueryParams, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - use axum::response::Response; - - let identity = match resolve_identity(&headers, &state, "http") { - Ok(id) => id, - Err(e) => return e.into_response(), - }; - let database_id = match resolve_database_id(&headers, &db_param, &state) { - Ok(id) => id, - Err(e) => return e.into_response(), - }; - - let sql = body.sql.trim(); - if sql.is_empty() { - return (StatusCode::BAD_REQUEST, "empty SQL").into_response(); - } - - let tenant_id = identity.tenant_id; - - // Quota enforcement — reject before any planning or dispatch. - if let Err(e) = state.shared.check_tenant_quota(tenant_id) { - let body = serde_json::json!({ "error": e.to_string() }); - return Response::builder() - .status(StatusCode::TOO_MANY_REQUESTS) - .header("Retry-After", "1") - .header("Content-Type", "application/json") - .body(axum::body::Body::from(body.to_string())) - .unwrap_or_else(|_| { - (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response() - }); - } - - let query_ctx = &state.query_ctx; - - let auth_ctx = crate::control::server::session_auth::build_auth_context(&identity); - let perm_cache = state.shared.permission_cache.read().await; - let sec = crate::control::planner::context::PlanSecurityContext { - identity: &identity, - auth: &auth_ctx, - rls_store: &state.shared.rls, - permissions: &state.shared.permissions, - roles: &state.shared.roles, - permission_cache: Some(&*perm_cache), - }; - let (tasks, output_schema) = match query_ctx - .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { - sql, - tenant_id, - database_id, - sec: &sec, - }) - .await - { - Ok(t) => t, - Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - }; - - let trace_id = crate::control::trace_context::generate_trace_id(); - - // Lazy fast path: an eligible single-task, unordered, multi-row SELECT - // streams its rows straight off a `ResultStream` as NDJSON lines instead - // of materializing the whole result first. HTTP is stateless, so there is - // no autocommit / transaction-block gate (cf. native + pgwire). The - // streaming body outlives this handler, so request-accounting is not - // bracketed around it — admission was already gated by `check_tenant_quota` - // above, matching the pgwire lazy path which also does not request-account - // the streamed `QueryResponse`. - match super::query_stream::try_open_stream(&state, &tasks, database_id, trace_id).await { - Ok(Some((stream, limit))) => { - let body = axum::body::Body::from_stream(super::query_stream::ndjson_body_stream( - stream, - limit, - Some(output_schema.clone()), - )); - return Response::builder() - .header("Content-Type", "application/x-ndjson") - .body(body) - .unwrap_or_else(|_| { - (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response() - }); - } - Ok(None) => { - // Not streamable — fall through to the materialized path below. - } - Err(e) => { - let (_status, msg) = GatewayErrorMap::to_http(&e); - return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(); - } - } - - state.shared.tenant_request_start(tenant_id); - - let mut ndjson = String::new(); - for task in tasks { - // Captured before dispatch moves `task.plan` — needed by the - // protocol-neutral shaping core below. - let plan_kind = describe_plan(&task.plan); - let plan_for_shape = task.plan.clone(); - - let dispatch_result: crate::Result>> = match state.shared.gateway.as_ref() { - Some(gw) => { - let gw_ctx = QueryContext { - tenant_id: task.tenant_id, - trace_id, - database_id, - }; - gw.execute(&gw_ctx, task.plan).await - } - None => { - // Single-node boot: gateway not yet initialised — dispatch locally. - crate::control::server::dispatch_utils::dispatch_to_data_plane( - &state.shared, - task.tenant_id, - task.database_id, - task.vshard_id, - task.plan, - trace_id, - ) - .await - .map(|r| vec![r.payload.to_vec()]) - } - }; - - match dispatch_result { - Ok(payloads) => { - for payload in &payloads { - if payload.is_empty() { - continue; - } - match shape_http_payload( - payload, - &plan_for_shape, - plan_kind, - Some(&output_schema), - &state.shared, - database_id, - tenant_id, - ) { - Ok(HttpShaped::Rows(rows)) => { - for row in rows { - ndjson.push_str(&row.to_string()); - ndjson.push('\n'); - } - } - Ok(HttpShaped::Passthrough) => { - passthrough_to_ndjson(payload, &mut ndjson); - } - Err(e) => { - ndjson.push_str(&serde_json::json!({"error": e.message()}).to_string()); - ndjson.push('\n'); - } - } - } - } - Err(e) => { - let (_status, msg) = GatewayErrorMap::to_http(&e); - ndjson.push_str(&serde_json::json!({"error": msg}).to_string()); - ndjson.push('\n'); - } - } - } - - state.shared.tenant_request_end(tenant_id); - - Response::builder() - .header("Content-Type", "application/x-ndjson") - .body(axum::body::Body::from(ndjson)) - .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response()) -} diff --git a/nodedb/src/control/server/http/routes/query/materialized.rs b/nodedb/src/control/server/http/routes/query/materialized.rs new file mode 100644 index 000000000..aa61ffef0 --- /dev/null +++ b/nodedb/src/control/server/http/routes/query/materialized.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::sync::Arc; + +use axum::extract::{Query as QueryParams, State}; +use axum::http::HeaderMap; +use axum::response::IntoResponse; + +use crate::bridge::envelope::Status; +use crate::control::gateway::GatewayErrorMap; +use crate::control::gateway::core::QueryContext; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::server::response_shape::types::describe_plan; +use crate::control::server::shared::authorization::{authorize_database, authorize_task_set}; + +use super::super::super::auth::{ApiError, AppState, resolve_identity}; +use super::super::super::types::{HttpQueryRequest, HttpQueryResponse}; +use super::super::result_shape::{ + HttpShaped, ddl_results_to_json, passthrough_json_row, shape_http_payload, +}; +use super::{DatabaseQueryParam, resolve_database_id}; + +/// POST /v1/query — execute a SQL/DDL statement. +/// +/// Request body: `{ "sql": "..." }` +/// Response: `{ "status": "ok", "rows": [...] }` or `{ "error": "..." }` +/// +/// Database context (optional): +/// - `X-NodeDB-Database: ` header (highest priority) +/// - `?database=` query parameter (fallback) +pub async fn query( + headers: HeaderMap, + QueryParams(db_param): QueryParams, + State(state): State, + axum::Json(body): axum::Json, +) -> Result { + let identity = resolve_identity(&headers, &state, "http")?; + let database_id = resolve_database_id(&headers, &db_param, &state)?; + let trace_id = crate::control::trace_context::extract_from_headers(&headers); + let emitter = ArcAuditEmitter(Arc::clone(&state.shared.audit)); + authorize_database(&identity, database_id, &emitter).map_err(crate::Error::from)?; + + let sql = body.sql.as_str(); + + // HTTP is stateless — there is no BEGIN/COMMIT session concept over this + // transport, so a session-less scope satisfies the DDL dispatch signature. + // A fresh store reports "not in a transaction block" for any address, so + // the staging gate inside `plan_and_dispatch` always takes the immediate + // autocommit branch here, unchanged from before the gate existed. + let http_scope = crate::control::server::shared::session::DetachedTxnScope::new(); + let txn_ctx = http_scope.ctx(); + + // Try DDL commands first (same as pgwire handler). + if let Some(result) = crate::control::server::shared::ddl::dispatch( + &state.shared, + &identity, + sql.trim(), + database_id, + &txn_ctx, + ) + .await + { + return match result { + Ok(results) => { + let json_rows = ddl_results_to_json(results); + Ok(axum::Json(HttpQueryResponse::ok(json_rows))) + } + Err(e) => Err(ddl_error_to_api(e)), + }; + } + + // Extract per-query ON DENY override + plan SQL with RLS injection. + let tenant_id = identity.tenant_id; + + // Quota enforcement — reject before any planning or dispatch. + state + .shared + .check_tenant_quota(tenant_id) + .map_err(|e| ApiError::RateLimited { + message: e.to_string(), + retry_after_secs: 1, + })?; + + let mut auth_ctx = crate::control::server::session_auth::build_auth_context(&identity); + let clean_sql = + crate::control::server::session_auth::extract_and_apply_on_deny(sql, &mut auth_ctx); + let perm_cache = state.shared.permission_cache.read().await; + let sec = crate::control::planner::context::PlanSecurityContext { + identity: &identity, + auth: &auth_ctx, + rls_store: &state.shared.rls, + permissions: &state.shared.permissions, + roles: &state.shared.roles, + permission_cache: Some(&*perm_cache), + }; + let (mut tasks, output_schema) = state + .query_ctx + .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { + sql: &clean_sql, + tenant_id, + database_id, + sec: &sec, + }) + .await + .map_err(|e| ApiError::BadRequest(format!("SQL planning failed: {e}")))?; + + crate::control::planner::implicit_edges::append_implicit_edge_tasks( + &state.shared, + &mut tasks, + tenant_id, + database_id, + crate::types::TraceId::ZERO, + ) + .await + .map_err(ApiError::from)?; + + authorize_task_set( + &identity, + &tasks, + &state.shared.permissions, + &state.shared.roles, + &emitter, + ) + .map_err(crate::Error::from)?; + + if tasks.is_empty() { + return Ok(axum::Json(HttpQueryResponse::ok(vec![]))); + } + + // Track active request for quota accounting. + state.shared.tenant_request_start(tenant_id); + + // Execute each task via the SPSC bridge. + let mut result_rows = Vec::new(); + + let result = async { + for task in tasks { + // `INSERT ... SELECT` is orchestrated on the Control Plane: the + // source is scanned, each target row gets its OWN fresh, registered + // surrogate, and the rows are written via an atomic `BatchInsert`. + // The orchestrator issues its own WAL-backed writes, so the outer + // per-task WAL append below is skipped for it. + if let crate::bridge::envelope::PhysicalPlan::Document( + nodedb_physical::physical_plan::DocumentOp::InsertSelect { + target_collection, + source_collection, + source_filters, + source_limit, + }, + ) = &task.plan + { + let plan_kind = describe_plan(&task.plan); + let plan_for_shape = task.plan.clone(); + let resp = crate::control::insert_select::run_insert_select( + &state.shared, + task.tenant_id, + task.database_id, + target_collection, + source_collection, + source_filters, + *source_limit, + ) + .await + .map_err(gateway_error)?; + append_response( + &mut result_rows, + resp, + &plan_for_shape, + plan_kind, + &output_schema, + &state, + database_id, + tenant_id, + )?; + continue; + } + + // Autocommit `MERGE` is orchestrated on the Control Plane: each + // NOT-MATCHED insert row gets its OWN fresh, registered surrogate + // and all arms apply atomically. The orchestrator issues its own + // writes, so the per-task WAL append below is skipped for it. + if let crate::bridge::envelope::PhysicalPlan::Document( + nodedb_physical::physical_plan::DocumentOp::Merge { + target_collection, + source_collection, + source_alias, + target_join_col, + source_join_col, + clauses, + returning: _, + resolve_only: false, + resolved_inserts: None, + source_rows: _, + }, + ) = &task.plan + { + let plan_kind = describe_plan(&task.plan); + let plan_for_shape = task.plan.clone(); + let resp = crate::control::merge_orchestrator::run_merge( + &state.shared, + crate::control::merge_orchestrator::MergeArgs { + tenant_id: task.tenant_id, + database_id: task.database_id, + target_collection, + source_collection, + source_alias, + target_join_col, + source_join_col, + clauses, + }, + ) + .await + .map_err(gateway_error)?; + append_response( + &mut result_rows, + resp, + &plan_for_shape, + plan_kind, + &output_schema, + &state, + database_id, + tenant_id, + )?; + continue; + } + + // Autocommit `UPDATE ... FROM ` is orchestrated on the + // Control Plane: the source is scanned on its OWN core and shipped + // into the plan so the target-core handler joins against it instead + // of a local read. The orchestrator issues its own write, so the + // per-task WAL append below is skipped for it. + if let crate::bridge::envelope::PhysicalPlan::Document( + nodedb_physical::physical_plan::DocumentOp::UpdateFromJoin { + target_collection, + source_collection, + source_alias, + target_join_col, + source_join_col, + updates, + target_filters, + returning, + resolve_only: false, + source_rows: None, + }, + ) = &task.plan + { + let plan_kind = describe_plan(&task.plan); + let plan_for_shape = task.plan.clone(); + let resp = crate::control::update_from_join_orchestrator::run_update_from_join( + &state.shared, + crate::control::update_from_join_orchestrator::UpdateFromJoinArgs { + tenant_id: task.tenant_id, + database_id: task.database_id, + target_collection, + source_collection, + source_alias, + target_join_col, + source_join_col, + updates, + target_filters, + returning: returning.as_ref(), + }, + ) + .await + .map_err(gateway_error)?; + append_response( + &mut result_rows, + resp, + &plan_for_shape, + plan_kind, + &output_schema, + &state, + database_id, + tenant_id, + )?; + continue; + } + + // Captured before dispatch moves `task.plan` — needed by the + // protocol-neutral shaping core below. + let plan_kind = describe_plan(&task.plan); + let plan_for_shape = task.plan.clone(); + + // Dispatch: prefer gateway when available (cluster-aware routing — + // the gateway owns WAL durability on the target node), fall back to + // direct local SPSC dispatch on single-node boot. On the local path + // the WAL append is performed inside the dispatch core, under the + // write-admission guard and just before the enqueue, so LSN order + // matches apply order. + let payloads = match state.shared.gateway.as_ref() { + Some(gw) => { + let gw_ctx = QueryContext { + tenant_id: task.tenant_id, + trace_id, + database_id, + }; + gw.execute(&gw_ctx, task.plan) + .await + .map_err(gateway_error)? + } + None => { + // Single-node boot: gateway not yet initialised — dispatch locally. + let response = + crate::control::server::dispatch_utils::dispatch_autocommit_write( + &state.shared, + crate::control::server::dispatch_utils::AutocommitWrite { + tenant_id: task.tenant_id, + database_id: task.database_id, + vshard_id: task.vshard_id, + plan: task.plan, + trace_id, + event_source: crate::event::EventSource::User, + txn_id: None, + }, + ) + .await + .map_err(gateway_error)?; + if response.status != Status::Ok { + return Err(response_error(&response)); + } + vec![response.payload.to_vec()] + } + }; + + for payload in &payloads { + if payload.is_empty() { + continue; + } + match shape_http_payload( + payload, + &plan_for_shape, + plan_kind, + Some(&output_schema), + &state.shared, + database_id, + tenant_id, + ) { + Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), + Ok(HttpShaped::Passthrough) => result_rows.push(passthrough_json_row(payload)), + Err(e) => return Err(ApiError::Internal(e.message().to_string())), + } + } + } + + Ok(axum::Json(HttpQueryResponse::ok(result_rows))) + } + .await; + + state.shared.tenant_request_end(tenant_id); + result +} + +fn ddl_error_to_api(error: crate::control::server::shared::ddl::DdlError) -> ApiError { + if error.sqlstate == "42501" { + ApiError::Forbidden(error.message) + } else { + ApiError::BadRequest(error.message) + } +} + +fn gateway_error(error: crate::Error) -> ApiError { + let (status, msg) = GatewayErrorMap::to_http(&error); + ApiError::HttpStatus(status, msg) +} + +fn response_error(response: &crate::bridge::envelope::Response) -> ApiError { + let detail = response + .error_code + .as_ref() + .map(|code| format!("{code:?}")) + .unwrap_or_else(|| "unknown error".into()); + ApiError::Internal(detail) +} + +#[allow(clippy::too_many_arguments)] +fn append_response( + result_rows: &mut Vec, + response: crate::bridge::envelope::Response, + plan: &crate::bridge::envelope::PhysicalPlan, + plan_kind: crate::control::server::response_shape::types::PlanKind, + output_schema: &crate::control::server::response_shape::schema::OutputSchema, + state: &AppState, + database_id: nodedb_types::DatabaseId, + tenant_id: crate::types::TenantId, +) -> Result<(), ApiError> { + if response.status != Status::Ok { + return Err(response_error(&response)); + } + let payload = response.payload.to_vec(); + if payload.is_empty() { + return Ok(()); + } + match shape_http_payload( + &payload, + plan, + plan_kind, + Some(output_schema), + &state.shared, + database_id, + tenant_id, + ) { + Ok(HttpShaped::Rows(rows)) => result_rows.extend(rows), + Ok(HttpShaped::Passthrough) => result_rows.push(passthrough_json_row(&payload)), + Err(e) => return Err(ApiError::Internal(e.message().to_string())), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ddl_insufficient_privilege_maps_to_forbidden() { + let error = crate::control::server::shared::ddl::DdlError { + sqlstate: "42501".into(), + message: "write permission denied".into(), + }; + + assert!(matches!( + ddl_error_to_api(error), + ApiError::Forbidden(message) if message == "write permission denied" + )); + } +} diff --git a/nodedb/src/control/server/http/routes/query/ndjson.rs b/nodedb/src/control/server/http/routes/query/ndjson.rs new file mode 100644 index 000000000..377ba9061 --- /dev/null +++ b/nodedb/src/control/server/http/routes/query/ndjson.rs @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::sync::Arc; + +use axum::extract::{Query as QueryParams, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; + +use crate::control::gateway::GatewayErrorMap; +use crate::control::gateway::core::QueryContext; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::server::response_shape::types::describe_plan; +use crate::control::server::shared::authorization::{authorize_database, authorize_task_set}; + +use super::super::super::auth::{ApiError, AppState, resolve_identity}; +use super::super::result_shape::{HttpShaped, passthrough_to_ndjson, shape_http_payload}; +use super::{DatabaseQueryParam, resolve_database_id}; + +/// POST /v1/query/stream — execute SQL and return results as NDJSON (newline-delimited JSON). +/// +/// Each result row is a separate JSON line terminated by `\n`. +/// Content-Type: application/x-ndjson +/// +/// This is suitable for streaming large result sets without buffering +/// the entire response. Clients can process each line as it arrives. +pub async fn query_ndjson( + State(state): State, + headers: HeaderMap, + QueryParams(db_param): QueryParams, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + use axum::response::Response; + + let identity = match resolve_identity(&headers, &state, "http") { + Ok(id) => id, + Err(e) => return e.into_response(), + }; + let database_id = match resolve_database_id(&headers, &db_param, &state) { + Ok(id) => id, + Err(e) => return e.into_response(), + }; + + let emitter = ArcAuditEmitter(Arc::clone(&state.shared.audit)); + if let Err(error) = authorize_database(&identity, database_id, &emitter) { + return ApiError::from(crate::Error::from(error)).into_response(); + } + + let sql = body.sql.trim(); + if sql.is_empty() { + return (StatusCode::BAD_REQUEST, "empty SQL").into_response(); + } + + let tenant_id = identity.tenant_id; + + // Quota enforcement — reject before any planning or dispatch. + if let Err(e) = state.shared.check_tenant_quota(tenant_id) { + let body = serde_json::json!({ "error": e.to_string() }); + return Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("Retry-After", "1") + .header("Content-Type", "application/json") + .body(axum::body::Body::from(body.to_string())) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response() + }); + } + + let query_ctx = &state.query_ctx; + + let auth_ctx = crate::control::server::session_auth::build_auth_context(&identity); + let perm_cache = state.shared.permission_cache.read().await; + let sec = crate::control::planner::context::PlanSecurityContext { + identity: &identity, + auth: &auth_ctx, + rls_store: &state.shared.rls, + permissions: &state.shared.permissions, + roles: &state.shared.roles, + permission_cache: Some(&*perm_cache), + }; + let (mut tasks, output_schema) = match query_ctx + .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { + sql, + tenant_id, + database_id, + sec: &sec, + }) + .await + { + Ok(t) => t, + Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + }; + + if let Err(error) = crate::control::planner::implicit_edges::append_implicit_edge_tasks( + &state.shared, + &mut tasks, + tenant_id, + database_id, + crate::types::TraceId::ZERO, + ) + .await + { + return ApiError::from(error).into_response(); + } + + if let Err(error) = authorize_task_set( + &identity, + &tasks, + &state.shared.permissions, + &state.shared.roles, + &emitter, + ) { + return ApiError::from(crate::Error::from(error)).into_response(); + } + + let trace_id = crate::control::trace_context::generate_trace_id(); + + // Lazy fast path: an eligible single-task, unordered, multi-row SELECT + // streams its rows straight off a `ResultStream` as NDJSON lines instead + // of materializing the whole result first. HTTP is stateless, so there is + // no autocommit / transaction-block gate (cf. native + pgwire). The + // streaming body outlives this handler, so request-accounting is not + // bracketed around it — admission was already gated by `check_tenant_quota` + // above, matching the pgwire lazy path which also does not request-account + // the streamed `QueryResponse`. + match super::super::query_stream::try_open_stream(&state, &tasks, database_id, trace_id).await { + Ok(Some((stream, limit))) => { + let body = + axum::body::Body::from_stream(super::super::query_stream::ndjson_body_stream( + stream, + limit, + Some(output_schema.clone()), + )); + return Response::builder() + .header("Content-Type", "application/x-ndjson") + .body(body) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response() + }); + } + Ok(None) => { + // Not streamable — fall through to the materialized path below. + } + Err(e) => { + let (_status, msg) = GatewayErrorMap::to_http(&e); + return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(); + } + } + + state.shared.tenant_request_start(tenant_id); + + let mut ndjson = String::new(); + for task in tasks { + // Captured before dispatch moves `task.plan` — needed by the + // protocol-neutral shaping core below. + let plan_kind = describe_plan(&task.plan); + let plan_for_shape = task.plan.clone(); + + let dispatch_result: crate::Result>> = match state.shared.gateway.as_ref() { + Some(gw) => { + let gw_ctx = QueryContext { + tenant_id: task.tenant_id, + trace_id, + database_id, + }; + gw.execute(&gw_ctx, task.plan).await + } + None => { + // Single-node boot: gateway not yet initialised — dispatch locally. + crate::control::server::dispatch_utils::dispatch_to_data_plane( + &state.shared, + task.tenant_id, + task.database_id, + task.vshard_id, + task.plan, + trace_id, + ) + .await + .map(|r| vec![r.payload.to_vec()]) + } + }; + + match dispatch_result { + Ok(payloads) => { + for payload in &payloads { + if payload.is_empty() { + continue; + } + match shape_http_payload( + payload, + &plan_for_shape, + plan_kind, + Some(&output_schema), + &state.shared, + database_id, + tenant_id, + ) { + Ok(HttpShaped::Rows(rows)) => { + for row in rows { + ndjson.push_str(&row.to_string()); + ndjson.push('\n'); + } + } + Ok(HttpShaped::Passthrough) => { + passthrough_to_ndjson(payload, &mut ndjson); + } + Err(e) => { + ndjson.push_str(&serde_json::json!({"error": e.message()}).to_string()); + ndjson.push('\n'); + } + } + } + } + Err(e) => { + let (_status, msg) = GatewayErrorMap::to_http(&e); + ndjson.push_str(&serde_json::json!({"error": msg}).to_string()); + ndjson.push('\n'); + } + } + } + + state.shared.tenant_request_end(tenant_id); + + Response::builder() + .header("Content-Type", "application/x-ndjson") + .body(axum::body::Body::from(ndjson)) + .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "encoding error").into_response()) +} diff --git a/nodedb/src/control/server/pgwire/handler/auth.rs b/nodedb/src/control/server/pgwire/handler/auth.rs new file mode 100644 index 000000000..e1a22b871 --- /dev/null +++ b/nodedb/src/control/server/pgwire/handler/auth.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Pgwire adapters for transport-neutral SQL authorization. + +use std::sync::Arc; + +use pgwire::api::ClientInfo; +use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; + +use crate::config::auth::AuthMode; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::{AuthMethod, AuthenticatedIdentity}; +use crate::control::server::session_auth::identity::stored_user_identity; +use crate::control::server::shared::authorization::{ + AuthorizationError, authorize_database, authorize_task_set, +}; +use crate::control::server::shared::session::SessionStore; +use crate::control::state::SharedState; +use nodedb_physical::physical_task::PhysicalTask; + +use super::core::NodeDbPgHandler; + +/// Resolve the identity shared by pgwire Parse and Execute paths. +/// +/// Applying a superuser's session tenant override here ensures catalog +/// resolution during Parse observes the same identity as later execution. +pub(super) fn resolve_session_identity( + state: &SharedState, + auth_mode: AuthMode, + sessions: &SessionStore, + client: &C, + addr: &std::net::SocketAddr, +) -> PgWireResult { + let username = client + .metadata() + .get("user") + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + + let mut identity = match auth_mode { + AuthMode::Trust => { + stored_user_identity(state, &username, AuthMethod::Trust).ok_or_else(|| { + PgWireError::UserError(Box::new(ErrorInfo::new( + "FATAL".to_owned(), + "28000".to_owned(), + format!("trust auth: user '{username}' does not exist"), + ))) + })? + } + AuthMode::Password | AuthMode::Certificate => { + stored_user_identity(state, &username, AuthMethod::ScramSha256).ok_or_else(|| { + PgWireError::UserError(Box::new(ErrorInfo::new( + "FATAL".to_owned(), + "28000".to_owned(), + format!("authenticated user '{username}' not found in credential store"), + ))) + })? + } + }; + + if let Some(effective) = sessions.get_effective_tenant_id(addr) { + if identity.is_superuser { + identity.tenant_id = effective; + } else { + sessions.set_effective_tenant_id(addr, None); + } + } + + Ok(identity) +} + +impl NodeDbPgHandler { + /// Authorize the pgwire session database immediately after identity resolution. + pub(super) fn authorize_session_database( + &self, + identity: &AuthenticatedIdentity, + addr: &std::net::SocketAddr, + ) -> PgWireResult<()> { + let database_id = self + .sessions + .get_current_database(addr) + .unwrap_or(crate::types::DatabaseId::DEFAULT); + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_database(identity, database_id, &emitter).map_err(pgwire_authorization_error) + } + + /// Authorize the final task set before pgwire execution can take any route. + pub(super) fn authorize_tasks( + &self, + identity: &AuthenticatedIdentity, + tasks: &[PhysicalTask], + ) -> PgWireResult<()> { + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_task_set( + identity, + tasks, + &self.state.permissions, + &self.state.roles, + &emitter, + ) + .map_err(pgwire_authorization_error) + } +} + +pub(super) fn pgwire_authorization_error(error: AuthorizationError) -> PgWireError { + PgWireError::UserError(Box::new(ErrorInfo::new( + "ERROR".to_owned(), + "42501".to_owned(), + crate::Error::from(error).to_string(), + ))) +} diff --git a/nodedb/src/control/server/pgwire/handler/core.rs b/nodedb/src/control/server/pgwire/handler/core.rs index 12b80304b..d3aaa50b6 100644 --- a/nodedb/src/control/server/pgwire/handler/core.rs +++ b/nodedb/src/control/server/pgwire/handler/core.rs @@ -12,30 +12,25 @@ use futures::SinkExt; use futures::sink::Sink; use pgwire::api::portal::Portal; -use pgwire::api::query::{ExtendedQueryHandler, SimpleQueryHandler}; +use pgwire::api::query::ExtendedQueryHandler; use pgwire::api::results::{DescribePortalResponse, DescribeStatementResponse, Response}; use pgwire::api::stmt::StoredStatement; use pgwire::api::store::PortalStore; use pgwire::api::{ClientInfo, ClientPortalStore}; -use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; +use pgwire::error::{PgWireError, PgWireResult}; use pgwire::messages::PgWireBackendMessage; -use crate::bridge::envelope::PhysicalPlan; use crate::config::auth::AuthMode; use crate::control::planner::context::QueryContext; -use crate::control::security::audit::{ - AuditEmitContext, AuditEmitter, AuditEvent, NoopAuditEmitter, -}; -use crate::control::security::identity::{ - AuthMethod, AuthenticatedIdentity, required_permission, role_grants_permission, -}; +use crate::control::security::identity::AuthenticatedIdentity; use crate::control::state::SharedState; use crate::types::RequestId; use super::super::types::notice_warning; -use super::plan::extract_collection; use super::prepared::{NodeDbQueryParser, ParsedStatement}; -use crate::control::server::shared::session::{SessionStore, TransactionState}; +use crate::control::server::shared::session::SessionStore; + +mod simple_query; /// PostgreSQL wire protocol handler for NodeDB. /// @@ -51,7 +46,7 @@ pub struct NodeDbPgHandler { query_parser: Arc, pub(super) auth_mode: AuthMode, /// Per-connection session state (transaction blocks, parameters). - pub(crate) sessions: SessionStore, + pub(crate) sessions: Arc, /// Per-connection in-flight COPY IN restore accumulators. pub(crate) restore_state: Arc, } @@ -65,13 +60,18 @@ impl NodeDbPgHandler { // procedural DML) build their own no-lease `QueryContext` // via `for_state`. let query_ctx = QueryContext::for_state_with_lease(&state); - let query_parser = Arc::new(NodeDbQueryParser::new(Arc::clone(&state))); + let sessions = Arc::new(SessionStore::new()); + let query_parser = Arc::new(NodeDbQueryParser::new( + Arc::clone(&state), + auth_mode.clone(), + Arc::clone(&sessions), + )); Self { state, query_ctx, query_parser, auth_mode, - sessions: SessionStore::new(), + sessions, restore_state: Arc::new(crate::control::backup::RestoreState::new()), } } @@ -96,322 +96,13 @@ impl NodeDbPgHandler { client: &C, addr: &std::net::SocketAddr, ) -> PgWireResult { - let username = client - .metadata() - .get("user") - .cloned() - .unwrap_or_else(|| "unknown".to_string()); - - let mut identity = match self.auth_mode { - AuthMode::Trust => { - // Strict resolution: `resolve_trust_user` has already ensured - // the user exists (either because it was already in the store - // or via the bootstrap auto-create path on an empty store), - // so any miss here is a genuine unknown user. - self.state - .credentials - .to_identity(&username, AuthMethod::Trust) - .ok_or_else(|| { - PgWireError::UserError(Box::new(ErrorInfo::new( - "FATAL".to_owned(), - "28000".to_owned(), - format!("trust auth: user '{username}' does not exist"), - ))) - })? - } - AuthMode::Password | AuthMode::Certificate => self - .state - .credentials - .to_identity(&username, AuthMethod::ScramSha256) - .ok_or_else(|| { - PgWireError::UserError(Box::new(ErrorInfo::new( - "FATAL".to_owned(), - "28000".to_owned(), - format!("authenticated user '{username}' not found in credential store"), - ))) - })?, - }; - - if let Some(effective) = self.sessions.get_effective_tenant_id(addr) { - // The SET handler guarantees the override only gets installed for - // superuser sessions. If somehow a non-superuser carries one - // (e.g. an ALTER USER demotion landed after the override was - // installed), treat the override as cleared — the identity-bound - // tenant is the safe fallback. Drop the override so future - // requests on this session don't keep paying the check. - if identity.is_superuser { - identity.tenant_id = effective; - } else { - self.sessions.set_effective_tenant_id(addr, None); - } - } - - Ok(identity) - } - - /// Enforce that the identity may access the session's current database. - /// - /// Called at session bind time (first query on this connection). If the - /// resolved `current_database` is not in `identity.accessible_databases`, - /// the connection is rejected with `ACCESS_DENIED` (SQLSTATE 42501) before - /// any query executes. Superusers bypass this check. - /// - /// This is the single enforcement point. Every query path goes through - /// `do_query` (simple) or `execute` (extended), both of which call this - /// immediately after identity resolution. - pub(super) fn enforce_database_access( - &self, - identity: &AuthenticatedIdentity, - addr: &std::net::SocketAddr, - ) -> PgWireResult<()> { - if identity.is_superuser { - return Ok(()); - } - let db = self - .sessions - .get_current_database(addr) - .unwrap_or(crate::types::DatabaseId::DEFAULT); - if !identity.can_access_database(db) { - let emitter = crate::control::security::audit::ArcAuditEmitter(std::sync::Arc::clone( - &self.state.audit, - )); - emitter.emit( - AuditEvent::PermissionDenied, - &identity.username, - &format!("database access denied: db={}", db.as_u64()), - AuditEmitContext::new( - Some(identity.tenant_id), - &identity.user_id.to_string(), - &identity.username, - ), - ); - return Err(PgWireError::UserError(Box::new(ErrorInfo::new( - "FATAL".to_owned(), - "42501".to_owned(), - format!( - "permission denied for database: user '{}' does not have access", - identity.username - ), - )))); - } - Ok(()) - } - - /// Check if the identity has permission for the given plan. - /// - /// Enforcement layers: - /// 1. Superuser → always allowed - /// 2. System catalog (`_system.*`) → superuser only - /// 3. Collection-level grants (PermissionStore::check with ownership + roles + grants) - /// 4. Built-in role fallback (role_grants_permission) - pub(super) fn check_permission( - &self, - identity: &AuthenticatedIdentity, - plan: &PhysicalPlan, - ) -> PgWireResult<()> { - if identity.is_superuser { - return Ok(()); - } - - let required = required_permission(plan); - let collection = extract_collection(plan); - - // Block non-superuser access to system catalog collections. - if let Some(coll) = collection - && coll.starts_with("_system") - { - let emitter = crate::control::security::audit::ArcAuditEmitter(std::sync::Arc::clone( - &self.state.audit, - )); - emitter.emit( - AuditEvent::PermissionDenied, - &identity.username, - &format!("system catalog access denied: {coll}"), - AuditEmitContext::new( - Some(identity.tenant_id), - &identity.user_id.to_string(), - &identity.username, - ), - ); - return Err(PgWireError::UserError(Box::new(ErrorInfo::new( - "ERROR".to_owned(), - "42501".to_owned(), - "permission denied: system catalog access requires superuser".to_owned(), - )))); - } - - // Check collection-level permissions (ownership + explicit grants + role grants). - // Noop emitter here — the role fallback below is the terminal decision point. - if let Some(coll) = collection - && self.state.permissions.check( - identity, - required, - coll, - &self.state.roles, - &NoopAuditEmitter, - ) - { - return Ok(()); - } - - // Fall back to role-based check. - let has_permission = identity - .roles - .iter() - .any(|role| role_grants_permission(role, required)); - - if has_permission { - Ok(()) - } else { - // Terminal denial — emit PermissionDenied audit row. - let emitter = crate::control::security::audit::ArcAuditEmitter(std::sync::Arc::clone( - &self.state.audit, - )); - emitter.emit( - AuditEvent::PermissionDenied, - &identity.username, - &format!( - "permission {:?} denied{}", - required, - collection.map(|c| format!(" on '{c}'")).unwrap_or_default() - ), - AuditEmitContext::new( - Some(identity.tenant_id), - &identity.user_id.to_string(), - &identity.username, - ), - ); - - Err(PgWireError::UserError(Box::new(ErrorInfo::new( - "ERROR".to_owned(), - "42501".to_owned(), - format!( - "permission denied: user '{}' lacks {:?} permission{}", - identity.username, - required, - collection.map(|c| format!(" on '{c}'")).unwrap_or_default() - ), - )))) - } - } -} - -// ── SimpleQueryHandler ────────────────────────────────────────────── - -#[async_trait] -impl SimpleQueryHandler for NodeDbPgHandler { - async fn do_query(&self, client: &mut C, query: &str) -> PgWireResult> - where - C: ClientInfo + ClientPortalStore + Sink + Unpin + Send + Sync, - C::Error: Debug, - PgWireError: From<>::Error>, - { - let addr = client.socket_addr(); - self.sessions.ensure_session(addr); - - let identity = self.resolve_identity(client, &addr)?; - self.enforce_database_access(&identity, &addr)?; - - // Emit db.id / db.name trace fields at session bind so that any - // downstream spans inherit the database context. - let current_db = self - .sessions - .get_current_database(&addr) - .unwrap_or(crate::types::DatabaseId::DEFAULT); - let db_name: String = self - .state - .credentials - .catalog() - .get_database(current_db) - .ok() - .flatten() - .map(|d| d.name.clone()) - .unwrap_or_else(|| "default".to_string()); - tracing::debug!( - db.id = current_db.as_u64(), - db.name = %db_name, - user = %identity.username, - "session query dispatch", - ); - - // Send notice if BEGIN is called (advisory transactions). - let upper = query.trim().to_uppercase(); - if (upper == "BEGIN" || upper == "BEGIN TRANSACTION" || upper == "START TRANSACTION") - && self.sessions.transaction_state(&addr) == TransactionState::InBlock - { - let notice = notice_warning("there is already a transaction in progress"); - let _ = client - .send(PgWireBackendMessage::NoticeResponse(notice)) - .await; - } - - if (upper == "COMMIT" || upper == "END") - && self.sessions.transaction_state(&addr) == TransactionState::Idle - { - let notice = notice_warning("there is no transaction in progress"); - let _ = client - .send(PgWireBackendMessage::NoticeResponse(notice)) - .await; - } - - // J.4: install the DDL audit context for this statement. Any - // `propose_catalog_entry` call reached from `execute_sql` - // picks up the identity + raw SQL so the applier can emit a - // full audit record on every replica. The guard auto-clears - // on scope exit. - let _audit_scope = crate::control::server::shared::session::audit_context::AuditScope::new( - crate::control::server::shared::session::audit_context::AuditCtx { - auth_user_id: identity.user_id.to_string(), - auth_user_name: identity.username.clone(), - sql_text: query.to_string(), - }, - ); - - let result = self.execute_sql(&identity, &addr, query).await; - - // Drain queued NOTICE messages emitted by response shapers (e.g. - // `truncated_before_horizon` on array slices) and send them before - // the query result so the client associates the warning with the - // current statement. - for message in self.sessions.drain_notices(&addr) { - let notice = notice_warning(&message); - let _ = client - .send(PgWireBackendMessage::NoticeResponse(notice)) - .await; - } - - // Drain pending LIVE SELECT notifications and send as pgwire - // async NotificationResponse messages. This is the standard - // PostgreSQL notification delivery model: notifications are - // delivered between queries. - if self.sessions.has_live_subscriptions(&addr) { - let notifications = self.sessions.drain_live_notifications(&addr); - for (channel, payload) in notifications { - let notification = pgwire::messages::response::NotificationResponse::new( - 0, // backend PID (not meaningful for NodeDB) - channel, payload, - ); - let _ = client - .send(PgWireBackendMessage::NotificationResponse(notification)) - .await; - } - } - - // Drain pending LISTEN/NOTIFY notifications and deliver as pgwire - // NotificationResponse messages (between queries, per PG semantics). - if self.sessions.has_listen_subscriptions(&addr) { - let notifications = self.sessions.drain_listen_notifications(&addr); - for n in notifications { - let notification = pgwire::messages::response::NotificationResponse::new( - n.pid, n.channel, n.payload, - ); - let _ = client - .send(PgWireBackendMessage::NotificationResponse(notification)) - .await; - } - } - - result + super::auth::resolve_session_identity( + &self.state, + self.auth_mode.clone(), + &self.sessions, + client, + addr, + ) } } diff --git a/nodedb/src/control/server/pgwire/handler/core/simple_query.rs b/nodedb/src/control/server/pgwire/handler/core/simple_query.rs new file mode 100644 index 000000000..eff5a282e --- /dev/null +++ b/nodedb/src/control/server/pgwire/handler/core/simple_query.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::fmt::Debug; + +use async_trait::async_trait; +use futures::SinkExt; +use futures::sink::Sink; +use pgwire::api::query::SimpleQueryHandler; +use pgwire::api::results::Response; +use pgwire::api::{ClientInfo, ClientPortalStore}; +use pgwire::error::{PgWireError, PgWireResult}; +use pgwire::messages::PgWireBackendMessage; + +use super::NodeDbPgHandler; +use crate::control::server::shared::session::TransactionState; + +// ── SimpleQueryHandler ────────────────────────────────────────────── + +#[async_trait] +impl SimpleQueryHandler for NodeDbPgHandler { + async fn do_query(&self, client: &mut C, query: &str) -> PgWireResult> + where + C: ClientInfo + ClientPortalStore + Sink + Unpin + Send + Sync, + C::Error: Debug, + PgWireError: From<>::Error>, + { + let addr = client.socket_addr(); + self.sessions.ensure_session(addr); + + let identity = self.resolve_identity(client, &addr)?; + self.authorize_session_database(&identity, &addr)?; + + // Emit db.id / db.name trace fields at session bind so that any + // downstream spans inherit the database context. + let current_db = self + .sessions + .get_current_database(&addr) + .unwrap_or(crate::types::DatabaseId::DEFAULT); + let db_name: String = self + .state + .credentials + .catalog() + .get_database(current_db) + .ok() + .flatten() + .map(|d| d.name.clone()) + .unwrap_or_else(|| "default".to_string()); + tracing::debug!( + db.id = current_db.as_u64(), + db.name = %db_name, + user = %identity.username, + "session query dispatch", + ); + + // Send notice if BEGIN is called (advisory transactions). + let upper = query.trim().to_uppercase(); + if (upper == "BEGIN" || upper == "BEGIN TRANSACTION" || upper == "START TRANSACTION") + && self.sessions.transaction_state(&addr) == TransactionState::InBlock + { + let notice = super::super::super::types::notice_warning( + "there is already a transaction in progress", + ); + let _ = client + .send(PgWireBackendMessage::NoticeResponse(notice)) + .await; + } + + if (upper == "COMMIT" || upper == "END") + && self.sessions.transaction_state(&addr) == TransactionState::Idle + { + let notice = + super::super::super::types::notice_warning("there is no transaction in progress"); + let _ = client + .send(PgWireBackendMessage::NoticeResponse(notice)) + .await; + } + + // J.4: install the DDL audit context for this statement. Any + // `propose_catalog_entry` call reached from `execute_sql` + // picks up the identity + raw SQL so the applier can emit a + // full audit record on every replica. The guard auto-clears + // on scope exit. + let _audit_scope = crate::control::server::shared::session::audit_context::AuditScope::new( + crate::control::server::shared::session::audit_context::AuditCtx { + auth_user_id: identity.user_id.to_string(), + auth_user_name: identity.username.clone(), + sql_text: query.to_string(), + }, + ); + + let result = self.execute_sql(&identity, &addr, query).await; + + // Drain queued NOTICE messages emitted by response shapers (e.g. + // `truncated_before_horizon` on array slices) and send them before + // the query result so the client associates the warning with the + // current statement. + for message in self.sessions.drain_notices(&addr) { + let notice = super::super::super::types::notice_warning(&message); + let _ = client + .send(PgWireBackendMessage::NoticeResponse(notice)) + .await; + } + + // Drain pending LIVE SELECT notifications and send as pgwire + // async NotificationResponse messages. This is the standard + // PostgreSQL notification delivery model: notifications are + // delivered between queries. + if self.sessions.has_live_subscriptions(&addr) { + let notifications = self.sessions.drain_live_notifications(&addr); + for (channel, payload) in notifications { + let notification = pgwire::messages::response::NotificationResponse::new( + 0, // backend PID (not meaningful for NodeDB) + channel, payload, + ); + let _ = client + .send(PgWireBackendMessage::NotificationResponse(notification)) + .await; + } + } + + // Drain pending LISTEN/NOTIFY notifications and deliver as pgwire + // NotificationResponse messages (between queries, per PG semantics). + if self.sessions.has_listen_subscriptions(&addr) { + let notifications = self.sessions.drain_listen_notifications(&addr); + for n in notifications { + let notification = pgwire::messages::response::NotificationResponse::new( + n.pid, n.channel, n.payload, + ); + let _ = client + .send(PgWireBackendMessage::NotificationResponse(notification)) + .await; + } + } + + result + } +} diff --git a/nodedb/src/control/server/pgwire/handler/cursor_query.rs b/nodedb/src/control/server/pgwire/handler/cursor_query.rs index 38078e42a..feb864e01 100644 --- a/nodedb/src/control/server/pgwire/handler/cursor_query.rs +++ b/nodedb/src/control/server/pgwire/handler/cursor_query.rs @@ -57,6 +57,8 @@ impl NodeDbPgHandler { ))) })?; + self.authorize_tasks(identity, &tasks)?; + let mut rows = Vec::new(); for task in tasks { let resp = crate::control::server::dispatch_utils::dispatch_to_data_plane( diff --git a/nodedb/src/control/server/pgwire/handler/facet.rs b/nodedb/src/control/server/pgwire/handler/facet.rs index dcb10da53..66147fd87 100644 --- a/nodedb/src/control/server/pgwire/handler/facet.rs +++ b/nodedb/src/control/server/pgwire/handler/facet.rs @@ -18,19 +18,23 @@ use nodedb_physical::physical_plan::QueryOp; use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; use super::core::NodeDbPgHandler; -use super::plan::{PlanKind, payload_to_response}; +use super::plan::multirow_payload_to_response; /// Execute `SELECT FACET_COUNTS(collection => '...', filter => '...', fields => [...])`. pub(super) async fn execute_facet_counts_sql( handler: &NodeDbPgHandler, identity: &AuthenticatedIdentity, - _addr: &std::net::SocketAddr, + addr: &std::net::SocketAddr, sql: &str, ) -> PgWireResult> { let parsed = parse_facet_counts_args(sql)?; let tenant_id = identity.tenant_id; - let vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &parsed.collection); + let database_id = handler + .sessions + .get_current_database(addr) + .unwrap_or(DatabaseId::DEFAULT); + let vshard = VShardId::from_collection_in_database(database_id, &parsed.collection); // Convert filter text to ScanFilter predicates. let filter_bytes = if parsed.filter.is_empty() { @@ -42,7 +46,7 @@ pub(super) async fn execute_facet_counts_sql( let task = PhysicalTask { tenant_id, vshard_id: vshard, - database_id: DatabaseId::DEFAULT, + database_id, plan: PhysicalPlan::Query(QueryOp::FacetCounts { collection: parsed.collection, filters: filter_bytes, @@ -53,6 +57,7 @@ pub(super) async fn execute_facet_counts_sql( txn_id: None, }; + handler.authorize_tasks(identity, std::slice::from_ref(&task))?; let resp = handler.dispatch_task(task, None, None).await.map_err(|e| { PgWireError::UserError(Box::new(ErrorInfo::new( "ERROR".to_owned(), @@ -61,9 +66,7 @@ pub(super) async fn execute_facet_counts_sql( ))) })?; - Ok(vec![ - payload_to_response(&resp.payload, PlanKind::MultiRow).response, - ]) + Ok(vec![multirow_payload_to_response(&resp.payload).response]) } /// Execute `SELECT SEARCH_WITH_FACETS(query => '...', facets => [...])`. @@ -88,7 +91,11 @@ pub(super) async fn execute_search_with_facets_sql( let (collection, filter_text) = extract_collection_and_filter(&parsed.query)?; let tenant_id = identity.tenant_id; - let vshard = VShardId::from_collection_in_database(DatabaseId::DEFAULT, &collection); + let database_id = handler + .sessions + .get_current_database(addr) + .unwrap_or(DatabaseId::DEFAULT); + let vshard = VShardId::from_collection_in_database(database_id, &collection); let filter_bytes = if filter_text.is_empty() { Vec::new() @@ -99,7 +106,7 @@ pub(super) async fn execute_search_with_facets_sql( let facet_task = PhysicalTask { tenant_id, vshard_id: vshard, - database_id: DatabaseId::DEFAULT, + database_id, plan: PhysicalPlan::Query(QueryOp::FacetCounts { collection, filters: filter_bytes, @@ -110,6 +117,7 @@ pub(super) async fn execute_search_with_facets_sql( txn_id: None, }; + handler.authorize_tasks(identity, std::slice::from_ref(&facet_task))?; let facet_resp = handler .dispatch_task(facet_task, None, None) .await @@ -141,9 +149,7 @@ pub(super) async fn execute_search_with_facets_sql( }); let payload = sonic_rs::to_vec(&combined).unwrap_or_default(); - Ok(vec![ - payload_to_response(&payload, PlanKind::MultiRow).response, - ]) + Ok(vec![multirow_payload_to_response(&payload).response]) } // ── Parsing helpers ─────────────────────────────────────────────────── diff --git a/nodedb/src/control/server/pgwire/handler/live_select.rs b/nodedb/src/control/server/pgwire/handler/live_select.rs index 5f6ad2feb..69581f42e 100644 --- a/nodedb/src/control/server/pgwire/handler/live_select.rs +++ b/nodedb/src/control/server/pgwire/handler/live_select.rs @@ -8,9 +8,12 @@ use std::sync::Arc; use pgwire::api::results::{DataRowEncoder, QueryResponse, Response}; use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; -use crate::control::security::identity::AuthenticatedIdentity; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::{AuthenticatedIdentity, Permission}; +use crate::control::server::shared::authorization::authorize_collection; use super::super::types::text_field; +use super::auth::pgwire_authorization_error; use super::core::NodeDbPgHandler; impl NodeDbPgHandler { @@ -32,6 +35,22 @@ impl NodeDbPgHandler { ))) })?; + let database_id = self + .sessions + .get_current_database(addr) + .unwrap_or(crate::types::DatabaseId::DEFAULT); + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_collection( + identity, + database_id, + &coll_name, + Permission::Read, + &self.state.permissions, + &self.state.roles, + &emitter, + ) + .map_err(pgwire_authorization_error)?; + let tenant_id = identity.tenant_id; let sub = self .state diff --git a/nodedb/src/control/server/pgwire/handler/mod.rs b/nodedb/src/control/server/pgwire/handler/mod.rs index 957558aee..2ab859fec 100644 --- a/nodedb/src/control/server/pgwire/handler/mod.rs +++ b/nodedb/src/control/server/pgwire/handler/mod.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 +mod auth; mod copy_handler; mod core; mod current_setting; diff --git a/nodedb/src/control/server/pgwire/handler/plan.rs b/nodedb/src/control/server/pgwire/handler/plan.rs index 3d15abd90..80d0146c3 100644 --- a/nodedb/src/control/server/pgwire/handler/plan.rs +++ b/nodedb/src/control/server/pgwire/handler/plan.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use futures::stream; use pgwire::api::results::{DataRowEncoder, QueryResponse, Response, Tag}; +use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use sonic_rs; use crate::bridge::envelope::PhysicalPlan; @@ -19,9 +20,6 @@ use crate::control::server::shared::sql::staging_predicates::{ use super::super::types::text_field; pub(super) use crate::control::server::response_shape::types::{PlanKind, describe_plan}; -// Neutral plan classification lives in `shared`; re-exported here so existing -// pgwire call sites keep naming it via `super::plan::extract_collection`. -pub(super) use crate::control::server::shared::plan_util::extract_collection; /// Returns `true` when a plan can produce a deterministic pgwire tag without /// a round-trip to the Data Plane. @@ -129,7 +127,7 @@ pub(super) fn tag_from_staged(kind: StagedTagKind, affected: usize) -> Tag { /// Caller invariant: `plan` must already have passed `is_calvin_foldable`. /// The match arms here are kept in lockstep with that predicate so a desync /// between the two is loud rather than silent. -pub(super) fn calvin_tag_for_plan(plan: &PhysicalPlan) -> Tag { +pub(super) fn calvin_tag_for_plan(plan: &PhysicalPlan) -> PgWireResult { use nodedb_physical::physical_plan::KvOp; match plan { @@ -137,21 +135,20 @@ pub(super) fn calvin_tag_for_plan(plan: &PhysicalPlan) -> Tag { | PhysicalPlan::Document(DocumentOp::PointInsert { .. }) | PhysicalPlan::Kv(KvOp::Put { .. }) | PhysicalPlan::Kv(KvOp::Insert { .. }) - | PhysicalPlan::Kv(KvOp::InsertIfAbsent { .. }) => Tag::new("INSERT").with_rows(1), + | PhysicalPlan::Kv(KvOp::InsertIfAbsent { .. }) => Ok(Tag::new("INSERT").with_rows(1)), PhysicalPlan::Document(DocumentOp::PointUpdate { returning: None, .. - }) => Tag::new("UPDATE").with_rows(1), + }) => Ok(Tag::new("UPDATE").with_rows(1)), PhysicalPlan::Document(DocumentOp::PointDelete { returning: None, .. }) - | PhysicalPlan::Kv(KvOp::Delete { .. }) => Tag::new("DELETE").with_rows(1), + | PhysicalPlan::Kv(KvOp::Delete { .. }) => Ok(Tag::new("DELETE").with_rows(1)), - other => unreachable!( - "calvin_tag_for_plan called on non-foldable plan; \ - is_calvin_foldable invariant broken: {other:?}" - ), + other => Err(invalid_plan_shape(format!( + "calvin_tag_for_plan called on non-foldable plan: {other:?}" + ))), } } @@ -174,9 +171,9 @@ impl From for ShapedResponse { } } -pub(super) fn payload_to_response(payload: &[u8], kind: PlanKind) -> ShapedResponse { +pub(super) fn payload_to_response(payload: &[u8], kind: PlanKind) -> PgWireResult { match kind { - PlanKind::Execution => Response::Execution(Tag::new("OK")).into(), + PlanKind::Execution => Ok(Response::Execution(Tag::new("OK")).into()), PlanKind::DmlResult(tag) => { let count = if payload.is_empty() { // Point operations with empty payload succeeded on exactly 1 row. @@ -184,47 +181,93 @@ pub(super) fn payload_to_response(payload: &[u8], kind: PlanKind) -> ShapedRespo } else { extract_affected_count(payload).unwrap_or(1) as usize }; - Response::Execution(Tag::new(tag).with_rows(count)).into() + Ok(Response::Execution(Tag::new(tag).with_rows(count)).into()) } PlanKind::ArraySlice | PlanKind::ReturningRows | PlanKind::SingleDocument => { - unreachable!( - "shaped via response_shape::compose; payload_to_response is only reached \ - for Execution/DmlResult tags and MultiRow (facet)" - ) - } - PlanKind::MultiRow => { - let schema = Arc::new(vec![text_field("result")]); - if payload.is_empty() { - return Response::Query(QueryResponse::new(schema, stream::empty())).into(); - } - let text = decode_payload_to_json(payload); - - // For multi-row results, parse the JSON array and stream each - // element as a separate pgwire row. This avoids materializing - // a single giant row for large result sets. - if let Ok(serde_json::Value::Array(items)) = - sonic_rs::from_str::(&text) - { - let row_schema = schema.clone(); - let rows: Vec<_> = items - .iter() - .map(|item| { - let mut encoder = DataRowEncoder::new(row_schema.clone()); - let _ = encoder.encode_field(&item.to_string()); - Ok(encoder.take_row()) - }) - .collect(); - return Response::Query(QueryResponse::new(schema, stream::iter(rows))).into(); - } - - // Single document or non-array: send as one row. - let mut encoder = DataRowEncoder::new(schema.clone()); - if let Err(e) = encoder.encode_field(&text) { - tracing::error!(error = %e, "failed to encode field"); - return Response::Execution(Tag::new("ERROR")).into(); - } - let row = encoder.take_row(); - Response::Query(QueryResponse::new(schema, stream::iter(vec![Ok(row)]))).into() + Err(invalid_plan_shape(format!( + "payload_to_response cannot handle plan kind {kind:?}" + ))) } + PlanKind::MultiRow => Ok(multirow_payload_to_response(payload)), + } +} + +pub(super) fn multirow_payload_to_response(payload: &[u8]) -> ShapedResponse { + let schema = Arc::new(vec![text_field("result")]); + if payload.is_empty() { + return Response::Query(QueryResponse::new(schema, stream::empty())).into(); + } + let text = decode_payload_to_json(payload); + + // For multi-row results, parse the JSON array and stream each + // element as a separate pgwire row. This avoids materializing + // a single giant row for large result sets. + if let Ok(serde_json::Value::Array(items)) = sonic_rs::from_str::(&text) { + let row_schema = schema.clone(); + let rows: Vec<_> = items + .iter() + .map(|item| { + let mut encoder = DataRowEncoder::new(row_schema.clone()); + let _ = encoder.encode_field(&item.to_string()); + Ok(encoder.take_row()) + }) + .collect(); + return Response::Query(QueryResponse::new(schema, stream::iter(rows))).into(); + } + + // Single document or non-array: send as one row. + let mut encoder = DataRowEncoder::new(schema.clone()); + if let Err(error) = encoder.encode_field(&text) { + tracing::error!(%error, "failed to encode field"); + return Response::Execution(Tag::new("ERROR")).into(); + } + let row = encoder.take_row(); + Response::Query(QueryResponse::new(schema, stream::iter(vec![Ok(row)]))).into() +} + +fn invalid_plan_shape(message: String) -> PgWireError { + PgWireError::UserError(Box::new(ErrorInfo::new( + "ERROR".to_owned(), + "XX000".to_owned(), + message, + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use nodedb_physical::physical_plan::KvOp; + + #[test] + fn calvin_tag_rejects_non_foldable_plan() { + let plan = PhysicalPlan::Kv(KvOp::Get { + collection: "items".into(), + key: Vec::new(), + rls_filters: Vec::new(), + surrogate_ceiling: None, + }); + assert!(calvin_tag_for_plan(&plan).is_err()); + } + + #[test] + fn passthrough_rejects_precomposed_shapes() { + assert!(payload_to_response(&[], PlanKind::ArraySlice).is_err()); + assert!(payload_to_response(&[], PlanKind::ReturningRows).is_err()); + assert!(payload_to_response(&[], PlanKind::SingleDocument).is_err()); + } + + #[test] + fn multirow_helper_remains_infallible() { + let shaped = multirow_payload_to_response(&[]); + assert!(matches!(shaped.response, Response::Query(_))); + } + + #[test] + fn foldable_tag_still_matches_operation() { + let plan = PhysicalPlan::Kv(KvOp::Delete { + collection: "items".into(), + keys: Vec::new(), + }); + assert!(calvin_tag_for_plan(&plan).is_ok()); } } diff --git a/nodedb/src/control/server/pgwire/handler/prepared/execute.rs b/nodedb/src/control/server/pgwire/handler/prepared/execute.rs index b1f3a9eef..df29ba2cc 100644 --- a/nodedb/src/control/server/pgwire/handler/prepared/execute.rs +++ b/nodedb/src/control/server/pgwire/handler/prepared/execute.rs @@ -19,7 +19,7 @@ use pgwire::messages::PgWireBackendMessage; use crate::control::server::response_shape::schema::{OutputColumn, OutputSchema}; use super::super::core::NodeDbPgHandler; -use super::super::routing::execute::ResultShaping; +use super::super::routing::result_shaping::ResultShaping; use super::result_format::{pg_type_to_ddl_col_type, resolve_result_formats}; use super::statement::ParsedStatement; @@ -42,7 +42,7 @@ impl NodeDbPgHandler { { let addr = client.socket_addr(); let identity = self.resolve_identity(client, &addr)?; - self.enforce_database_access(&identity, &addr)?; + self.authorize_session_database(&identity, &addr)?; let stmt = &portal.statement.statement; let tenant_id = identity.tenant_id; @@ -359,76 +359,57 @@ mod tests { let types = vec![Some(Type::NUMERIC)]; let result = convert_portal_params(¶ms, &types, &text_format()).unwrap(); match &result[0] { - nodedb_sql::ParamValue::Decimal(d) => { - assert_eq!(d.to_string(), "123.45"); - } + nodedb_sql::ParamValue::Decimal(decimal) => assert_eq!(decimal.to_string(), "123.45"), other => panic!("expected Decimal, got {other:?}"), } } + fn assert_binary_type_rejected(ty: Type, bytes: &'static [u8], name: &str) { + let params = vec![Some(Bytes::from_static(bytes))]; + let types = vec![Some(ty)]; + let error = convert_portal_params(¶ms, &types, &binary_format()).unwrap_err(); + let message = error.to_string(); + assert!(message.contains(name) || message.contains("0A000")); + } + #[test] fn convert_numeric_binary_returns_error() { - // Binary format code + NUMERIC type → explicit rejection. - let params = vec![Some(Bytes::from_static(&[0x00, 0x03, 0x00, 0x02]))]; - let types = vec![Some(Type::NUMERIC)]; - let err = convert_portal_params(¶ms, &types, &binary_format()).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("binary NUMERIC") || msg.contains("0A000"), - "expected binary-format error, got: {msg}" - ); + assert_binary_type_rejected(Type::NUMERIC, &[0x00, 0x03, 0x00, 0x02], "NUMERIC"); } #[test] fn convert_timestamp_binary_returns_error() { - let params = vec![Some(Bytes::from_static(&[ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]))]; - let types = vec![Some(Type::TIMESTAMP)]; - let err = convert_portal_params(¶ms, &types, &binary_format()).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("binary TIMESTAMP") || msg.contains("0A000"), - "expected binary-format error, got: {msg}" - ); + assert_binary_type_rejected(Type::TIMESTAMP, &[0; 8], "TIMESTAMP"); } #[test] fn convert_timestamptz_binary_returns_error() { - let params = vec![Some(Bytes::from_static(&[ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - ]))]; - let types = vec![Some(Type::TIMESTAMPTZ)]; - let err = convert_portal_params(¶ms, &types, &binary_format()).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("binary TIMESTAMPTZ") || msg.contains("0A000"), - "expected binary-format error, got: {msg}" - ); + assert_binary_type_rejected(Type::TIMESTAMPTZ, &[0; 8], "TIMESTAMPTZ"); + } + + fn assert_text_param( + input: &'static [u8], + ty: Type, + expected: fn(&nodedb_sql::ParamValue) -> bool, + ) { + let params = vec![Some(Bytes::from_static(input))]; + let types = vec![Some(ty)]; + let result = convert_portal_params(¶ms, &types, &text_format()).unwrap(); + assert!(expected(&result[0])); } #[test] fn convert_timestamp_text_to_typed() { - let params = vec![Some(Bytes::from_static(b"2024-01-01 00:00:00"))]; - let types = vec![Some(Type::TIMESTAMP)]; - let result = convert_portal_params(¶ms, &types, &text_format()).unwrap(); - assert!( - matches!(result[0], nodedb_sql::ParamValue::Timestamp(_)), - "expected Timestamp, got {:?}", - result[0] - ); + assert_text_param(b"2024-01-01 00:00:00", Type::TIMESTAMP, |value| { + matches!(value, nodedb_sql::ParamValue::Timestamp(_)) + }); } #[test] fn convert_timestamptz_text_to_typed() { - let params = vec![Some(Bytes::from_static(b"2024-01-01 00:00:00+00"))]; - let types = vec![Some(Type::TIMESTAMPTZ)]; - let result = convert_portal_params(¶ms, &types, &text_format()).unwrap(); - assert!( - matches!(result[0], nodedb_sql::ParamValue::Timestamptz(_)), - "expected Timestamptz, got {:?}", - result[0] - ); + assert_text_param(b"2024-01-01 00:00:00+00", Type::TIMESTAMPTZ, |value| { + matches!(value, nodedb_sql::ParamValue::Timestamptz(_)) + }); } #[test] @@ -437,74 +418,57 @@ mod tests { let params = vec![Some(Bytes::from(input))]; let types = vec![Some(Type::BOOL)]; let result = convert_portal_params(¶ms, &types, &text_format()).unwrap(); - assert!(matches!(result[0], nodedb_sql::ParamValue::Bool(v) if v == expected)); + assert!(matches!(result[0], nodedb_sql::ParamValue::Bool(value) if value == expected)); } } - /// DATE params arrive as text per pgwire spec. The bind layer - /// preserves the text so the engine's literal-coercion path can - /// convert it to the target column type. #[test] fn passthrough_date_text() { - let out = pgwire_text_to_param("2026-04-19", &Type::DATE); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == "2026-04-19")); + let value = pgwire_text_to_param("2026-04-19", &Type::DATE); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == "2026-04-19")); } #[test] fn timestamp_text_parses_to_typed() { - let out = pgwire_text_to_param("2026-04-19 12:00:00", &Type::TIMESTAMP); - assert!( - matches!(out, nodedb_sql::ParamValue::Timestamp(_)), - "expected Timestamp variant, got {out:?}" - ); + let value = pgwire_text_to_param("2026-04-19 12:00:00", &Type::TIMESTAMP); + assert!(matches!(value, nodedb_sql::ParamValue::Timestamp(_))); } #[test] fn timestamptz_text_parses_to_typed() { - let out = pgwire_text_to_param("2026-04-19 12:00:00+00", &Type::TIMESTAMPTZ); - assert!( - matches!(out, nodedb_sql::ParamValue::Timestamptz(_)), - "expected Timestamptz variant, got {out:?}" - ); + let value = pgwire_text_to_param("2026-04-19 12:00:00+00", &Type::TIMESTAMPTZ); + assert!(matches!(value, nodedb_sql::ParamValue::Timestamptz(_))); } #[test] fn passthrough_uuid_text() { let uuid = "550e8400-e29b-41d4-a716-446655440000"; - let out = pgwire_text_to_param(uuid, &Type::UUID); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == uuid)); + let value = pgwire_text_to_param(uuid, &Type::UUID); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == uuid)); } #[test] fn passthrough_jsonb_text() { let json = r#"{"a":1}"#; - let out = pgwire_text_to_param(json, &Type::JSONB); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == json)); + let value = pgwire_text_to_param(json, &Type::JSONB); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == json)); } - /// BYTEA text form per pgwire is `\x` — passed through as-is - /// so the engine's BYTEA parser (which already handles both escape - /// and hex forms) converts it. #[test] fn passthrough_bytea_hex_text() { - let out = pgwire_text_to_param("\\xDEADBEEF", &Type::BYTEA); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == "\\xDEADBEEF")); + let value = pgwire_text_to_param("\\xDEADBEEF", &Type::BYTEA); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == "\\xDEADBEEF")); } #[test] fn int_parse_failure_falls_back_to_text() { - // `abc` isn't a valid INT8 text representation. The function - // preserves the text rather than dropping the binding. - let out = pgwire_text_to_param("abc", &Type::INT8); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == "abc")); + let value = pgwire_text_to_param("abc", &Type::INT8); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == "abc")); } #[test] fn unknown_type_routes_to_text() { - // `Type::UNKNOWN` — the postgres-js fetch_types:false path. - // Text is the correct output: the planner's use-site coercion - // (`coerce::as_usize_literal`, etc.) handles numeric contexts. - let out = pgwire_text_to_param("42", &Type::UNKNOWN); - assert!(matches!(&out, nodedb_sql::ParamValue::Text(s) if s == "42")); + let value = pgwire_text_to_param("42", &Type::UNKNOWN); + assert!(matches!(&value, nodedb_sql::ParamValue::Text(text) if text == "42")); } } diff --git a/nodedb/src/control/server/pgwire/handler/prepared/parser.rs b/nodedb/src/control/server/pgwire/handler/prepared/parser.rs index 84b46312c..f118668a1 100644 --- a/nodedb/src/control/server/pgwire/handler/prepared/parser.rs +++ b/nodedb/src/control/server/pgwire/handler/prepared/parser.rs @@ -12,11 +12,16 @@ use async_trait::async_trait; use pgwire::api::results::{FieldFormat, FieldInfo}; use pgwire::api::stmt::QueryParser; use pgwire::api::{ClientInfo, Type}; -use pgwire::error::PgWireResult; +use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; +use crate::config::auth::AuthMode; +use crate::control::security::audit::ArcAuditEmitter; use crate::control::server::response_shape::types::DdlColType; +use crate::control::server::shared::authorization::{authorize_database, authorize_task_set}; +use crate::control::server::shared::session::SessionStore; use crate::control::state::SharedState; +use super::super::auth::{pgwire_authorization_error, resolve_session_identity}; use super::statement::ParsedStatement; use parser_schema::{ count_placeholders, is_dsl_statement, result_fields_for_returning, @@ -57,11 +62,92 @@ fn ddl_col_type_to_pg(ty: &DdlColType) -> Type { /// from the catalog schema, and computes the result schema. pub struct NodeDbQueryParser { state: Arc, + auth_mode: AuthMode, + sessions: Arc, } impl NodeDbQueryParser { - pub fn new(state: Arc) -> Self { - Self { state } + pub fn new(state: Arc, auth_mode: AuthMode, sessions: Arc) -> Self { + Self { + state, + auth_mode, + sessions, + } + } + + fn placeholder_types(sql: &str, client_types: &[Option]) -> Vec> { + let param_count = count_placeholders(sql); + let mut param_types = vec![None; param_count.max(client_types.len())]; + for (index, client_type) in client_types.iter().enumerate() { + if let Some(client_type) = client_type { + param_types[index] = Some(client_type.clone()); + } + } + param_types + } + + async fn authorize_plannable_sql( + &self, + sql: &str, + identity: &crate::control::security::identity::AuthenticatedIdentity, + database_id: crate::types::DatabaseId, + emitter: &ArcAuditEmitter, + ) -> PgWireResult { + let (sql_without_returning, _) = + match crate::control::server::pgwire::handler::returning::strip_returning(sql) { + Ok(parts) => parts, + Err(_) => return Ok(false), + }; + let sql_for_planning = substitute_placeholders_with_null(&sql_without_returning); + let query_ctx = + crate::control::planner::context::QueryContext::for_state_with_lease(&self.state); + let auth_ctx = crate::control::server::session_auth::build_auth_context(identity); + let permission_cache = self.state.permission_cache.read().await; + let security = crate::control::planner::context::PlanSecurityContext { + identity, + auth: &auth_ctx, + rls_store: &self.state.rls, + permissions: &self.state.permissions, + roles: &self.state.roles, + permission_cache: Some(&*permission_cache), + }; + let Ok((mut tasks, _)) = query_ctx + .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { + sql: &sql_for_planning, + tenant_id: identity.tenant_id, + database_id, + sec: &security, + }) + .await + else { + return Ok(false); + }; + drop(permission_cache); + + crate::control::planner::implicit_edges::append_implicit_edge_tasks( + &self.state, + &mut tasks, + identity.tenant_id, + database_id, + crate::types::TraceId::ZERO, + ) + .await + .map_err(|error| { + PgWireError::UserError(Box::new(ErrorInfo::new( + "ERROR".to_owned(), + "XX000".to_owned(), + error.to_string(), + ))) + })?; + authorize_task_set( + identity, + &tasks, + &self.state.permissions, + &self.state.roles, + emitter, + ) + .map_err(pgwire_authorization_error)?; + Ok(true) } /// Infer parameter and result types using nodedb-sql catalog, scoped to @@ -72,11 +158,12 @@ impl NodeDbQueryParser { sql: &str, client_types: &[Option], tenant_id: u64, + database_id: crate::types::DatabaseId, ) -> (Vec>, Vec) { let catalog = crate::control::planner::catalog_adapter::OriginCatalog::new( Arc::clone(&self.state.credentials), tenant_id, - crate::types::DatabaseId::DEFAULT, + database_id, Some(Arc::clone(&self.state.retention_policy_registry)), ); @@ -84,13 +171,7 @@ impl NodeDbQueryParser { // SQL string (e.g. `WHERE id = $1` where the planner needs bound // params to typecheck) still reports the right number of // parameter slots in Describe. - let param_count = count_placeholders(sql); - let mut param_types = vec![None; param_count.max(client_types.len())]; - for (i, ct) in client_types.iter().enumerate() { - if let Some(t) = ct { - param_types[i] = Some(t.clone()); - } - } + let param_types = Self::placeholder_types(sql, client_types); // Strip RETURNING from DML before passing to DataFusion. Retain the // parsed spec so we can build result fields for Describe. @@ -132,7 +213,7 @@ impl NodeDbQueryParser { crate::control::planner::sql_plan_convert::output_schema::build_output_schema( &plans, &catalog, - crate::types::DatabaseId::DEFAULT, + database_id, ); let result_fields: Vec = output_schema .columns @@ -165,10 +246,24 @@ impl QueryParser for NodeDbQueryParser { where C: ClientInfo + Unpin + Send + Sync, { + let addr = client.socket_addr(); + let identity = resolve_session_identity( + &self.state, + self.auth_mode.clone(), + &self.sessions, + client, + &addr, + )?; + let database_id = self + .sessions + .get_current_database(&addr) + .unwrap_or(crate::types::DatabaseId::DEFAULT); + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_database(&identity, database_id, &emitter).map_err(pgwire_authorization_error)?; + // Wire-streaming COPY shapes for backup/restore: bypass nodedb-sql - // entirely. The Execute handler intercepts these via - // `control::backup::detect`. Returning early avoids a fruitless - // sqlparser pass on syntax it doesn't model. + // entirely. Authorization still precedes this early return so a denied + // Parse cannot create a statement that later reaches Execute. if crate::control::backup::detect(sql).is_some() { return Ok(ParsedStatement { sql: sql.to_owned(), @@ -178,28 +273,14 @@ impl QueryParser for NodeDbQueryParser { }); } - // Resolve the connecting user's tenant from pgwire metadata so - // parse-time catalog lookups are scoped to the right tenant. - // Unknown users fall back to tenant 1 only during bootstrap - // (credential store empty) — otherwise parse-time inference - // returns empty field info, which is the safe default. - let tenant_id = client - .metadata() - .get("user") - .and_then(|u| { - self.state - .credentials - .to_identity(u, crate::control::security::identity::AuthMethod::Trust) - .or_else(|| { - self.state.credentials.to_identity( - u, - crate::control::security::identity::AuthMethod::ScramSha256, - ) - }) - }) - .map(|id| id.tenant_id.as_u64()) - .unwrap_or(1); - let (param_types, result_fields) = self.try_infer_types(sql, types, tenant_id); + let can_infer_schema = self + .authorize_plannable_sql(sql, &identity, database_id, &emitter) + .await?; + let (param_types, result_fields) = if can_infer_schema { + self.try_infer_types(sql, types, identity.tenant_id.as_u64(), database_id) + } else { + (Self::placeholder_types(sql, types), Vec::new()) + }; // If type inference produced no result fields and the SQL matches a // known DSL prefix, mark the statement as a DSL passthrough. The diff --git a/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs index 3f19a090e..e551a1d6e 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/calvin_dispatch.rs @@ -117,7 +117,7 @@ impl NodeDbPgHandler { tenant_id, database_id, result_formats, - )); + )?); } return Ok(calvin_responses); } @@ -178,7 +178,7 @@ impl NodeDbPgHandler { tenant_id, database_id, result_formats, - )); + )?); } Ok(calvin_responses) } diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch/dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch/dispatch.rs index dc2c13d41..f94302821 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch/dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_dispatch/dispatch.rs @@ -16,7 +16,7 @@ use pgwire::error::{ErrorInfo, PgWireError, PgWireResult}; use crate::control::clone::resolver::{ CloneReadParams, ResolveOutcome, filter_tombstoned_rows, resolve_read, }; -use crate::control::server::pgwire::handler::plan::{PlanKind, payload_to_response}; +use crate::control::server::pgwire::handler::plan::{PlanKind, multirow_payload_to_response}; use crate::control::server::pgwire::handler::shape_encode; use crate::control::server::response_shape::compose::{self, ShapeOutcome}; use crate::control::server::response_shape::kv::apply_kv_wrap; @@ -40,6 +40,7 @@ impl NodeDbPgHandler { pub(in crate::control::server::pgwire::handler::routing) async fn maybe_dispatch_clone_reads( &self, tasks: Vec, + identity: &crate::control::security::identity::AuthenticatedIdentity, tenant_id: TenantId, addr: &std::net::SocketAddr, projection: Option<&OutputSchema>, @@ -103,7 +104,7 @@ impl NodeDbPgHandler { Ok(Some(vec![response])) } ShapeOutcome::Passthrough => { - let shaped = payload_to_response(&empty, PlanKind::MultiRow); + let shaped = multirow_payload_to_response(&empty); if let Some(notice) = shaped.notice { self.sessions.push_notice(addr, notice); } @@ -126,6 +127,11 @@ impl NodeDbPgHandler { ); } + // Clone resolution adds source-database tasks after the initial + // authorization pass. Re-authorize the complete augmented set + // before either half can be dispatched. + self.authorize_tasks(identity, &tasks)?; + // Split tasks into target and source halves. let (target_tasks, source_tasks) = tasks.split_at(source_start_idx); @@ -293,8 +299,7 @@ impl NodeDbPgHandler { pg_responses.push(response); } ShapeOutcome::Passthrough => { - let shaped = - payload_to_response(resp.payload.as_ref(), PlanKind::MultiRow); + let shaped = multirow_payload_to_response(resp.payload.as_ref()); if let Some(notice) = shaped.notice { self.sessions.push_notice(addr, notice); } diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/document.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/document.rs index 4af9ec8dc..8cfe0eaef 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/document.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/document.rs @@ -10,9 +10,13 @@ use nodedb_types::{CloneStatus, Lsn, TenantId}; use crate::control::clone::copyup::{CopyUpParams, perform_clone_copyup}; use crate::control::clone::tombstone::{TombstoneParams, perform_clone_tombstone}; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::{AuthenticatedIdentity, Permission}; +use crate::control::server::shared::authorization::authorize_collection; use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan}; use nodedb_physical::physical_task::PhysicalTask; +use super::super::super::auth::pgwire_authorization_error; use super::super::super::core::NodeDbPgHandler; use super::entry::CloneWriteOutcome; use super::probes::{fetch_source_row, probe_row_in_target}; @@ -23,6 +27,7 @@ impl NodeDbPgHandler { pub(super) async fn intercept_doc_clone_write( &self, task: &PhysicalTask, + identity: &AuthenticatedIdentity, tenant_id: TenantId, ) -> PgWireResult { let (collection_qualified, document_id, surrogate, is_delete) = match &task.plan { @@ -61,6 +66,18 @@ impl NodeDbPgHandler { CloneStatus::Shadowed | CloneStatus::Materializing { .. } => {} } + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_collection( + identity, + origin.source_database, + &origin.source_collection, + Permission::Read, + &self.state.permissions, + &self.state.roles, + &emitter, + ) + .map_err(pgwire_authorization_error)?; + let row_in_target = probe_row_in_target( &self.state, tenant_id, diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/entry.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/entry.rs index f4befd6ef..74934858d 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/entry.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/entry.rs @@ -8,6 +8,7 @@ use pgwire::error::PgWireResult; use nodedb_types::TenantId; use crate::bridge::envelope::Response; +use crate::control::security::identity::AuthenticatedIdentity; use nodedb_physical::physical_plan::{DocumentOp, KvOp, PhysicalPlan}; use nodedb_physical::physical_task::PhysicalTask; @@ -30,15 +31,18 @@ impl NodeDbPgHandler { pub(in crate::control::server::pgwire::handler::routing) async fn maybe_intercept_clone_write( &self, task: &PhysicalTask, + identity: &AuthenticatedIdentity, tenant_id: TenantId, ) -> PgWireResult { match &task.plan { PhysicalPlan::Document(DocumentOp::PointUpdate { .. }) | PhysicalPlan::Document(DocumentOp::PointDelete { .. }) => { - self.intercept_doc_clone_write(task, tenant_id).await + self.intercept_doc_clone_write(task, identity, tenant_id) + .await } PhysicalPlan::Kv(KvOp::FieldSet { .. }) | PhysicalPlan::Kv(KvOp::Delete { .. }) => { - self.intercept_kv_clone_write(task, tenant_id).await + self.intercept_kv_clone_write(task, identity, tenant_id) + .await } _ => Ok(CloneWriteOutcome::Passthrough), } diff --git a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/kv.rs b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/kv.rs index 1233a3382..f7aa90fa2 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/kv.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/clone_write_dispatch/kv.rs @@ -10,10 +10,14 @@ use nodedb_types::{CloneStatus, Lsn, TenantId}; use crate::control::clone::copyup::{KvCopyUpParams, perform_kv_clone_copyup}; use crate::control::clone::tombstone::{KvTombstoneParams, perform_kv_clone_tombstone}; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::{AuthenticatedIdentity, Permission}; +use crate::control::server::shared::authorization::authorize_collection; use crate::types::VShardId; use nodedb_physical::physical_plan::{KvOp, PhysicalPlan}; use nodedb_physical::physical_task::PhysicalTask; +use super::super::super::auth::pgwire_authorization_error; use super::super::super::core::NodeDbPgHandler; use super::entry::CloneWriteOutcome; use super::probes::{dispatch_data_plane_raw, fetch_kv_source_value, probe_kv_key_in_target}; @@ -24,6 +28,7 @@ impl NodeDbPgHandler { pub(super) async fn intercept_kv_clone_write( &self, task: &PhysicalTask, + identity: &AuthenticatedIdentity, tenant_id: TenantId, ) -> PgWireResult { let (collection_qualified, kv_key, is_delete) = match &task.plan { @@ -45,14 +50,26 @@ impl NodeDbPgHandler { let Some(desc) = desc else { return Ok(CloneWriteOutcome::Passthrough); }; - if desc.cloned_from.is_none() { + let Some(ref origin) = desc.cloned_from else { return Ok(CloneWriteOutcome::Passthrough); - } + }; match desc.clone_status { CloneStatus::Materialized => return Ok(CloneWriteOutcome::Passthrough), CloneStatus::Shadowed | CloneStatus::Materializing { .. } => {} } + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_collection( + identity, + origin.source_database, + &origin.source_collection, + Permission::Read, + &self.state.permissions, + &self.state.roles, + &emitter, + ) + .map_err(pgwire_authorization_error)?; + // Split each key into one of two paths: // • key absent in target (source-only) → record a tombstone // so future scans hide the source row. @@ -137,6 +154,18 @@ impl NodeDbPgHandler { CloneStatus::Shadowed | CloneStatus::Materializing { .. } => {} } + let emitter = ArcAuditEmitter(Arc::clone(&self.state.audit)); + authorize_collection( + identity, + origin.source_database, + &origin.source_collection, + Permission::Read, + &self.state.permissions, + &self.state.roles, + &emitter, + ) + .map_err(pgwire_authorization_error)?; + // FieldSet is not a delete. let _ = is_delete; diff --git a/nodedb/src/control/server/pgwire/handler/routing/cluster_array.rs b/nodedb/src/control/server/pgwire/handler/routing/cluster_array.rs index 21e764e0c..8d3db4d4f 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/cluster_array.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/cluster_array.rs @@ -77,7 +77,7 @@ impl NodeDbPgHandler { Ok(response) } ShapeOutcome::Passthrough => { - let shaped = payload_to_response(&payload_bytes, cluster_plan_kind); + let shaped = payload_to_response(&payload_bytes, cluster_plan_kind)?; if let Some(notice) = shaped.notice { self.sessions.push_notice(addr, notice); } diff --git a/nodedb/src/control/server/pgwire/handler/routing/execute.rs b/nodedb/src/control/server/pgwire/handler/routing/execute.rs index c52fc5eef..2314c4819 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/execute.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/execute.rs @@ -19,18 +19,10 @@ use super::super::core::NodeDbPgHandler; use super::super::plan::{describe_plan, payload_to_response}; use super::super::shape_encode; use super::planning::consistency_for_tasks; +use super::result_shaping::ResultShaping; use super::set_ops; use crate::control::server::response_shape::schema::OutputSchema; -/// Result-shaping inputs for the prepared/extended execution path: the -/// Describe-supplied output schema (when present) and the client's per-column -/// result formats (empty = all text). -#[derive(Clone, Copy)] -pub(in crate::control::server::pgwire::handler) struct ResultShaping<'a> { - pub projection: Option<&'a OutputSchema>, - pub formats: &'a [FieldFormat], -} - impl NodeDbPgHandler { /// Plan and dispatch SQL after quota and DDL checks have passed. /// @@ -51,10 +43,7 @@ impl NodeDbPgHandler { tenant_id: TenantId, addr: &std::net::SocketAddr, ) -> PgWireResult> { - // The planner's authoritative output schema is computed inside - // `execute_planned_sql_inner` and used to shape/project every SELECT-read - // producer's response via the neutral shaping core, so there is no - // post-hoc reproject seam here. + // Planner output shapes every SELECT-read response through the neutral core. // Simple query has no Bind message, so no client-requested result // formats: everything renders in text. self.execute_planned_sql_inner( @@ -132,6 +121,10 @@ impl NodeDbPgHandler { ))) })?; + // The final task set must be authorized before any clone interception, + // orchestration, staging, or dispatch path can observe it. + self.authorize_tasks(identity, &tasks)?; + // Clone CoW read-path interception: for Shadowed/Materializing clones, // augment tasks with source-database reads and merge results. // Returns Some(responses) when clone dispatch is fully handled. @@ -139,6 +132,7 @@ impl NodeDbPgHandler { if let Some(clone_responses) = self .maybe_dispatch_clone_reads( tasks.clone(), + identity, tenant_id, addr, effective_schema, @@ -289,8 +283,6 @@ impl NodeDbPgHandler { )))); } - self.check_permission(identity, &task.plan)?; - // ClusterArray plans are handled entirely on the Control Plane by the // ArrayCoordinator — they must never reach the SPSC bridge or // trigger/DML machinery. Intercept them here and short-circuit. @@ -483,7 +475,7 @@ impl NodeDbPgHandler { responses.push(response); } ShapeOutcome::Passthrough => { - let shaped = payload_to_response(&resp.payload, plan_kind); + let shaped = payload_to_response(&resp.payload, plan_kind)?; if let Some(notice) = shaped.notice { self.sessions.push_notice(addr, notice); } diff --git a/nodedb/src/control/server/pgwire/handler/routing/execute_dml_hooks.rs b/nodedb/src/control/server/pgwire/handler/routing/execute_dml_hooks.rs index df9ff7520..775a584b3 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/execute_dml_hooks.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/execute_dml_hooks.rs @@ -241,7 +241,10 @@ impl NodeDbPgHandler { // Non-cloned collections and Materialized clones short-circuit here. { use super::clone_write_dispatch::CloneWriteOutcome; - match self.maybe_intercept_clone_write(&task, tenant_id).await? { + match self + .maybe_intercept_clone_write(&task, identity, tenant_id) + .await? + { CloneWriteOutcome::Handled(resp) => { use crate::control::server::response_shape::compose::{ ShapeOutcome, shape_payload_no_plan, @@ -265,7 +268,7 @@ impl NodeDbPgHandler { crate::control::server::pgwire::handler::plan::payload_to_response( resp.payload.as_ref(), plan_kind, - ); + )?; if let Some(notice) = shaped.notice { self.sessions.push_notice(addr, notice); } diff --git a/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs b/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs index c3aef9864..35197b92f 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/gateway_dispatch.rs @@ -21,7 +21,7 @@ use crate::types::{ReadConsistency, TenantId, TraceId}; use nodedb_physical::physical_task::PhysicalTask; use super::super::core::NodeDbPgHandler; -use super::super::plan::{PlanKind, payload_to_response}; +use super::super::plan::{PlanKind, multirow_payload_to_response}; use super::super::shape_encode; impl NodeDbPgHandler { @@ -134,8 +134,7 @@ impl NodeDbPgHandler { responses.push(response); } ShapeOutcome::Passthrough => { - responses - .push(payload_to_response(payload, PlanKind::MultiRow).response); + responses.push(multirow_payload_to_response(payload).response); } } } diff --git a/nodedb/src/control/server/pgwire/handler/routing/mod.rs b/nodedb/src/control/server/pgwire/handler/routing/mod.rs index d9f7b039f..136fd20f8 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/mod.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/mod.rs @@ -18,5 +18,6 @@ pub(in crate::control::server::pgwire::handler) mod execute; mod execute_dml_hooks; mod gateway_dispatch; mod planning; +pub(in crate::control::server::pgwire::handler) mod result_shaping; mod set_ops; mod streaming; diff --git a/nodedb/src/control/server/pgwire/handler/routing/planning.rs b/nodedb/src/control/server/pgwire/handler/routing/planning.rs index c8b48d98c..47fa2af7b 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/planning.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/planning.rs @@ -387,7 +387,7 @@ pub(super) fn calvin_execution_response( tenant_id: TenantId, database_id: crate::types::DatabaseId, formats: &[pgwire::api::results::FieldFormat], -) -> pgwire::api::results::Response { +) -> pgwire::error::PgWireResult { use super::super::plan::{calvin_tag_for_plan, is_calvin_foldable}; use crate::control::server::response_shape::compose::{ ShapeOutcome, shape_response_materialized, @@ -409,7 +409,7 @@ pub(super) fn calvin_execution_response( { let (response, _notice) = super::super::shape_encode::shaped_query_response(shaped, formats); - return response; + return Ok(response); } // Plain (non-RETURNING) write with a deposited applied Response: surface its @@ -419,17 +419,17 @@ pub(super) fn calvin_execution_response( if let Some(resp) = apply_resp && let PlanKind::DmlResult(_) = describe_plan(&task.plan) { - return super::super::plan::payload_to_response( + return Ok(super::super::plan::payload_to_response( resp.payload.as_bytes(), describe_plan(&task.plan), - ) - .response; + )? + .response); } let tag = if is_calvin_foldable(&task.plan) { - calvin_tag_for_plan(&task.plan) + calvin_tag_for_plan(&task.plan)? } else { Tag::new("OK") }; - pgwire::api::results::Response::Execution(tag) + Ok(pgwire::api::results::Response::Execution(tag)) } diff --git a/nodedb/src/control/server/pgwire/handler/routing/result_shaping.rs b/nodedb/src/control/server/pgwire/handler/routing/result_shaping.rs new file mode 100644 index 000000000..3274b604a --- /dev/null +++ b/nodedb/src/control/server/pgwire/handler/routing/result_shaping.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Result-shaping inputs for simple and prepared pgwire execution. + +use pgwire::api::results::FieldFormat; + +use crate::control::server::response_shape::schema::OutputSchema; + +#[derive(Clone, Copy)] +pub(in crate::control::server::pgwire::handler) struct ResultShaping<'a> { + pub projection: Option<&'a OutputSchema>, + pub formats: &'a [FieldFormat], +} diff --git a/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs b/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs index 15dadc359..a843776e2 100644 --- a/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs +++ b/nodedb/src/control/server/pgwire/handler/routing/set_ops.rs @@ -11,7 +11,7 @@ use nodedb_physical::physical_task::PostSetOp; use crate::control::server::response_shape::compose::{self, ShapeOutcome}; use crate::control::server::response_shape::schema::OutputSchema; -use super::super::plan::{PlanKind, payload_to_response}; +use super::super::plan::{PlanKind, multirow_payload_to_response}; use super::super::shape_encode; /// Apply set operation merging to collected sub-query payloads, then shape and @@ -34,7 +34,7 @@ pub(super) fn apply_set_ops( match compose::shape_payload_no_plan(&merged, PlanKind::MultiRow, projection) { ShapeOutcome::Rows(shaped) => shape_encode::shaped_query_response(shaped, result_formats), ShapeOutcome::Passthrough => { - let shaped = payload_to_response(&merged, PlanKind::MultiRow); + let shaped = multirow_payload_to_response(&merged); (shaped.response, shaped.notice) } } diff --git a/nodedb/src/control/server/pgwire/handler/session_explain.rs b/nodedb/src/control/server/pgwire/handler/session_explain.rs index 3c2dbe488..47023811f 100644 --- a/nodedb/src/control/server/pgwire/handler/session_explain.rs +++ b/nodedb/src/control/server/pgwire/handler/session_explain.rs @@ -100,6 +100,8 @@ impl NodeDbPgHandler { ))) })?; + self.authorize_tasks(identity, &tasks)?; + let schema = Arc::new(vec![text_field("QUERY PLAN")]); let mut rows = Vec::new(); let mut encoder = DataRowEncoder::new(schema.clone()); diff --git a/nodedb/src/control/server/session_auth/identity.rs b/nodedb/src/control/server/session_auth/identity.rs index c78f29692..19b27163e 100644 --- a/nodedb/src/control/server/session_auth/identity.rs +++ b/nodedb/src/control/server/session_auth/identity.rs @@ -21,22 +21,19 @@ pub fn resolve_certificate_identity( peer_addr: &str, ) -> crate::Result { // Map cert CN to username (direct mapping: CN = username). - let identity = state - .credentials - .to_identity(cn, AuthMethod::Certificate) - .ok_or_else(|| { - state.audit_record( - AuditEvent::AuthFailure, - None, - peer_addr, - &format!("mTLS auth failed: no user for cert CN '{cn}'"), - ); - state.auth_metrics.record_auth_failure("certificate"); - crate::Error::RejectedAuthz { - tenant_id: TenantId::new(0), - resource: format!("no user mapped to certificate CN '{cn}'"), - } - })?; + let identity = stored_user_identity(state, cn, AuthMethod::Certificate).ok_or_else(|| { + state.audit_record( + AuditEvent::AuthFailure, + None, + peer_addr, + &format!("mTLS auth failed: no user for cert CN '{cn}'"), + ); + state.auth_metrics.record_auth_failure("certificate"); + crate::Error::RejectedAuthz { + tenant_id: TenantId::new(0), + resource: format!("no user mapped to certificate CN '{cn}'"), + } + })?; state.audit_record( AuditEvent::AuthSuccess, @@ -73,6 +70,24 @@ fn build_owner_database_set(state: &SharedState, user: &UserRecord) -> DatabaseS DatabaseSet::Some(SmallVec::from_iter(db_ids)) } +/// Build a session identity from a persisted user, including live database grants. +/// +/// [`CredentialStore::to_identity`](crate::control::security::credential::CredentialStore::to_identity) +/// only materializes credential fields. Session bind must additionally resolve the +/// user's default database and the current `_system.database_grants` set. +pub fn stored_user_identity( + state: &SharedState, + username: &str, + method: AuthMethod, +) -> Option { + let user = state.credentials.get_user(username)?; + let mut identity = state.credentials.to_identity(username, method)?; + identity.default_database = + (user.default_database_id != 0).then(|| DatabaseId::new(user.default_database_id)); + identity.accessible_databases = build_owner_database_set(state, &user); + Some(identity) +} + /// Verify an API key token and build an authenticated identity. /// /// Shared by native protocol and HTTP API authentication paths. @@ -124,7 +139,7 @@ pub fn verify_api_key_identity( /// /// Used by both explicit auth requests and auto-auth on first frame. pub fn trust_identity(state: &SharedState, username: &str) -> AuthenticatedIdentity { - if let Some(id) = state.credentials.to_identity(username, AuthMethod::Trust) { + if let Some(id) = stored_user_identity(state, username, AuthMethod::Trust) { id } else { AuthenticatedIdentity { diff --git a/nodedb/src/control/server/shared/authorization/mod.rs b/nodedb/src/control/server/shared/authorization/mod.rs index d3a9d5d86..8e20252b5 100644 --- a/nodedb/src/control/server/shared/authorization/mod.rs +++ b/nodedb/src/control/server/shared/authorization/mod.rs @@ -6,4 +6,4 @@ pub mod service; pub use error::AuthorizationError; pub use requirements::{AuthorizationRequirement, plan_requirements}; -pub use service::{authorize_database, authorize_task_set}; +pub use service::{authorize_collection, authorize_database, authorize_task_set}; diff --git a/nodedb/src/control/server/shared/authorization/requirements.rs b/nodedb/src/control/server/shared/authorization/requirements.rs index d275726b9..9f7518b6b 100644 --- a/nodedb/src/control/server/shared/authorization/requirements.rs +++ b/nodedb/src/control/server/shared/authorization/requirements.rs @@ -43,5 +43,99 @@ impl AuthorizationRequirement { } #[cfg(test)] -#[path = "requirements/tests.rs"] -mod tests; +mod tests { + use super::*; + use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, KvOp, MetaOp, QueryOp}; + + #[test] + fn insert_select_requires_source_read_and_target_write() { + let plan = crate::bridge::envelope::PhysicalPlan::Document(DocumentOp::InsertSelect { + target_collection: "target".into(), + source_collection: "source".into(), + source_filters: Vec::new(), + source_limit: 0, + }); + + assert_eq!( + plan_requirements(&plan), + vec![ + AuthorizationRequirement::collection("source", Permission::Read), + AuthorizationRequirement::collection("target", Permission::Write), + ] + ); + } + + fn provider_scan(provider: Option<&str>) -> crate::bridge::envelope::PhysicalPlan { + crate::bridge::envelope::PhysicalPlan::Query(QueryOp::ProviderScan { + provider: provider.map(str::to_owned), + rows: Vec::new(), + filters: Vec::new(), + projection: Vec::new(), + sort_keys: Vec::new(), + limit: None, + offset: 0, + distinct: false, + }) + } + + #[test] + fn resource_less_provider_scan_requires_tenant_permission() { + assert_eq!( + plan_requirements(&provider_scan(Some("_system.audit_log"))), + vec![AuthorizationRequirement::collection( + "_system.audit_log", + Permission::Read, + )] + ); + assert_eq!( + plan_requirements(&provider_scan(None)), + vec![AuthorizationRequirement::tenant(Permission::Read)] + ); + } + + #[test] + fn nested_resource_less_plan_keeps_tenant_fallback() { + let plan = crate::bridge::envelope::PhysicalPlan::Meta(MetaOp::TransactionBatch { + plans: vec![provider_scan(None)], + txn_id: None, + }); + assert_eq!( + plan_requirements(&plan), + vec![AuthorizationRequirement::tenant(Permission::Read)] + ); + } + + #[test] + fn crdt_constraint_reads_remain_collection_scoped() { + let plan = crate::bridge::envelope::PhysicalPlan::Crdt(CrdtOp::ReadConstraints { + collection: "documents".into(), + }); + assert_eq!( + plan_requirements(&plan), + vec![AuthorizationRequirement::collection( + "documents", + Permission::Read, + )] + ); + } + + #[test] + fn nested_collection_plan_does_not_require_tenant_permission() { + let plan = crate::bridge::envelope::PhysicalPlan::Meta(MetaOp::TransactionBatch { + plans: vec![crate::bridge::envelope::PhysicalPlan::Kv(KvOp::Get { + collection: "orders".into(), + key: Vec::new(), + rls_filters: Vec::new(), + surrogate_ceiling: None, + })], + txn_id: None, + }); + assert_eq!( + plan_requirements(&plan), + vec![AuthorizationRequirement::collection( + "orders", + Permission::Read, + )] + ); + } +} diff --git a/nodedb/src/control/server/shared/authorization/requirements/collect.rs b/nodedb/src/control/server/shared/authorization/requirements/collect.rs index d116cf823..826a20377 100644 --- a/nodedb/src/control/server/shared/authorization/requirements/collect.rs +++ b/nodedb/src/control/server/shared/authorization/requirements/collect.rs @@ -6,7 +6,7 @@ use super::AuthorizationRequirement; use super::order::requirement_order; use super::query::collect_query_requirements; -/// Return every collection requirement for `plan`. +/// Return every authorization requirement for `plan`. /// /// The general plan permission applies to ordinary single-resource plans. The /// multi-resource cases below intentionally override it so sources require @@ -122,13 +122,11 @@ fn collect_requirements(plan: &PhysicalPlan, out: &mut Vec { - add_tenant_requirement(required_permission(plan), out); for nested in plans { pending.push(nested); } } PhysicalPlan::Meta(MetaOp::StageWrite { plan: nested }) => { - add_tenant_requirement(required_permission(plan), out); pending.push(nested); } PhysicalPlan::Kv(KvOp::Transfer { collection, .. }) => { @@ -180,15 +178,45 @@ fn collect_requirements(plan: &PhysicalPlan, out: &mut Vec add_general_requirements(plan, out), } - // A plan without a collection name is not public: it still needs the - // permission implied by its physical operation at tenant scope. This also - // covers provider-materialized and array-backed plans. - if out.len() == initial_len { - add_tenant_requirement(required_permission(plan), out); + // A wrapper delegates its resource boundary to its nested plans. Its + // own missing collection must not add a tenant-wide requirement when a + // descendant names the protected resource; a genuinely resource-less + // descendant still receives this fail-closed fallback when visited. + if out.len() == initial_len && requires_tenant_fallback(plan) { + out.push(AuthorizationRequirement::tenant(required_permission(plan))); } } } +fn requires_tenant_fallback(plan: &PhysicalPlan) -> bool { + use nodedb_physical::physical_plan::{MetaOp, QueryOp}; + + match plan { + PhysicalPlan::Query(QueryOp::Exchange(_)) + | PhysicalPlan::Meta(MetaOp::StageWrite { .. }) => false, + PhysicalPlan::Meta( + MetaOp::TransactionBatch { plans, .. } + | MetaOp::ResolveTxn { plans, .. } + | MetaOp::RecordCalvinWriteVersions { plans, .. } + | MetaOp::CalvinExecuteStatic { plans, .. } + | MetaOp::CalvinExecuteActive { plans, .. }, + ) => plans.is_empty(), + PhysicalPlan::Vector(_) + | PhysicalPlan::Graph(_) + | PhysicalPlan::Document(_) + | PhysicalPlan::Kv(_) + | PhysicalPlan::Text(_) + | PhysicalPlan::Columnar(_) + | PhysicalPlan::Timeseries(_) + | PhysicalPlan::Spatial(_) + | PhysicalPlan::Crdt(_) + | PhysicalPlan::Query(_) + | PhysicalPlan::Meta(_) + | PhysicalPlan::Array(_) + | PhysicalPlan::ClusterArray(_) => true, + } +} + pub(super) fn add_general_requirements( plan: &PhysicalPlan, out: &mut Vec, @@ -209,13 +237,6 @@ pub(super) fn add_collection_requirement( } } -pub(super) fn add_tenant_requirement( - permission: Permission, - out: &mut Vec, -) { - out.push(AuthorizationRequirement::tenant(permission)); -} - pub(super) fn add_read(collection: &str, out: &mut Vec) { add_collection_requirement(collection, Permission::Read, out); } diff --git a/nodedb/src/control/server/shared/authorization/requirements/tests.rs b/nodedb/src/control/server/shared/authorization/requirements/tests.rs deleted file mode 100644 index 8ff9c6d0f..000000000 --- a/nodedb/src/control/server/shared/authorization/requirements/tests.rs +++ /dev/null @@ -1,92 +0,0 @@ -use super::*; -use nodedb_physical::physical_plan::{CrdtOp, DocumentOp, KvOp, MetaOp, QueryOp}; - -#[test] -fn insert_select_requires_source_read_and_target_write() { - let plan = crate::bridge::envelope::PhysicalPlan::Document(DocumentOp::InsertSelect { - target_collection: "target".into(), - source_collection: "source".into(), - source_filters: Vec::new(), - source_limit: 0, - }); - - assert_eq!( - plan_requirements(&plan), - vec![ - AuthorizationRequirement::collection("source", Permission::Read), - AuthorizationRequirement::collection("target", Permission::Write), - ] - ); -} - -#[test] -fn provider_scan_preserves_only_named_provider() { - let named = crate::bridge::envelope::PhysicalPlan::Query(QueryOp::ProviderScan { - provider: Some("_system.audit_log".into()), - rows: Vec::new(), - filters: Vec::new(), - projection: Vec::new(), - sort_keys: Vec::new(), - limit: None, - offset: 0, - distinct: false, - }); - let materialized = crate::bridge::envelope::PhysicalPlan::Query(QueryOp::ProviderScan { - provider: None, - rows: Vec::new(), - filters: Vec::new(), - projection: Vec::new(), - sort_keys: Vec::new(), - limit: None, - offset: 0, - distinct: false, - }); - - assert_eq!( - plan_requirements(&named), - vec![AuthorizationRequirement::collection( - "_system.audit_log", - Permission::Read, - )] - ); - assert_eq!( - plan_requirements(&materialized), - vec![AuthorizationRequirement::tenant(Permission::Read)] - ); -} - -#[test] -fn crdt_constraint_reads_remain_collection_scoped() { - let plan = crate::bridge::envelope::PhysicalPlan::Crdt(CrdtOp::ReadConstraints { - collection: "documents".into(), - }); - - assert_eq!( - plan_requirements(&plan), - vec![AuthorizationRequirement::collection( - "documents", - Permission::Read, - )] - ); -} - -#[test] -fn transaction_batch_checks_its_tenant_scope_and_nested_resources() { - let plan = crate::bridge::envelope::PhysicalPlan::Meta(MetaOp::TransactionBatch { - plans: vec![crate::bridge::envelope::PhysicalPlan::Kv(KvOp::Get { - collection: "orders".into(), - key: Vec::new(), - rls_filters: Vec::new(), - surrogate_ceiling: None, - })], - txn_id: None, - }); - - assert_eq!( - plan_requirements(&plan), - vec![ - AuthorizationRequirement::tenant(Permission::Write), - AuthorizationRequirement::collection("orders", Permission::Read), - ] - ); -} diff --git a/nodedb/src/control/server/shared/authorization/service.rs b/nodedb/src/control/server/shared/authorization/service.rs index fb4907829..2918c5de8 100644 --- a/nodedb/src/control/server/shared/authorization/service.rs +++ b/nodedb/src/control/server/shared/authorization/service.rs @@ -36,6 +36,32 @@ pub fn authorize_database( ) } +/// Authorize one collection operation before work that precedes physical planning. +/// +/// Trigger-capable DML uses this early gate to prevent unauthorized callers from +/// firing triggers or consuming sequence values. The final planned task set must +/// still be authorized separately because it can contain additional resources. +pub fn authorize_collection( + identity: &AuthenticatedIdentity, + database_id: DatabaseId, + collection: &str, + permission: Permission, + permissions: &PermissionStore, + roles: &RoleStore, + emitter: &dyn AuditEmitter, +) -> Result<(), AuthorizationError> { + authorize_database(identity, database_id, emitter)?; + authorize_collection_requirement( + identity, + database_id, + collection, + permission, + permissions, + roles, + emitter, + ) +} + /// Authorize an entire physical task set before any task is dispatched. /// /// Every task must belong to the authenticated tenant and selected database. @@ -225,5 +251,126 @@ fn deny( } #[cfg(test)] -#[path = "service/tests.rs"] -mod tests; +mod tests { + use super::*; + use crate::bridge::envelope::PhysicalPlan; + use crate::control::security::identity::{AuthMethod, DatabaseSet}; + use crate::types::VShardId; + use nodedb_physical::physical_plan::KvOp; + + fn identity(roles: Vec, databases: DatabaseSet) -> AuthenticatedIdentity { + AuthenticatedIdentity { + user_id: 7, + username: "reader".into(), + tenant_id: TenantId::new(9), + auth_method: AuthMethod::Trust, + roles, + is_superuser: false, + default_database: None, + accessible_databases: databases, + } + } + + fn read_task() -> PhysicalTask { + PhysicalTask { + tenant_id: TenantId::new(9), + database_id: DatabaseId::DEFAULT, + vshard_id: VShardId::new(0), + plan: PhysicalPlan::Kv(KvOp::Get { + collection: "orders".into(), + key: Vec::new(), + rls_filters: Vec::new(), + surrogate_ceiling: None, + }), + post_set_op: nodedb_physical::physical_task::PostSetOp::None, + txn_id: None, + } + } + + #[test] + fn database_scope_denial_is_typed() { + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + let error = authorize_database(&id, DatabaseId::new(2), &NoopAuditEmitter) + .expect_err("database outside identity scope must be denied"); + assert!(error.resource().contains("database")); + } + + #[test] + fn explicit_collection_grant_is_accepted() { + let permissions = PermissionStore::new(); + let roles = RoleStore::new(); + permissions + .grant( + "collection:9:orders", + "user:reader", + Permission::Read, + "admin", + None, + ) + .expect("in-memory grant must succeed"); + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + + assert!( + authorize_task_set(&id, &[read_task()], &permissions, &roles, &NoopAuditEmitter,) + .is_ok() + ); + } + + #[test] + fn task_set_fails_closed_when_a_resource_is_missing_permission() { + let id = identity( + Vec::new(), + DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), + ); + assert!( + authorize_task_set( + &id, + &[read_task()], + &PermissionStore::new(), + &RoleStore::new(), + &NoopAuditEmitter, + ) + .is_err() + ); + } + + #[test] + fn system_collection_and_wrong_database_role_are_denied() { + let permissions = PermissionStore::new(); + let roles = RoleStore::new(); + let id = identity( + vec![Role::DatabaseReader(DatabaseId::new(3))], + DatabaseSet::Some(smallvec::smallvec![DatabaseId::new(3), DatabaseId::new(4)]), + ); + assert!( + authorize_collection_requirement( + &id, + DatabaseId::new(3), + "_SyStEm.audit_log", + Permission::Read, + &permissions, + &roles, + &NoopAuditEmitter, + ) + .is_err() + ); + assert!( + authorize_collection_requirement( + &id, + DatabaseId::new(4), + "orders", + Permission::Read, + &permissions, + &roles, + &NoopAuditEmitter, + ) + .is_err() + ); + } +} diff --git a/nodedb/src/control/server/shared/authorization/service/tests.rs b/nodedb/src/control/server/shared/authorization/service/tests.rs deleted file mode 100644 index 3db96e064..000000000 --- a/nodedb/src/control/server/shared/authorization/service/tests.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::*; -use crate::bridge::envelope::PhysicalPlan; -use crate::control::security::identity::{AuthMethod, DatabaseSet, Permission}; -use crate::types::VShardId; -use nodedb_physical::physical_plan::KvOp; - -fn identity(roles: Vec, databases: DatabaseSet) -> AuthenticatedIdentity { - AuthenticatedIdentity { - user_id: 7, - username: "reader".into(), - tenant_id: TenantId::new(9), - auth_method: AuthMethod::Trust, - roles, - is_superuser: false, - default_database: None, - accessible_databases: databases, - } -} - -#[test] -fn database_scope_denial_is_typed() { - let id = identity( - Vec::new(), - DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), - ); - let error = authorize_database(&id, DatabaseId::new(2), &NoopAuditEmitter) - .expect_err("database outside identity scope must be denied"); - assert!(error.resource().contains("database")); -} - -#[test] -fn explicit_collection_grant_is_accepted() { - let permissions = PermissionStore::new(); - let roles = RoleStore::new(); - permissions - .grant( - "collection:9:orders", - "user:reader", - Permission::Read, - "admin", - None, - ) - .expect("in-memory grant must succeed"); - let id = identity( - Vec::new(), - DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), - ); - assert!( - authorize_collection_requirement( - &id, - DatabaseId::DEFAULT, - "orders", - Permission::Read, - &permissions, - &roles, - &NoopAuditEmitter, - ) - .is_ok() - ); -} - -#[test] -fn task_set_fails_closed_when_a_resource_is_missing_permission() { - let permissions = PermissionStore::new(); - let roles = RoleStore::new(); - let id = identity( - Vec::new(), - DatabaseSet::Some(smallvec::smallvec![DatabaseId::DEFAULT]), - ); - let tasks = vec![PhysicalTask { - tenant_id: TenantId::new(9), - database_id: DatabaseId::DEFAULT, - vshard_id: VShardId::new(0), - plan: PhysicalPlan::Kv(KvOp::Get { - collection: "orders".into(), - key: Vec::new(), - rls_filters: Vec::new(), - surrogate_ceiling: None, - }), - post_set_op: nodedb_physical::physical_task::PostSetOp::None, - txn_id: None, - }]; - - assert!(authorize_task_set(&id, &tasks, &permissions, &roles, &NoopAuditEmitter).is_err()); -} - -#[test] -fn system_collection_and_wrong_database_role_are_denied() { - let permissions = PermissionStore::new(); - let roles = RoleStore::new(); - let id = identity( - vec![Role::DatabaseReader(DatabaseId::new(3))], - DatabaseSet::Some(smallvec::smallvec![DatabaseId::new(3), DatabaseId::new(4)]), - ); - assert!( - authorize_collection_requirement( - &id, - DatabaseId::new(3), - "_SyStEm.audit_log", - Permission::Read, - &permissions, - &roles, - &NoopAuditEmitter, - ) - .is_err() - ); - assert!( - authorize_collection_requirement( - &id, - DatabaseId::new(4), - "orders", - Permission::Read, - &permissions, - &roles, - &NoopAuditEmitter, - ) - .is_err() - ); -} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/insert.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/insert.rs index d64166254..dbe250c3d 100644 --- a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/insert.rs +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/insert.rs @@ -16,8 +16,8 @@ use crate::control::server::shared::session::DmlTxnCtx; use crate::control::state::SharedState; use super::parse::{ - dispatch_plan, extract_vector_fields, fields_to_insert_sql, parse_write_statement, - plan_and_dispatch, returning_response, + authorize_write_target, dispatch_plan, extract_vector_fields, fields_to_insert_sql, + parse_write_statement, plan_and_dispatch, returning_response, }; use super::triggers::{fire_before_triggers, fire_instead_triggers, fire_sync_after_triggers}; @@ -29,11 +29,15 @@ pub async fn insert_document( sql: &str, txn_ctx: &DmlTxnCtx<'_>, ) -> Option, DdlError>> { - let parsed = match parse_write_statement(state, identity, sql, "INSERT INTO ")? { + let parsed = match parse_write_statement(state, identity, database_id, sql, "INSERT INTO ")? { Ok(p) => p, Err(e) => return Some(Err(e)), }; + if let Err(error) = authorize_write_target(state, identity, database_id, &parsed.coll_name) { + return Some(Err(error)); + } + let tenant_id = identity.tenant_id; // Fire INSTEAD OF INSERT triggers — if handled, skip normal dispatch. @@ -265,7 +269,7 @@ pub async fn insert_document( provenance: None, }); - if let Some(err) = dispatch_plan(state, tenant_id, vec_vshard, vec_plan).await { + if let Some(err) = dispatch_plan(state, identity, database_id, vec_vshard, vec_plan).await { return Some(err); } } diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse.rs index 5c6f3e99c..c753bca13 100644 --- a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse.rs +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse.rs @@ -1,587 +1,19 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Shared parsing, SQL-building, and plan-dispatch helpers for the -//! protocol-neutral INSERT/UPSERT DML handlers. -//! -//! Relocated verbatim from the pgwire `ddl::collection::insert_parse` module -//! (now deleted) except for the result type, which is [`DdlError`] / -//! [`DdlResult`] instead of pgwire `Response` / `PgWireResult`. +//! Protocol-neutral INSERT/UPSERT parsing, SQL generation, response shaping, +//! and dispatch helpers. -use crate::control::security::identity::AuthenticatedIdentity; -use crate::control::server::shared::ddl::result::{DdlError, DdlResult}; -use crate::control::server::shared::ddl::sql_parse::{parse_sql_value, split_values}; -use crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate; -use crate::control::server::shared::session::{ - DmlTxnCtx, InTxnRoute, StagingGateError, route_in_tx_write, -}; -use crate::control::state::SharedState; -use crate::types::DatabaseId; -use crate::types::TraceId; - -/// Parsed INSERT/UPSERT statement fields. -pub(super) struct ParsedInsert { - pub coll_name: String, - pub doc_id: String, - pub fields: std::collections::HashMap, - pub has_returning: bool, - /// Collection type looked up from the catalog. Drives the write plan. - pub collection_type: Option, -} - -pub(super) fn extract_vector_fields( - fields: &std::collections::HashMap, -) -> Vec<(String, Vec)> { - fields - .iter() - .filter_map(|(field_name, value)| match value { - nodedb_types::Value::Array(items) => { - let vector: Vec = items - .iter() - .map(|item| match item { - nodedb_types::Value::Float(v) => Some(*v as f32), - nodedb_types::Value::Integer(v) => Some(*v as f32), - _ => None, - }) - .collect::>>()?; - Some((field_name.clone(), vector)) - } - _ => None, - }) - .collect() -} - -/// Parse an INSERT/UPSERT SQL statement into structured fields. -/// -/// `keyword` is the SQL prefix to match (e.g., "INSERT INTO " or "UPSERT INTO "). -/// Returns `None` if the collection has a typed schema (let the SQL path handle it). -pub(super) fn parse_write_statement( - state: &SharedState, - identity: &AuthenticatedIdentity, - sql: &str, - keyword: &str, -) -> Option> { - let upper = sql.to_uppercase(); - let kw_pos = upper.find(keyword)?; - let after_into = sql[kw_pos + keyword.len()..].trim_start(); - let coll_name_str = after_into.split_whitespace().next()?; - let coll_name = coll_name_str.to_lowercase(); - - // Check if collection is schemaless. Let the SQL path handle typed INSERT - // with VALUES syntax, but always handle here for pre-write concerns: - // - UPSERT (triggers + nodedb-sql handles the routing) - // - { } object literal syntax (triggers + nodedb-sql handles the routing) - let tenant_id = identity.tenant_id; - let is_upsert = keyword.starts_with("UPSERT"); - let after_coll_trimmed = after_into[coll_name_str.len()..].trim_start(); - let is_object_literal = - after_coll_trimmed.starts_with('{') || after_coll_trimmed.starts_with('['); - let mut coll_type: Option = None; - let catalog = state.credentials.catalog(); - if let Ok(Some(coll)) = - catalog.get_collection(DatabaseId::DEFAULT, tenant_id.as_u64(), &coll_name) - { - // Skip non-schemaless collections for standard VALUES INSERT (let SQL path handle). - // But always handle here for: UPSERT, { } object literal (any collection type). - if !is_upsert && !is_object_literal && !coll.collection_type.is_schemaless() { - return None; - } - coll_type = Some(coll.collection_type.clone()); - } - - // Determine which form this statement uses: { } object literal or (cols) VALUES (vals). - // If { }, rewrite to VALUES SQL via nodedb-sql's preprocess, then parse that. - let after_coll_name = after_into[coll_name_str.len()..].trim_start(); - - if after_coll_name.starts_with('{') || after_coll_name.starts_with('[') { - if let Ok(Some(preprocessed)) = nodedb_sql::parser::preprocess::preprocess(sql) { - let rewritten = preprocessed.sql; - let rewritten_upper = rewritten.to_uppercase(); - // The preprocessed SQL is always INSERT INTO regardless of original keyword. - return parse_values_form( - &rewritten, - &rewritten_upper, - "INSERT INTO ", - &coll_name, - coll_type, - ); - } - return Some(Err(ddl_err( - "42601", - "failed to parse object literal in INSERT/UPSERT statement", - ))); - } - - parse_values_form(sql, &upper, keyword, &coll_name, coll_type) -} - -/// Parse the `(cols) VALUES (vals)` form. -fn parse_values_form( - sql: &str, - upper: &str, - keyword: &str, - coll_name: &str, - coll_type: Option, -) -> Option> { - let first_open = match sql.find('(') { - Some(p) => p, - None => { - return Some(Err(ddl_err( - "42601", - format!("missing column list in {}", keyword.trim()), - ))); - } - }; - let values_kw = match upper.find("VALUES") { - Some(p) => p, - None => { - return Some(Err(ddl_err("42601", "missing VALUES clause"))); - } - }; - let first_close = match sql[first_open..values_kw].rfind(')') { - Some(p) => first_open + p, - None => { - return Some(Err(ddl_err("42601", "missing closing ) for column list"))); - } - }; - let cols_str = &sql[first_open + 1..first_close]; - let columns: Vec<&str> = cols_str.split(',').map(|c| c.trim()).collect(); - - let after_values = sql[values_kw + 6..].trim_start(); - let vals_open = match after_values.find('(') { - Some(p) => p, - None => { - return Some(Err(ddl_err("42601", "missing VALUES (...)"))); - } - }; - let vals_close = match after_values.rfind(')') { - Some(p) => p, - None => { - return Some(Err(ddl_err("42601", "missing closing ) for VALUES"))); - } - }; - let vals_str = &after_values[vals_open + 1..vals_close]; - let values: Vec<&str> = split_values(vals_str); - - if columns.len() != values.len() { - return Some(Err(ddl_err( - "42601", - format!( - "column count ({}) doesn't match value count ({})", - columns.len(), - values.len() - ), - ))); - } - - let mut doc_id = String::new(); - let mut fields = std::collections::HashMap::new(); - - for (col, val) in columns.iter().zip(values.iter()) { - let col = col.trim().trim_matches('"'); - let val = val.trim(); - if col.eq_ignore_ascii_case("id") - || col.eq_ignore_ascii_case("document_id") - || col.eq_ignore_ascii_case("key") - { - doc_id = val.trim_matches('\'').to_string(); - } - fields.insert(col.to_string(), parse_sql_value(val)); - } - - if doc_id.is_empty() { - doc_id = nodedb_types::id_gen::uuid_v7(); - } - - let has_returning = upper.contains("RETURNING"); - - Some(Ok(ParsedInsert { - coll_name: coll_name.to_string(), - doc_id, - fields, - has_returning, - collection_type: coll_type, - })) -} - -/// Format a RETURNING response from parsed fields. -pub(super) fn returning_response( - doc_id: &str, - fields: &std::collections::HashMap, -) -> Result, DdlError> { - use crate::control::server::response_shape::types::{DdlColType, ShapedRows}; - use serde_json::{Map, Value as JsonValue}; - - let mut result_doc = fields.clone(); - result_doc.insert( - "id".to_string(), - nodedb_types::Value::String(doc_id.to_string()), - ); - let json_str = - sonic_rs::to_string(&nodedb_types::Value::Object(result_doc)).unwrap_or_default(); - - let mut row = Map::new(); - row.insert("result".to_string(), JsonValue::String(json_str)); - - Ok(vec![DdlResult::Rows(ShapedRows { - columns: vec!["result".to_string()], - column_types: vec![DdlColType::Text], - rows: vec![row], - notice: None, - })]) -} - -/// Dispatch a plan to WAL + Data Plane, returning an error response on failure. -pub(super) async fn dispatch_plan( - state: &SharedState, - tenant_id: nodedb_types::TenantId, - vshard_id: crate::types::VShardId, - plan: crate::bridge::envelope::PhysicalPlan, -) -> Option, DdlError>> { - // The WAL append is performed inside the dispatch core, under the - // write-admission guard and just before the enqueue, so LSN order matches - // apply order. - if let Err(e) = crate::control::server::dispatch_utils::dispatch_autocommit_write( - state, - crate::control::server::dispatch_utils::AutocommitWrite { - tenant_id, - database_id: crate::types::DatabaseId::DEFAULT, - vshard_id, - plan, - trace_id: TraceId::ZERO, - event_source: crate::event::EventSource::User, - txn_id: None, - }, - ) - .await - { - return Some(Err(ddl_err("XX000", e.to_string()))); - } - None -} - -/// Quote a user-supplied map key for safe inclusion as a SQL column -/// identifier. Object-literal keys come from untrusted client payloads; -/// concatenating them raw into generated SQL allows a crafted key to -/// close the column list and inject arbitrary statements. Double-quoted -/// identifiers are the SQL standard form; embedded double quotes are -/// escaped by doubling. -fn quote_column_identifier(key: &str) -> String { - format!("\"{}\"", key.replace('"', "\"\"")) -} - -/// Build a SQL INSERT statement from field map. -/// -/// Produces `INSERT INTO coll ("col1", "col2") VALUES ('val1', 'val2')`. -/// Column identifiers are double-quoted so that map keys containing -/// punctuation, whitespace, or SQL syntax are treated as a single -/// identifier by the downstream parser instead of fragmenting the -/// statement. -pub(in crate::control::server::shared::ddl::neutral::collection) fn fields_to_insert_sql( - collection: &str, - fields: &std::collections::HashMap, -) -> String { - let mut cols = Vec::with_capacity(fields.len()); - let mut vals = Vec::with_capacity(fields.len()); - - let mut entries: Vec<_> = fields.iter().collect(); - entries.sort_by_key(|(k, _)| k.as_str()); +mod dispatch; +mod encoding; +mod response; +mod statement; +mod types; - for (key, value) in entries { - cols.push(quote_column_identifier(key)); - vals.push(value_to_sql_literal(value)); - } - - format!( - "INSERT INTO {} ({}) VALUES ({})", - collection, - cols.join(", "), - vals.join(", ") - ) -} - -/// Build a SQL UPSERT statement from field map. See -/// `fields_to_insert_sql` for the identifier quoting rationale. -pub(super) fn fields_to_upsert_sql( - collection: &str, - fields: &std::collections::HashMap, -) -> String { - let mut cols = Vec::with_capacity(fields.len()); - let mut vals = Vec::with_capacity(fields.len()); - - let mut entries: Vec<_> = fields.iter().collect(); - entries.sort_by_key(|(k, _)| k.as_str()); - - for (key, value) in entries { - cols.push(quote_column_identifier(key)); - vals.push(value_to_sql_literal(value)); - } - - format!( - "UPSERT INTO {} ({}) VALUES ({})", - collection, - cols.join(", "), - vals.join(", ") - ) -} - -/// Delegate to the shared implementation in nodedb-sql. -fn value_to_sql_literal(value: &nodedb_types::Value) -> String { - nodedb_sql::parser::preprocess::value_to_sql_literal(value) -} - -/// Plan SQL through nodedb-sql and dispatch the resulting physical plans. -/// -/// This is the shared path: SQL → nodedb-sql → EngineRules → SqlPlan → PhysicalPlan → dispatch. -/// Returns `Ok(())` on success, or a protocol-neutral error on failure. -pub(in crate::control::server::shared::ddl::neutral::collection) async fn plan_and_dispatch( - state: &SharedState, - _identity: &AuthenticatedIdentity, - tenant_id: nodedb_types::TenantId, - database_id: crate::types::DatabaseId, - sql: &str, - txn_ctx: &DmlTxnCtx<'_>, -) -> Result<(), DdlError> { - let query_ctx = crate::control::planner::context::QueryContext::for_state(state); - let (mut tasks, _output_schema) = query_ctx - .plan_sql(sql, tenant_id, database_id) - .await - .map_err(|e| ddl_err("XX000", e.to_string()))?; - - // Schemaless INSERT / UPSERT / object-literal documents carrying `_from` - // / `_to` mirror an implicit graph edge. Extract it here — the same as the - // main SQL and native surfaces — so the edge routes through the shared - // dispatch path (Calvin dual-home cross-shard, single-home otherwise) - // instead of the removed Data-Plane hook. - crate::control::planner::implicit_edges::append_implicit_edge_tasks( - state, - &mut tasks, - tenant_id, - database_id, - TraceId::ZERO, - ) - .await - .map_err(|e| ddl_err("XX000", e.to_string()))?; - - // A cross-shard write (e.g. a doc + its dual-homed implicit edge, or a - // multi-row insert spanning vShards) must commit atomically through the - // Calvin sequencer when one is wired (cluster). Single-node has no sequencer, - // so fall through to the per-task local dispatch below — every vShard is - // local there, so a single-home edge is still reachable by forward traversal. - if state.sequencer_inbox.get().is_some() - && matches!( - crate::control::planner::calvin::classify_dispatch( - &tasks, - &std::collections::BTreeSet::new(), - ), - crate::control::planner::calvin::DispatchClass::MultiShard { .. } - ) - { - crate::control::planner::calvin::dispatch_tasks_to_calvin( - state, - &tasks, - tenant_id, - crate::control::planner::calvin::CrossShardTxnMode::Strict, - false, - ) - .await - .map_err(|e| ddl_err("XX000", e.to_string()))?; - return Ok(()); - } - - for task in tasks { - let task_vshard_id = task.vshard_id; - let task_database_id = task.database_id; - - let routed = route_in_tx_write(txn_ctx.sessions, txn_ctx.addr, task, |staged| { - crate::control::server::dispatch_utils::dispatch_to_data_plane_with_txn( - state, - staged.tenant_id, - staged.database_id, - staged.vshard_id, - staged.plan, - TraceId::ZERO, - staged.txn_id, - ) - }) - .await; - - let task = match routed { - Ok(InTxnRoute::Read(task)) => *task, - Ok(InTxnRoute::Buffered) => continue, - // Staged into the per-transaction overlay + buffered for COMMIT - // replay; `plan_and_dispatch` returns `()` on success so the - // affected count has no return path here (its callers already - // build their own "OK" result independent of row counts). - Ok(InTxnRoute::Staged(_outcome)) => continue, - Err(StagingGateError::Dispatch(e)) => { - return Err(ddl_err("XX000", e.to_string())); - } - Err(StagingGateError::Rejected { code }) => { - let (_, sqlstate, message) = match code { - Some(code) => error_code_to_sqlstate(&code), - None => ("ERROR", "XX000", "unknown data plane error".to_owned()), - }; - return Err(ddl_err(sqlstate, message)); - } - }; - - // Not in a transaction block (or a read): the immediate autocommit path. - // The WAL append is performed inside the dispatch core, under the - // write-admission guard and just before the enqueue, so LSN order matches - // apply order. - let response = crate::control::server::dispatch_utils::dispatch_autocommit_write( - state, - crate::control::server::dispatch_utils::AutocommitWrite { - tenant_id, - database_id: task_database_id, - vshard_id: task_vshard_id, - plan: task.plan, - trace_id: TraceId::ZERO, - event_source: crate::event::EventSource::User, - txn_id: None, - }, - ) - .await - .map_err(|e| ddl_err("XX000", e.to_string()))?; - - // Data Plane returns `Ok(Response { status: Error, .. })` for - // constraint violations (UNIQUE, CHECK-at-write, etc.). Surface - // them as errors — without this mapping the Data Plane's - // rejection would be silently swallowed and the write appears - // to succeed at the SQL layer. - if response.status == crate::bridge::envelope::Status::Error { - let detail = match &response.error_code { - Some(crate::bridge::envelope::ErrorCode::Internal { detail, .. }) => detail.clone(), - Some(other) => format!("{other:?}"), - None => String::from_utf8_lossy(&response.payload).into_owned(), - }; - let sqlstate = if detail.to_lowercase().contains("unique") { - "23505" - } else { - "XX000" - }; - return Err(ddl_err(sqlstate, detail)); - } - } - Ok(()) -} - -/// Build a [`DdlError`] from an ANSI SQLSTATE code and a message. -fn ddl_err(sqlstate: &str, message: impl Into) -> DdlError { - DdlError { - sqlstate: sqlstate.to_string(), - message: message.into(), - } -} - -#[cfg(test)] -mod tests { - //! Object-literal rewrite contract tests. - //! - //! Map keys originate from untrusted client input. The SQL builders - //! MUST either reject / quote unsafe identifiers and MUST canonicalize - //! non-finite floats before concatenation. These tests drive the - //! helpers directly to pin the contract; integration tests would - //! mask the bug behind accidental downstream parser rejection. - use super::*; - use std::collections::HashMap; - - fn one_field(key: &str, value: nodedb_types::Value) -> HashMap { - let mut m = HashMap::new(); - m.insert("id".into(), nodedb_types::Value::String("r1".into())); - m.insert(key.into(), value); - m - } - - #[test] - fn upsert_sql_quotes_injection_key_as_single_identifier() { - // Spec: a key containing SQL syntax characters must appear only - // inside a double-quoted identifier, never as bare tokens. The - // test payload contains `);` which, if unquoted, would close - // the column list and start a new statement. - let fields = one_field( - "a); DROP COLLECTION other; --", - nodedb_types::Value::Integer(1), - ); - let sql = fields_to_upsert_sql("t", &fields); - let quoted = "\"a); DROP COLLECTION other; --\""; - assert!( - sql.contains(quoted), - "expected injection key to be safely double-quoted; got {sql}" - ); - // Regression guard on the specific exploit: no bare `DROP` token - // appears outside the quoted identifier window. - let parts: Vec<&str> = sql.split(quoted).collect(); - for p in &parts { - assert!( - !p.contains("DROP"), - "unquoted DROP token leaked outside identifier: {sql}" - ); - } - } - - #[test] - fn insert_sql_quotes_injection_key_as_single_identifier() { - let fields = one_field( - "b); DROP COLLECTION other; --", - nodedb_types::Value::Integer(2), - ); - let sql = fields_to_insert_sql("t", &fields); - let quoted = "\"b); DROP COLLECTION other; --\""; - assert!( - sql.contains(quoted), - "expected injection key to be safely double-quoted; got {sql}" - ); - let parts: Vec<&str> = sql.split(quoted).collect(); - for p in &parts { - assert!( - !p.contains("DROP"), - "unquoted DROP token leaked outside identifier: {sql}" - ); - } - } - - #[test] - fn upsert_sql_escapes_embedded_double_quote_in_key() { - // Spec: a key containing `"` must be escaped by doubling, so the - // quoted-identifier form stays closed correctly. Otherwise the - // key could still terminate the identifier and inject syntax. - let fields = one_field( - "a\"); DROP COLLECTION other; --", - nodedb_types::Value::Integer(1), - ); - let sql = fields_to_upsert_sql("t", &fields); - let escaped = "\"a\"\"); DROP COLLECTION other; --\""; - assert!( - sql.contains(escaped), - "embedded `\"` must be doubled; got {sql}" - ); - } - - #[test] - fn upsert_sql_rejects_or_canonicalizes_nan_float() { - // Spec: `Value::Float(NaN)` must not reach the generated SQL as - // the bare token `NaN`. `format!("{f}")` currently emits `NaN` - // verbatim, which the planner parses as an identifier reference. - let mut fields = HashMap::new(); - fields.insert("id".into(), nodedb_types::Value::String("r1".into())); - fields.insert("score".into(), nodedb_types::Value::Float(f64::NAN)); - let sql = fields_to_upsert_sql("t", &fields); - assert!( - !sql.contains("NaN"), - "non-finite float leaked as bare `NaN` token: {sql}" - ); - } - - #[test] - fn upsert_sql_rejects_or_canonicalizes_infinity_float() { - let mut fields = HashMap::new(); - fields.insert("id".into(), nodedb_types::Value::String("r1".into())); - fields.insert("score".into(), nodedb_types::Value::Float(f64::INFINITY)); - let sql = fields_to_upsert_sql("t", &fields); - assert!( - !sql.contains("inf"), - "non-finite float leaked as bare `inf` token: {sql}" - ); - } -} +pub(in crate::control::server::shared::ddl::neutral::collection) use dispatch::{ + authorize_write_target, dispatch_plan, plan_and_dispatch, +}; +pub(in crate::control::server::shared::ddl::neutral::collection) use encoding::fields_to_insert_sql; +pub(super) use encoding::fields_to_upsert_sql; +pub(super) use response::returning_response; +pub(super) use statement::parse_write_statement; +pub(super) use types::extract_vector_fields; diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/dispatch.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/dispatch.rs new file mode 100644 index 000000000..dec869b5d --- /dev/null +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/dispatch.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::sync::Arc; + +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::{AuthenticatedIdentity, Permission}; +use crate::control::server::shared::authorization::{ + AuthorizationError, authorize_collection, authorize_task_set, +}; +use crate::control::server::shared::ddl::result::{DdlError, DdlResult}; +use crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate; +use crate::control::server::shared::session::{ + DmlTxnCtx, InTxnRoute, StagingGateError, route_in_tx_write, +}; +use crate::control::state::SharedState; +use crate::types::TraceId; + +use super::types::ddl_err; + +/// Dispatch a plan to WAL + Data Plane, returning an error response on failure. +pub(in crate::control::server::shared::ddl::neutral::collection) async fn dispatch_plan( + state: &SharedState, + identity: &AuthenticatedIdentity, + database_id: crate::types::DatabaseId, + vshard_id: crate::types::VShardId, + plan: crate::bridge::envelope::PhysicalPlan, +) -> Option, DdlError>> { + let task = nodedb_physical::physical_task::PhysicalTask { + tenant_id: identity.tenant_id, + database_id, + vshard_id, + plan: plan.clone(), + post_set_op: nodedb_physical::physical_task::PostSetOp::None, + txn_id: None, + }; + if let Err(error) = authorize_final_task_set(state, identity, std::slice::from_ref(&task)) { + return Some(Err(error)); + } + + if let Err(error) = crate::control::server::dispatch_utils::dispatch_autocommit_write( + state, + crate::control::server::dispatch_utils::AutocommitWrite { + tenant_id: identity.tenant_id, + database_id, + vshard_id, + plan, + trace_id: TraceId::ZERO, + event_source: crate::event::EventSource::User, + txn_id: None, + }, + ) + .await + { + return Some(Err(ddl_err("XX000", error.to_string()))); + } + None +} + +/// Authorize a write target before triggers, sequences, or catalog reads run. +pub(in crate::control::server::shared::ddl::neutral::collection) fn authorize_write_target( + state: &SharedState, + identity: &AuthenticatedIdentity, + database_id: crate::types::DatabaseId, + collection: &str, +) -> Result<(), DdlError> { + let emitter = ArcAuditEmitter(Arc::clone(&state.audit)); + authorize_collection( + identity, + database_id, + collection, + Permission::Write, + &state.permissions, + &state.roles, + &emitter, + ) + .map_err(authorization_error_to_ddl) +} + +/// Plan SQL through nodedb-sql, authorize the final task set, and dispatch it. +pub(in crate::control::server::shared::ddl::neutral::collection) async fn plan_and_dispatch( + state: &SharedState, + identity: &AuthenticatedIdentity, + tenant_id: nodedb_types::TenantId, + database_id: crate::types::DatabaseId, + sql: &str, + txn_ctx: &DmlTxnCtx<'_>, +) -> Result<(), DdlError> { + let query_ctx = crate::control::planner::context::QueryContext::for_state(state); + let (mut tasks, _output_schema) = query_ctx + .plan_sql(sql, tenant_id, database_id) + .await + .map_err(|error| ddl_err("XX000", error.to_string()))?; + + // The final set includes implicit graph-edge writes and must be authorized + // before Calvin classification, transaction staging, or local dispatch. + crate::control::planner::implicit_edges::append_implicit_edge_tasks( + state, + &mut tasks, + tenant_id, + database_id, + TraceId::ZERO, + ) + .await + .map_err(|error| ddl_err("XX000", error.to_string()))?; + + authorize_final_task_set(state, identity, &tasks)?; + + if state.sequencer_inbox.get().is_some() + && matches!( + crate::control::planner::calvin::classify_dispatch( + &tasks, + &std::collections::BTreeSet::new(), + ), + crate::control::planner::calvin::DispatchClass::MultiShard { .. } + ) + { + crate::control::planner::calvin::dispatch_tasks_to_calvin( + state, + &tasks, + tenant_id, + crate::control::planner::calvin::CrossShardTxnMode::Strict, + false, + ) + .await + .map_err(|error| ddl_err("XX000", error.to_string()))?; + return Ok(()); + } + + for task in tasks { + let task_vshard_id = task.vshard_id; + let task_database_id = task.database_id; + + let routed = route_in_tx_write(txn_ctx.sessions, txn_ctx.addr, task, |staged| { + crate::control::server::dispatch_utils::dispatch_to_data_plane_with_txn( + state, + staged.tenant_id, + staged.database_id, + staged.vshard_id, + staged.plan, + TraceId::ZERO, + staged.txn_id, + ) + }) + .await; + + let task = match routed { + Ok(InTxnRoute::Read(task)) => *task, + Ok(InTxnRoute::Buffered) | Ok(InTxnRoute::Staged(_)) => continue, + Err(StagingGateError::Dispatch(error)) => { + return Err(ddl_err("XX000", error.to_string())); + } + Err(StagingGateError::Rejected { code }) => { + let (_, sqlstate, message) = match code { + Some(code) => error_code_to_sqlstate(&code), + None => ("ERROR", "XX000", "unknown data plane error".to_owned()), + }; + return Err(ddl_err(sqlstate, message)); + } + }; + + let response = crate::control::server::dispatch_utils::dispatch_autocommit_write( + state, + crate::control::server::dispatch_utils::AutocommitWrite { + tenant_id, + database_id: task_database_id, + vshard_id: task_vshard_id, + plan: task.plan, + trace_id: TraceId::ZERO, + event_source: crate::event::EventSource::User, + txn_id: None, + }, + ) + .await + .map_err(|error| ddl_err("XX000", error.to_string()))?; + + if response.status == crate::bridge::envelope::Status::Error { + let detail = match &response.error_code { + Some(crate::bridge::envelope::ErrorCode::Internal { detail, .. }) => detail.clone(), + Some(other) => format!("{other:?}"), + None => String::from_utf8_lossy(&response.payload).into_owned(), + }; + let sqlstate = if detail.to_lowercase().contains("unique") { + "23505" + } else { + "XX000" + }; + return Err(ddl_err(sqlstate, detail)); + } + } + Ok(()) +} + +fn authorize_final_task_set( + state: &SharedState, + identity: &AuthenticatedIdentity, + tasks: &[nodedb_physical::physical_task::PhysicalTask], +) -> Result<(), DdlError> { + let emitter = ArcAuditEmitter(Arc::clone(&state.audit)); + authorize_task_set(identity, tasks, &state.permissions, &state.roles, &emitter) + .map_err(authorization_error_to_ddl) +} + +fn authorization_error_to_ddl(error: AuthorizationError) -> DdlError { + DdlError { + sqlstate: nodedb_types::error::sqlstate::INSUFFICIENT_PRIVILEGE.to_owned(), + message: error.resource().to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::TenantId; + + #[test] + fn authorization_denial_preserves_insufficient_privilege_sqlstate() { + let error = AuthorizationError::new( + TenantId::new(1), + "permission denied on collection".to_owned(), + ); + let ddl_error = authorization_error_to_ddl(error); + + assert_eq!(ddl_error.sqlstate, "42501"); + assert!(ddl_error.message.contains("permission denied")); + } +} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/encoding.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/encoding.rs new file mode 100644 index 000000000..6e8a8a62a --- /dev/null +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/encoding.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: BUSL-1.1 + +/// Quote a user-supplied map key for safe inclusion as a SQL column +/// identifier. Object-literal keys come from untrusted client payloads; +/// concatenating them raw into generated SQL allows a crafted key to +/// close the column list and inject arbitrary statements. Double-quoted +/// identifiers are the SQL standard form; embedded double quotes are +/// escaped by doubling. +fn quote_column_identifier(key: &str) -> String { + format!("\"{}\"", key.replace('"', "\"\"")) +} + +/// Build a SQL INSERT statement from field map. +/// +/// Produces `INSERT INTO coll ("col1", "col2") VALUES ('val1', 'val2')`. +/// Column identifiers are double-quoted so that map keys containing +/// punctuation, whitespace, or SQL syntax are treated as a single +/// identifier by the downstream parser instead of fragmenting the +/// statement. +pub(in crate::control::server::shared::ddl::neutral::collection) fn fields_to_insert_sql( + collection: &str, + fields: &std::collections::HashMap, +) -> String { + fields_to_write_sql("INSERT INTO", collection, fields) +} + +/// Build a SQL UPSERT statement from field map. See +/// `fields_to_insert_sql` for the identifier quoting rationale. +pub(in crate::control::server::shared::ddl::neutral::collection) fn fields_to_upsert_sql( + collection: &str, + fields: &std::collections::HashMap, +) -> String { + fields_to_write_sql("UPSERT INTO", collection, fields) +} + +fn fields_to_write_sql( + keyword: &str, + collection: &str, + fields: &std::collections::HashMap, +) -> String { + let mut cols = Vec::with_capacity(fields.len()); + let mut vals = Vec::with_capacity(fields.len()); + let mut entries: Vec<_> = fields.iter().collect(); + entries.sort_by_key(|(key, _)| key.as_str()); + + for (key, value) in entries { + cols.push(quote_column_identifier(key)); + vals.push(value_to_sql_literal(value)); + } + + format!( + "{keyword} {} ({}) VALUES ({})", + collection, + cols.join(", "), + vals.join(", ") + ) +} + +/// Delegate to the shared implementation in nodedb-sql. +fn value_to_sql_literal(value: &nodedb_types::Value) -> String { + nodedb_sql::parser::preprocess::value_to_sql_literal(value) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn one_field(key: &str, value: nodedb_types::Value) -> HashMap { + let mut fields = HashMap::new(); + fields.insert("id".into(), nodedb_types::Value::String("r1".into())); + fields.insert(key.into(), value); + fields + } + + #[test] + fn upsert_sql_quotes_injection_key_as_single_identifier() { + let fields = one_field( + "a); DROP COLLECTION other; --", + nodedb_types::Value::Integer(1), + ); + let sql = fields_to_upsert_sql("t", &fields); + let quoted = "\"a); DROP COLLECTION other; --\""; + assert!(sql.contains(quoted)); + for part in sql.split(quoted) { + assert!(!part.contains("DROP")); + } + } + + #[test] + fn insert_sql_quotes_injection_key_as_single_identifier() { + let fields = one_field( + "b); DROP COLLECTION other; --", + nodedb_types::Value::Integer(2), + ); + let sql = fields_to_insert_sql("t", &fields); + let quoted = "\"b); DROP COLLECTION other; --\""; + assert!(sql.contains(quoted)); + for part in sql.split(quoted) { + assert!(!part.contains("DROP")); + } + } + + #[test] + fn upsert_sql_escapes_embedded_double_quote_in_key() { + let fields = one_field( + "a\"); DROP COLLECTION other; --", + nodedb_types::Value::Integer(1), + ); + let sql = fields_to_upsert_sql("t", &fields); + assert!(sql.contains("\"a\"\"); DROP COLLECTION other; --\"")); + } + + #[test] + fn upsert_sql_canonicalizes_nan_float() { + let fields = one_field("score", nodedb_types::Value::Float(f64::NAN)); + let sql = fields_to_upsert_sql("t", &fields); + assert!(!sql.contains("NaN")); + } + + #[test] + fn upsert_sql_canonicalizes_infinity_float() { + let fields = one_field("score", nodedb_types::Value::Float(f64::INFINITY)); + let sql = fields_to_upsert_sql("t", &fields); + assert!(!sql.contains("inf")); + } +} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/response.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/response.rs new file mode 100644 index 000000000..41833c2ac --- /dev/null +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/response.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use crate::control::server::shared::ddl::result::{DdlError, DdlResult}; + +/// Format a RETURNING response from parsed fields. +pub(in crate::control::server::shared::ddl::neutral::collection) fn returning_response( + doc_id: &str, + fields: &std::collections::HashMap, +) -> Result, DdlError> { + use crate::control::server::response_shape::types::{DdlColType, ShapedRows}; + use serde_json::{Map, Value as JsonValue}; + + let mut result_doc = fields.clone(); + result_doc.insert( + "id".to_string(), + nodedb_types::Value::String(doc_id.to_string()), + ); + let json_str = + sonic_rs::to_string(&nodedb_types::Value::Object(result_doc)).unwrap_or_default(); + + let mut row = Map::new(); + row.insert("result".to_string(), JsonValue::String(json_str)); + + Ok(vec![DdlResult::Rows(ShapedRows { + columns: vec!["result".to_string()], + column_types: vec![DdlColType::Text], + rows: vec![row], + notice: None, + })]) +} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/statement.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/statement.rs new file mode 100644 index 000000000..f7fdb8f56 --- /dev/null +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/statement.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::collections::HashMap; + +use crate::control::security::identity::AuthenticatedIdentity; +use crate::control::server::shared::ddl::result::DdlError; +use crate::control::server::shared::ddl::sql_parse::{parse_sql_value, split_values}; +use crate::control::state::SharedState; +use crate::types::DatabaseId; + +use super::types::{ParsedInsert, ddl_err}; + +/// Parse an INSERT/UPSERT SQL statement into structured fields. +/// +/// `keyword` is the SQL prefix to match (e.g., "INSERT INTO " or "UPSERT INTO "). +/// Returns `None` if the collection has a typed schema (let the SQL path handle it). +pub(in crate::control::server::shared::ddl::neutral::collection) fn parse_write_statement( + state: &SharedState, + identity: &AuthenticatedIdentity, + database_id: DatabaseId, + sql: &str, + keyword: &str, +) -> Option> { + let upper = sql.to_uppercase(); + let kw_pos = upper.find(keyword)?; + let after_into = sql[kw_pos + keyword.len()..].trim_start(); + let coll_name_str = after_into.split_whitespace().next()?; + let coll_name = coll_name_str.to_lowercase(); + + // Check if collection is schemaless. Let the SQL path handle typed INSERT + // with VALUES syntax, but always handle here for pre-write concerns: + // - UPSERT (triggers + nodedb-sql handles the routing) + // - { } object literal syntax (triggers + nodedb-sql handles the routing) + let tenant_id = identity.tenant_id; + let is_upsert = keyword.starts_with("UPSERT"); + let after_coll_trimmed = after_into[coll_name_str.len()..].trim_start(); + let is_object_literal = + after_coll_trimmed.starts_with('{') || after_coll_trimmed.starts_with('['); + let mut coll_type: Option = None; + let catalog = state.credentials.catalog(); + if let Ok(Some(coll)) = catalog.get_collection(database_id, tenant_id.as_u64(), &coll_name) { + // Skip non-schemaless collections for standard VALUES INSERT (let SQL path handle). + // But always handle here for: UPSERT, { } object literal (any collection type). + if !is_upsert && !is_object_literal && !coll.collection_type.is_schemaless() { + return None; + } + coll_type = Some(coll.collection_type.clone()); + } + + // Determine which form this statement uses: { } object literal or (cols) VALUES (vals). + // If { }, rewrite to VALUES SQL via nodedb-sql's preprocess, then parse that. + let after_coll_name = after_into[coll_name_str.len()..].trim_start(); + if after_coll_name.starts_with('{') || after_coll_name.starts_with('[') { + if let Ok(Some(preprocessed)) = nodedb_sql::parser::preprocess::preprocess(sql) { + let rewritten = preprocessed.sql; + let rewritten_upper = rewritten.to_uppercase(); + // The preprocessed SQL is always INSERT INTO regardless of original keyword. + return parse_values_form( + &rewritten, + &rewritten_upper, + "INSERT INTO ", + &coll_name, + coll_type, + ); + } + return Some(Err(ddl_err( + "42601", + "failed to parse object literal in INSERT/UPSERT statement", + ))); + } + + parse_values_form(sql, &upper, keyword, &coll_name, coll_type) +} + +/// Parse the `(cols) VALUES (vals)` form. +fn parse_values_form( + sql: &str, + upper: &str, + keyword: &str, + coll_name: &str, + coll_type: Option, +) -> Option> { + let first_open = match sql.find('(') { + Some(p) => p, + None => { + return Some(Err(ddl_err( + "42601", + format!("missing column list in {}", keyword.trim()), + ))); + } + }; + let values_kw = match upper.find("VALUES") { + Some(p) => p, + None => return Some(Err(ddl_err("42601", "missing VALUES clause"))), + }; + let first_close = match sql[first_open..values_kw].rfind(')') { + Some(p) => first_open + p, + None => { + return Some(Err(ddl_err("42601", "missing closing ) for column list"))); + } + }; + let cols_str = &sql[first_open + 1..first_close]; + let columns: Vec<&str> = cols_str.split(',').map(|c| c.trim()).collect(); + + let after_values = sql[values_kw + 6..].trim_start(); + let vals_open = match after_values.find('(') { + Some(p) => p, + None => return Some(Err(ddl_err("42601", "missing VALUES (...)"))), + }; + let vals_close = match after_values.rfind(')') { + Some(p) => p, + None => return Some(Err(ddl_err("42601", "missing closing ) for VALUES"))), + }; + let vals_str = &after_values[vals_open + 1..vals_close]; + let values: Vec<&str> = split_values(vals_str); + + if columns.len() != values.len() { + return Some(Err(ddl_err( + "42601", + format!( + "column count ({}) doesn't match value count ({})", + columns.len(), + values.len() + ), + ))); + } + + let mut doc_id = String::new(); + let mut fields = HashMap::new(); + for (col, val) in columns.iter().zip(values.iter()) { + let col = col.trim().trim_matches('"'); + let val = val.trim(); + if col.eq_ignore_ascii_case("id") + || col.eq_ignore_ascii_case("document_id") + || col.eq_ignore_ascii_case("key") + { + doc_id = val.trim_matches('\'').to_string(); + } + fields.insert(col.to_string(), parse_sql_value(val)); + } + + if doc_id.is_empty() { + doc_id = nodedb_types::id_gen::uuid_v7(); + } + + Some(Ok(ParsedInsert { + coll_name: coll_name.to_string(), + doc_id, + fields, + has_returning: upper.contains("RETURNING"), + collection_type: coll_type, + })) +} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/types.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/types.rs new file mode 100644 index 000000000..6ad099079 --- /dev/null +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/parse/types.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::collections::HashMap; + +use crate::control::server::shared::ddl::result::DdlError; + +/// Parsed INSERT/UPSERT statement fields. +pub(in crate::control::server::shared::ddl::neutral::collection) struct ParsedInsert { + pub coll_name: String, + pub doc_id: String, + pub fields: HashMap, + pub has_returning: bool, + /// Collection type looked up from the catalog. Drives the write plan. + pub collection_type: Option, +} + +pub(in crate::control::server::shared::ddl::neutral::collection) fn extract_vector_fields( + fields: &HashMap, +) -> Vec<(String, Vec)> { + fields + .iter() + .filter_map(|(field_name, value)| match value { + nodedb_types::Value::Array(items) => { + let vector: Vec = items + .iter() + .map(|item| match item { + nodedb_types::Value::Float(v) => Some(*v as f32), + nodedb_types::Value::Integer(v) => Some(*v as f32), + _ => None, + }) + .collect::>>()?; + Some((field_name.clone(), vector)) + } + _ => None, + }) + .collect() +} + +/// Build a [`DdlError`] from an ANSI SQLSTATE code and a message. +pub(super) fn ddl_err(sqlstate: &str, message: impl Into) -> DdlError { + DdlError { + sqlstate: sqlstate.to_string(), + message: message.into(), + } +} diff --git a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/upsert.rs b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/upsert.rs index df53e03bd..eefb6faa9 100644 --- a/nodedb/src/control/server/shared/ddl/neutral/collection/dml/upsert.rs +++ b/nodedb/src/control/server/shared/ddl/neutral/collection/dml/upsert.rs @@ -14,7 +14,9 @@ use crate::control::server::shared::ddl::sqlstate::error_code_to_sqlstate; use crate::control::server::shared::session::DmlTxnCtx; use crate::control::state::SharedState; -use super::parse::{fields_to_upsert_sql, parse_write_statement, plan_and_dispatch}; +use super::parse::{ + authorize_write_target, fields_to_upsert_sql, parse_write_statement, plan_and_dispatch, +}; use super::triggers::{ fire_before_triggers, fire_instead_triggers, fire_sync_after_triggers, fire_sync_after_update_triggers, @@ -31,11 +33,15 @@ pub async fn upsert_document( sql: &str, txn_ctx: &DmlTxnCtx<'_>, ) -> Option, DdlError>> { - let parsed = match parse_write_statement(state, identity, sql, "UPSERT INTO ")? { + let parsed = match parse_write_statement(state, identity, database_id, sql, "UPSERT INTO ")? { Ok(p) => p, Err(e) => return Some(Err(e)), }; + if let Err(error) = authorize_write_target(state, identity, database_id, &parsed.coll_name) { + return Some(Err(error)); + } + let tenant_id = identity.tenant_id; // Fire INSTEAD OF INSERT triggers (upsert treated as INSERT for triggers). diff --git a/nodedb/tests/http_query_authorization.rs b/nodedb/tests/http_query_authorization.rs index faea7a82f..6bacf4e6c 100644 --- a/nodedb/tests/http_query_authorization.rs +++ b/nodedb/tests/http_query_authorization.rs @@ -10,8 +10,10 @@ use std::time::Duration; use common::pgwire_harness::TestServer; use nodedb::config::auth::AuthMode; use nodedb::control::security::apikey::CreateKeyParams; +use nodedb::control::security::audit::NoopAuditEmitter; use nodedb::control::security::identity::{Permission, Role}; use nodedb::control::security::permission::collection_target; +use nodedb::control::server::session_auth::verify_api_key_identity; use nodedb::control::state::SharedState; use nodedb::types::{DatabaseId, TenantId}; @@ -94,10 +96,15 @@ async fn query_rejects_database_outside_api_key_scope() { .await .expect("POST cross-database query"); + let status = response.status(); + let body = response + .text() + .await + .expect("read cross-database authorization response"); assert_eq!( - response.status(), + status, reqwest::StatusCode::FORBIDDEN, - "HTTP queries must enforce the API key's database scope before planning or execution" + "HTTP queries must enforce the API key's database scope before planning or execution; response: {body}" ); } @@ -141,10 +148,91 @@ async fn query_rejects_cross_database_write_without_mutating_target() { rows.is_empty(), "a cross-database write must not mutate the target: {rows:?}" ); + let status = response.status(); + let body = response + .text() + .await + .expect("read cross-database write authorization response"); assert_eq!( - response.status(), + status, + reqwest::StatusCode::FORBIDDEN, + "cross-database HTTP writes must be rejected; response: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_rejects_schemaless_write_without_permission_or_mutation() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION denied_schemaless_writes") + .await + .expect("create denied schemaless collection"); + + let token = create_api_key( + &srv.shared, + "http_ungranted_schemaless_writer", + vec![Role::Custom("http_ungranted_schemaless_writer_role".into())], + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "INSERT INTO denied_schemaless_writes { id: 'forbidden', value: 19 }", + ) + .await; + + let rows = srv + .query_text("SELECT id FROM denied_schemaless_writes") + .await + .expect("inspect denied schemaless collection"); + assert!( + rows.is_empty(), + "an unauthorized DDL-routed write must not mutate the collection: {rows:?}" + ); + + let status = response.status(); + let body = response + .text() + .await + .expect("read schemaless write authorization response"); + assert_eq!( + status, reqwest::StatusCode::FORBIDDEN, - "cross-database HTTP writes must be rejected" + "DDL-routed schemaless writes require authorization before dispatch; response: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn query_authorizes_schemaless_write_before_firing_triggers() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION trigger_guarded_writes") + .await + .expect("create trigger-guarded collection"); + srv.exec( + "CREATE TRIGGER authorization_order BEFORE INSERT ON trigger_guarded_writes \ + FOR EACH ROW BEGIN RAISE EXCEPTION 'trigger fired before authorization'; END", + ) + .await + .expect("create rejecting trigger"); + + let token = create_api_key( + &srv.shared, + "http_trigger_bypass_writer", + vec![Role::Custom("http_trigger_bypass_role".into())], + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let response = post_query( + &http, + &token, + "INSERT INTO trigger_guarded_writes { id: 'forbidden' }", + ) + .await; + + let status = response.status(); + let body = response.text().await.expect("read authorization response"); + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "response: {body}"); + assert!( + body.contains("permission denied") && !body.contains("trigger fired before authorization"), + "authorization must reject before trigger execution: {body}" ); } @@ -189,13 +277,40 @@ async fn query_honors_explicit_collection_grant_for_custom_role() { Some(srv.shared.credentials.catalog()), ) .expect("grant collection read"); - let http = start_authenticated_http(Arc::clone(&srv.shared)).await; + let identity = verify_api_key_identity(&srv.shared, &token, "test", "http") + .expect("resolve granted API-key identity"); + assert_eq!(identity.username, username, "API-key identity username"); + assert_eq!( + identity.tenant_id, + TenantId::new(1), + "API-key identity tenant" + ); + assert!( + identity.can_access_database(DatabaseId::DEFAULT), + "API-key identity must access the selected default database" + ); + assert!( + srv.shared.permissions.check( + &identity, + Permission::Read, + "granted_rows", + &srv.shared.roles, + &NoopAuditEmitter, + ), + "PermissionStore must accept the explicit collection READ grant" + ); + let http = start_authenticated_http(Arc::clone(&srv.shared)).await; let response = post_query(&http, &token, "SELECT * FROM granted_rows").await; + let status = response.status(); + let body = response + .text() + .await + .expect("read HTTP authorization response"); assert_eq!( - response.status(), + status, reqwest::StatusCode::OK, - "HTTP authorization must honor PermissionStore grants before built-in role fallback" + "HTTP authorization must honor PermissionStore grants before built-in role fallback; response: {body}" ); } diff --git a/nodedb/tests/pgwire_database_authorization.rs b/nodedb/tests/pgwire_database_authorization.rs new file mode 100644 index 000000000..53a1df5b3 --- /dev/null +++ b/nodedb/tests/pgwire_database_authorization.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Extended-protocol database authorization and catalog-scope regressions. + +mod common; + +use common::pgwire_harness::TestServer; + +#[tokio::test] +async fn prepared_parse_uses_authorized_session_database_catalog() { + let server = TestServer::start().await; + server + .exec("CREATE DATABASE prepared_scope") + .await + .expect("create database"); + server + .exec("CREATE USER prepared_reader WITH PASSWORD 'x' ROLE readonly") + .await + .expect("create reader"); + server + .exec("GRANT ALL ON DATABASE prepared_scope TO prepared_reader") + .await + .expect("grant database access"); + server + .exec("USE DATABASE prepared_scope") + .await + .expect("select database"); + server + .exec( + "CREATE COLLECTION prepared_rows \ + (id TEXT PRIMARY KEY, content TEXT NOT NULL) \ + WITH (engine='document_strict')", + ) + .await + .expect("create collection in selected database"); + + let (client, _connection) = server + .connect_as_database("prepared_reader", "x", "prepared_scope") + .await + .expect("connect to granted database"); + let statement = client + .prepare("SELECT content FROM prepared_rows") + .await + .expect("Parse/Describe must use the selected database catalog"); + + assert_eq!(statement.columns().len(), 1); + assert_eq!(statement.columns()[0].name(), "content"); +} + +fn assert_insufficient_privilege( + result: Result, tokio_postgres::Error>, +) { + let error = result.expect_err("query must be denied"); + let database_error = error.as_db_error().expect("server must return SQLSTATE"); + assert_eq!(database_error.code().code(), "42501"); +} + +#[tokio::test] +async fn prepared_parse_denies_database_before_describe_metadata() { + let server = TestServer::start().await; + server + .exec("CREATE DATABASE prepared_denied") + .await + .expect("create database"); + server + .exec("CREATE USER denied_reader WITH PASSWORD 'x' ROLE readonly") + .await + .expect("create reader"); + server + .exec("USE DATABASE prepared_denied") + .await + .expect("select database"); + server + .exec( + "CREATE COLLECTION secret_schema \ + (id TEXT PRIMARY KEY, secret TEXT NOT NULL) \ + WITH (engine='document_strict')", + ) + .await + .expect("create secret collection"); + + let (client, _connection) = server + .connect_as_database("denied_reader", "x", "prepared_denied") + .await + .expect("startup may complete before the first statement gate"); + let error = client + .prepare("SELECT secret FROM secret_schema") + .await + .expect_err("Parse must reject the unauthorized database before Describe"); + let database_error = error.as_db_error().expect("server must return SQLSTATE"); + + assert_eq!(database_error.code().code(), "42501"); + assert!(database_error.message().contains("database")); +} + +#[tokio::test] +async fn prepared_parse_denies_collection_before_describe_metadata() { + let server = TestServer::start().await; + server + .exec("CREATE ROLE metadata_denied_role") + .await + .expect("create custom role"); + server + .exec( + "CREATE USER metadata_denied WITH PASSWORD 'x' \ + ROLE metadata_denied_role", + ) + .await + .expect("create ungranted user"); + server + .exec( + "CREATE COLLECTION metadata_secret \ + (id TEXT PRIMARY KEY, secret TEXT NOT NULL) \ + WITH (engine='document_strict')", + ) + .await + .expect("create secret collection"); + + let (client, _connection) = server + .connect_as("metadata_denied", "x") + .await + .expect("connect ungranted user"); + let error = client + .prepare("SELECT secret FROM metadata_secret") + .await + .expect_err("Parse must deny collection access before returning schema"); + + assert_eq!( + error.as_db_error().expect("server SQLSTATE").code().code(), + "42501" + ); +} + +#[tokio::test] +async fn pgwire_interceptors_authorize_before_plan_or_dispatch() { + let server = TestServer::start().await; + server + .exec("CREATE ROLE interceptor_denied_role") + .await + .expect("create custom role"); + server + .exec( + "CREATE USER interceptor_denied WITH PASSWORD 'x' \ + ROLE interceptor_denied_role", + ) + .await + .expect("create ungranted user"); + server + .exec("CREATE COLLECTION interceptor_secret") + .await + .expect("create secret collection"); + + let (client, _connection) = server + .connect_as("interceptor_denied", "x") + .await + .expect("connect ungranted user"); + + assert_insufficient_privilege( + client + .simple_query("LIVE SELECT * FROM interceptor_secret") + .await, + ); + assert_insufficient_privilege( + client + .simple_query("EXPLAIN SELECT * FROM interceptor_secret") + .await, + ); + assert_insufficient_privilege( + client + .simple_query( + "SELECT FACET_COUNTS(collection => 'interceptor_secret', fields => ['kind'])", + ) + .await, + ); + + client + .simple_query("BEGIN") + .await + .expect("begin cursor transaction"); + assert_insufficient_privilege( + client + .simple_query("DECLARE denied_cursor CURSOR FOR SELECT * FROM interceptor_secret") + .await, + ); +} From e7f6322b669377fe1e6b4fc2641f6ecf119017c9 Mon Sep 17 00:00:00 2001 From: Farhan Syah Date: Mon, 13 Jul 2026 23:07:46 +0000 Subject: [PATCH 6/6] feat(security): authorize websocket and native SQL --- .../server/http/routes/ws_rpc/execute_sql.rs | 54 ++++++++++++-- .../server/http/routes/ws_rpc/handler.rs | 50 +++++++++---- .../http/routes/ws_rpc/process_message.rs | 72 +++++++++++++++---- .../src/control/server/native/dispatch/sql.rs | 31 ++++++-- .../server/native/dispatch/sql_admin.rs | 26 ++++++- nodedb/tests/http_ws_authorization.rs | 68 +++++++++++++++++- nodedb/tests/native_sql_authorization.rs | 31 +++++++- 7 files changed, 290 insertions(+), 42 deletions(-) diff --git a/nodedb/src/control/server/http/routes/ws_rpc/execute_sql.rs b/nodedb/src/control/server/http/routes/ws_rpc/execute_sql.rs index c37562288..37c5c40e5 100644 --- a/nodedb/src/control/server/http/routes/ws_rpc/execute_sql.rs +++ b/nodedb/src/control/server/http/routes/ws_rpc/execute_sql.rs @@ -2,9 +2,14 @@ //! SQL execution via the SPSC gateway for WebSocket RPC. +use std::sync::Arc; + use crate::control::gateway::core::QueryContext; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::security::identity::AuthenticatedIdentity; +use crate::control::server::shared::authorization::{authorize_database, authorize_task_set}; use crate::control::state::SharedState; -use crate::types::{TenantId, TraceId}; +use crate::types::{DatabaseId, TraceId}; /// Execute SQL and return result as JSON. /// @@ -14,16 +19,55 @@ use crate::types::{TenantId, TraceId}; pub async fn execute_sql( shared: &SharedState, query_ctx: &crate::control::planner::context::QueryContext, - tenant_id: TenantId, + identity: &AuthenticatedIdentity, + database_id: DatabaseId, sql: &str, trace_id: TraceId, ) -> crate::Result { + let tenant_id = identity.tenant_id; + let emitter = ArcAuditEmitter(Arc::clone(&shared.audit)); + authorize_database(identity, database_id, &emitter)?; + // Quota enforcement — reject before planning or dispatch. shared.check_tenant_quota(tenant_id)?; - let (tasks, _output_schema) = query_ctx - .plan_sql(sql, tenant_id, crate::types::DatabaseId::DEFAULT) + let mut auth_ctx = crate::control::server::session_auth::build_auth_context(identity); + let clean_sql = + crate::control::server::session_auth::extract_and_apply_on_deny(sql, &mut auth_ctx); + let permission_cache = shared.permission_cache.read().await; + let security = crate::control::planner::context::PlanSecurityContext { + identity, + auth: &auth_ctx, + rls_store: &shared.rls, + permissions: &shared.permissions, + roles: &shared.roles, + permission_cache: Some(&*permission_cache), + }; + let (mut tasks, _output_schema) = query_ctx + .plan_sql_with_rls(crate::control::planner::context::PlanSqlWithRlsParams { + sql: &clean_sql, + tenant_id, + database_id, + sec: &security, + }) .await?; + drop(permission_cache); + + crate::control::planner::implicit_edges::append_implicit_edge_tasks( + shared, + &mut tasks, + tenant_id, + database_id, + trace_id, + ) + .await?; + authorize_task_set( + identity, + &tasks, + &shared.permissions, + &shared.roles, + &emitter, + )?; shared.tenant_request_start(tenant_id); @@ -183,7 +227,7 @@ pub async fn execute_sql( let gw_ctx = QueryContext { tenant_id: task.tenant_id, trace_id, - database_id: nodedb_types::id::DatabaseId::DEFAULT, + database_id: task.database_id, }; gw.execute(&gw_ctx, task.plan).await } diff --git a/nodedb/src/control/server/http/routes/ws_rpc/handler.rs b/nodedb/src/control/server/http/routes/ws_rpc/handler.rs index 4c597cdb3..47ba3ed60 100644 --- a/nodedb/src/control/server/http/routes/ws_rpc/handler.rs +++ b/nodedb/src/control/server/http/routes/ws_rpc/handler.rs @@ -29,11 +29,13 @@ use futures::{SinkExt, StreamExt}; use tracing::debug; use crate::control::change_stream::LiveSubscriptionSet; +use crate::control::security::audit::ArcAuditEmitter; use crate::control::server::http::auth::{AppState, ResolvedIdentity}; +use crate::control::server::shared::authorization::authorize_database; use crate::control::state::SharedState; -use crate::types::TenantId; +use crate::types::DatabaseId; -use super::process_message::process_message; +use super::process_message::{MessageContext, process_message}; /// WebSocket upgrade handler. /// @@ -46,17 +48,36 @@ pub async fn ws_handler( headers: HeaderMap, ws: WebSocketUpgrade, State(state): State, -) -> impl IntoResponse { - let tenant_id = identity.tenant_id(); +) -> axum::response::Response { + let identity = identity.0; + let database_id = match super::super::query::resolve_database_id( + &headers, + &super::super::query::DatabaseQueryParam::default(), + &state, + ) { + Ok(database_id) => database_id, + Err(error) => return error.into_response(), + }; + let emitter = ArcAuditEmitter(Arc::clone(&state.shared.audit)); + if let Err(error) = authorize_database(&identity, database_id, &emitter) { + return crate::control::server::http::auth::ApiError::Forbidden( + crate::Error::from(error).to_string(), + ) + .into_response(); + } let trace_id = crate::control::trace_context::extract_from_headers(&headers); - ws.on_upgrade(move |socket| handle_ws_connection(socket, state, tenant_id, trace_id)) + ws.on_upgrade(move |socket| { + handle_ws_connection(socket, state, identity, database_id, trace_id) + }) + .into_response() } /// Handle a single WebSocket connection. async fn handle_ws_connection( socket: WebSocket, state: AppState, - tenant_id: TenantId, + identity: crate::control::security::identity::AuthenticatedIdentity, + database_id: DatabaseId, trace_id: nodedb_types::TraceId, ) { let (mut sender, mut receiver) = socket.split(); @@ -91,16 +112,15 @@ async fn handle_ws_connection( while let Some(Ok(msg)) = receiver.next().await { match msg { Message::Text(text) => { - let (response, auth_session) = process_message( - &shared, - &state.query_ctx, - tenant_id, + let context = MessageContext { + shared: &shared, + query_ctx: &state.query_ctx, + identity: &identity, + database_id, trace_id, - &text, - &live_tx, - &mut live_set, - ) - .await; + live_tx: &live_tx, + }; + let (response, auth_session) = process_message(context, &text, &mut live_set).await; // Record session_id only after process_message confirms auth. if let Some(sid) = auth_session { diff --git a/nodedb/src/control/server/http/routes/ws_rpc/process_message.rs b/nodedb/src/control/server/http/routes/ws_rpc/process_message.rs index ca0f19177..2e99ea1ae 100644 --- a/nodedb/src/control/server/http/routes/ws_rpc/process_message.rs +++ b/nodedb/src/control/server/http/routes/ws_rpc/process_message.rs @@ -6,8 +6,11 @@ use sonic_rs; use tracing::debug; use crate::control::change_stream::LiveSubscriptionSet; +use crate::control::security::audit::{ArcAuditEmitter, NoopAuditEmitter}; +use crate::control::security::identity::{AuthenticatedIdentity, Permission}; +use crate::control::server::shared::authorization::authorize_collection; use crate::control::state::SharedState; -use crate::types::{TenantId, TraceId}; +use crate::types::{DatabaseId, TraceId}; use super::execute_sql::execute_sql; use super::format::{ @@ -24,19 +27,32 @@ pub fn extract_session_id(req: &serde_json::Value) -> Option { .map(String::from) } +pub(super) struct MessageContext<'a> { + pub(super) shared: &'a SharedState, + pub(super) query_ctx: &'a crate::control::planner::context::QueryContext, + pub(super) identity: &'a AuthenticatedIdentity, + pub(super) database_id: DatabaseId, + pub(super) trace_id: TraceId, + pub(super) live_tx: &'a tokio::sync::mpsc::Sender, +} + /// Process a single JSON-RPC message. /// /// Returns `(response_json, Option)`. /// The session_id is `Some` only when an "auth" request succeeds. -pub async fn process_message( - shared: &SharedState, - query_ctx: &crate::control::planner::context::QueryContext, - tenant_id: TenantId, - trace_id: TraceId, +pub(super) async fn process_message( + context: MessageContext<'_>, text: &str, - live_tx: &tokio::sync::mpsc::Sender, live_set: &mut LiveSubscriptionSet, ) -> (String, Option) { + let MessageContext { + shared, + query_ctx, + identity, + database_id, + trace_id, + live_tx, + } = context; let req: serde_json::Value = match sonic_rs::from_str(text) { Ok(v) => v, Err(e) => { @@ -82,7 +98,20 @@ pub async fn process_message( let missed = shared.change_stream.query_changes(None, 0, 10_000); let replay: Vec<_> = missed .iter() - .filter(|e| e.lsn.as_u64() > effective_lsn) + .filter(|event| { + event.lsn.as_u64() > effective_lsn + && event.tenant_id == identity.tenant_id + && authorize_collection( + identity, + database_id, + &event.collection, + Permission::Read, + &shared.permissions, + &shared.roles, + &NoopAuditEmitter, + ) + .is_ok() + }) .collect(); for event in &replay { @@ -120,10 +149,11 @@ pub async fn process_message( return (error_response(id, "missing params.sql"), None); } - let response = match execute_sql(shared, query_ctx, tenant_id, sql, trace_id).await { - Ok(result) => serde_json::json!({"id": id, "result": result}).to_string(), - Err(e) => ws_error_from_gateway(&id, &e), - }; + let response = + match execute_sql(shared, query_ctx, identity, database_id, sql, trace_id).await { + Ok(result) => serde_json::json!({"id": id, "result": result}).to_string(), + Err(e) => ws_error_from_gateway(&id, &e), + }; (response, None) } @@ -143,9 +173,25 @@ pub async fn process_message( ); } + let emitter = ArcAuditEmitter(std::sync::Arc::clone(&shared.audit)); + if let Err(error) = authorize_collection( + identity, + database_id, + &collection, + Permission::Read, + &shared.permissions, + &shared.roles, + &emitter, + ) { + return ( + error_response(id, &crate::Error::from(error).to_string()), + None, + ); + } + let mut sub = shared .change_stream - .subscribe(Some(collection.clone()), Some(tenant_id)); + .subscribe(Some(collection.clone()), Some(identity.tenant_id)); let sub_id = sub.id; let live_tx = live_tx.clone(); diff --git a/nodedb/src/control/server/native/dispatch/sql.rs b/nodedb/src/control/server/native/dispatch/sql.rs index b7fdf4263..d5a430469 100644 --- a/nodedb/src/control/server/native/dispatch/sql.rs +++ b/nodedb/src/control/server/native/dispatch/sql.rs @@ -6,9 +6,13 @@ use nodedb_types::TraceId; use nodedb_types::protocol::NativeResponse; use nodedb_types::value::Value; +use std::sync::Arc; + use crate::control::planner::calvin::{ CrossShardTxnMode, DispatchClass, classify_dispatch, dispatch_tasks_to_calvin, }; +use crate::control::security::audit::ArcAuditEmitter; +use crate::control::server::shared::authorization::{authorize_database, authorize_task_set}; use crate::control::server::shared::session::TransactionState; use super::sql_admin::{handle_explain, handle_set_sql, handle_show_sql, is_session_show}; @@ -129,13 +133,20 @@ async fn handle_sql_inner( return resp(NativeResponse::status_row(seq, "DISCARD ALL")); } + // Every statement that can inspect or mutate database state must pass the + // selected-database gate before EXPLAIN, DDL, planning, or stream creation. + let database_id = ctx.database_id(); + let emitter = ArcAuditEmitter(Arc::clone(&ctx.state.audit)); + if let Err(error) = authorize_database(ctx.identity, database_id, &emitter) { + return resp(error_to_native(seq, &crate::Error::from(error))); + } + // EXPLAIN. if upper.starts_with("EXPLAIN ") { return resp(handle_explain(ctx, seq, sql_trimmed).await); } // DDL: try DDL router first. - let database_id = ctx.database_id(); let txn_ctx = crate::control::server::shared::session::DmlTxnCtx { sessions: ctx.sessions, addr: ctx.peer_addr, @@ -246,6 +257,18 @@ async fn execute_planned( return resp(error_to_native(seq, &e)); } + drop(perm_cache); + let emitter = ArcAuditEmitter(Arc::clone(&ctx.state.audit)); + if let Err(error) = authorize_task_set( + ctx.identity, + &tasks, + &ctx.state.permissions, + &ctx.state.roles, + &emitter, + ) { + return resp(error_to_native(seq, &crate::Error::from(error))); + } + // Implicit-edge DELETE/UPDATE routing gate (native-protocol parity with // pgwire). See `edge_recon_gate` for the full invariant and guard // documentation. Returns early when the gate fires, consuming `tasks`. @@ -326,10 +349,8 @@ async fn execute_planned( } // Streaming fast path: an eligible autocommit, single-task, unordered - // multi-row SELECT streams its rows as multiple frames. The permission / - // tenant gate below is preserved for the materialized path; the streamable - // scan child is a plain user-collection read, and `plan_sql_with_rls` - // already applied RLS + permission planning, so it is safe to stream. + // multi-row SELECT streams its rows as multiple frames. The complete final + // task set was authorized above before this stream can be opened. if allow_stream { match try_open_sql_stream(ctx, seq, &tasks, database_id, Some(&output_schema)).await { Ok(Some(stream)) => return SqlOutcome::Stream(stream), diff --git a/nodedb/src/control/server/native/dispatch/sql_admin.rs b/nodedb/src/control/server/native/dispatch/sql_admin.rs index e4963fc30..7e9fdc316 100644 --- a/nodedb/src/control/server/native/dispatch/sql_admin.rs +++ b/nodedb/src/control/server/native/dispatch/sql_admin.rs @@ -148,7 +148,31 @@ pub(super) async fn handle_explain(ctx: &DispatchCtx<'_>, seq: u64, sql: &str) - }) .await { - Ok((tasks, _output_schema)) => { + Ok((mut tasks, _output_schema)) => { + drop(perm_cache); + if let Err(error) = crate::control::planner::implicit_edges::append_implicit_edge_tasks( + ctx.state, + &mut tasks, + ctx.tenant_id(), + database_id, + crate::types::TraceId::ZERO, + ) + .await + { + return error_to_native(seq, &error); + } + let emitter = crate::control::security::audit::ArcAuditEmitter(std::sync::Arc::clone( + &ctx.state.audit, + )); + if let Err(error) = crate::control::server::shared::authorization::authorize_task_set( + ctx.identity, + &tasks, + &ctx.state.permissions, + &ctx.state.roles, + &emitter, + ) { + return error_to_native(seq, &crate::Error::from(error)); + } let plan_text = tasks .iter() .map(|t| format!("{:?}", t.plan)) diff --git a/nodedb/tests/http_ws_authorization.rs b/nodedb/tests/http_ws_authorization.rs index 129fbaada..133c672e9 100644 --- a/nodedb/tests/http_ws_authorization.rs +++ b/nodedb/tests/http_ws_authorization.rs @@ -72,7 +72,12 @@ fn create_ws_api_key(shared: &SharedState, username: &str) -> String { .expect("create WebSocket API key") } -async fn ws_query(endpoint: &AuthenticatedWsEndpoint, token: &str, sql: &str) -> serde_json::Value { +async fn ws_request( + endpoint: &AuthenticatedWsEndpoint, + token: &str, + method: &str, + sql: &str, +) -> serde_json::Value { let mut request = format!("ws://{}/v1/ws", endpoint.local_addr) .into_client_request() .expect("WebSocket request"); @@ -86,7 +91,7 @@ async fn ws_query(endpoint: &AuthenticatedWsEndpoint, token: &str, sql: &str) -> ws.send(Message::Text( serde_json::json!({ "id": 77, - "method": "query", + "method": method, "params": {"sql": sql} }) .to_string() @@ -105,6 +110,10 @@ async fn ws_query(endpoint: &AuthenticatedWsEndpoint, token: &str, sql: &str) -> sonic_rs::from_str(&text).expect("valid WebSocket JSON response") } +async fn ws_query(endpoint: &AuthenticatedWsEndpoint, token: &str, sql: &str) -> serde_json::Value { + ws_request(endpoint, token, "query", sql).await +} + fn assert_permission_denied(response: &serde_json::Value, context: &str) { let error = response .get("error") @@ -116,6 +125,36 @@ fn assert_permission_denied(response: &serde_json::Value, context: &str) { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ws_upgrade_rejects_database_outside_api_key_scope() { + let srv = TestServer::start().await; + srv.exec("CREATE DATABASE ws_private_database") + .await + .expect("create private WebSocket database"); + let token = create_ws_api_key(&srv.shared, "ws_database_reader"); + let endpoint = start_authenticated_ws(Arc::clone(&srv.shared)).await; + let mut request = format!("ws://{}/v1/ws", endpoint.local_addr) + .into_client_request() + .expect("WebSocket request"); + request.headers_mut().insert( + http::header::AUTHORIZATION, + http::HeaderValue::from_str(&format!("Bearer {token}")).expect("authorization header"), + ); + request.headers_mut().insert( + http::HeaderName::from_static("x-nodedb-database"), + http::HeaderValue::from_static("ws_private_database"), + ); + + let error = tokio_tungstenite::connect_async(request) + .await + .expect_err("upgrade must reject a database outside API-key scope"); + let status = match error { + tokio_tungstenite::tungstenite::Error::Http(response) => response.status(), + other => panic!("expected HTTP upgrade rejection, got {other}"), + }; + assert_eq!(status, http::StatusCode::FORBIDDEN); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn ws_query_rejects_system_catalog_for_non_superuser() { let srv = TestServer::start().await; @@ -157,6 +196,31 @@ async fn ws_query_rejects_write_without_permission_or_mutation() { assert_permission_denied(&response, "WebSocket write without a PermissionStore grant"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ws_live_rejects_collection_before_subscription_open() { + let srv = TestServer::start().await; + srv.exec("CREATE COLLECTION ws_private_live") + .await + .expect("create private live collection"); + let token = create_ws_api_key(&srv.shared, "ws_ungranted_live_reader"); + let endpoint = start_authenticated_ws(Arc::clone(&srv.shared)).await; + + let response = ws_request( + &endpoint, + &token, + "live", + "LIVE SELECT * FROM ws_private_live", + ) + .await; + + assert_permission_denied(&response, "WebSocket live subscription without read grant"); + assert_eq!( + srv.shared.change_stream.subscriber_count(), + 0, + "denial must occur before opening a live subscription" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn ws_query_rejects_collection_without_permission() { let srv = TestServer::start().await; diff --git a/nodedb/tests/native_sql_authorization.rs b/nodedb/tests/native_sql_authorization.rs index 4be124392..abab64516 100644 --- a/nodedb/tests/native_sql_authorization.rs +++ b/nodedb/tests/native_sql_authorization.rs @@ -134,7 +134,8 @@ async fn native_materialized_sql_rejects_write_without_permission_or_mutation() server.shutdown().await; assert!( - observed.rows.as_ref().is_some_and(Vec::is_empty), + observed.rows.as_ref().is_none_or(Vec::is_empty) + && observed.rows_affected.unwrap_or_default() == 0, "an unauthorized native write must not mutate the collection: {observed:?}" ); assert_eq!( @@ -149,6 +150,34 @@ async fn native_materialized_sql_rejects_write_without_permission_or_mutation() ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn native_explain_rejects_collection_before_plan_metadata() { + let server = NativeTestServer::start_authenticated().await; + seed_private_collection(&server, "native_explain_private").await; + let token = create_api_key( + &server.shared, + "native_explain_reader", + vec![Role::Custom("native_explain_role".into())], + ); + let mut stream = authenticated_stream(&server, token).await; + + let response = send_sql( + &mut stream, + 2, + "EXPLAIN SELECT * FROM native_explain_private", + ) + .await; + drop(stream); + server.shutdown().await; + + assert_eq!(response.status, ResponseStatus::Error); + assert_eq!( + response.error.as_ref().map(|error| error.code.as_str()), + Some("42501"), + "native EXPLAIN must authorize before exposing plan metadata: {response:?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn native_lazy_sql_rejects_collection_without_permission_before_stream_open() { let server = NativeTestServer::start_authenticated().await;