Skip to content

Commit 5470dd4

Browse files
committed
test(security): cover SQL transport authorization
1 parent 5899db7 commit 5470dd4

8 files changed

Lines changed: 863 additions & 4 deletions

File tree

Cargo.lock

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

nodedb-test-support/src/native_harness/frames.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use tokio::net::TcpStream;
1111
use nodedb_types::protocol::request_fields::RequestFields;
1212
use nodedb_types::protocol::text_fields::TextFields;
1313
use nodedb_types::protocol::{
14-
FRAME_HEADER_LEN, HELLO_ACK_MAGIC, HELLO_ERROR_MAGIC_U32, HelloAckFrame, HelloErrorFrame,
15-
HelloFrame, NativeRequest, NativeResponse, OpCode,
14+
AuthMethod, FRAME_HEADER_LEN, HELLO_ACK_MAGIC, HELLO_ERROR_MAGIC_U32, HelloAckFrame,
15+
HelloErrorFrame, HelloFrame, NativeRequest, NativeResponse, OpCode,
1616
};
1717

1818
/// Perform the handshake with a custom `HelloFrame`.
@@ -127,6 +127,21 @@ pub async fn send_request(
127127
sonic_rs::from_slice(&response_payload).expect("json decode NativeResponse")
128128
}
129129

130+
/// Authenticate a fresh native connection with an API key. The JSON Auth
131+
/// request also selects JSON framing for the rest of the session.
132+
pub async fn send_api_key_auth(stream: &mut TcpStream, seq: u64, token: String) -> NativeResponse {
133+
send_request(
134+
stream,
135+
seq,
136+
OpCode::Auth,
137+
TextFields {
138+
auth: Some(AuthMethod::ApiKey { token }),
139+
..Default::default()
140+
},
141+
)
142+
.await
143+
}
144+
130145
/// Send a `SHOW`/SQL statement over an established JSON-encoding session and
131146
/// decode the `NativeResponse`. Assumes the session's first frame already
132147
/// selected JSON (see `json_request_gets_json_response`) — callers that open

nodedb-test-support/src/native_harness/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@
99
mod frames;
1010
mod server;
1111

12-
pub use frames::{do_handshake, read_frame, send_request, send_sql, write_frame};
12+
pub use frames::{
13+
do_handshake, read_frame, send_api_key_auth, send_request, send_sql, write_frame,
14+
};
1315
pub use server::NativeTestServer;

nodedb-test-support/src/native_harness/server.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use nodedb::wal::WalManager;
1717
/// A running native-protocol test server.
1818
pub struct NativeTestServer {
1919
pub addr: std::net::SocketAddr,
20+
pub shared: Arc<SharedState>,
2021
pub(super) shutdown_bus: nodedb::control::shutdown::ShutdownBus,
2122
pub(super) poller_shutdown_tx: tokio::sync::watch::Sender<bool>,
2223
pub(super) core_stop_tx: std::sync::mpsc::Sender<()>,
@@ -31,6 +32,15 @@ impl NativeTestServer {
3132
/// Spawn a single-core NodeDB server with the native listener bound to
3233
/// an ephemeral `127.0.0.1` port (trust-mode auth).
3334
pub async fn start() -> Self {
35+
Self::start_with_auth_mode(AuthMode::Trust).await
36+
}
37+
38+
/// Spawn a single-core server that requires an explicit native Auth frame.
39+
pub async fn start_authenticated() -> Self {
40+
Self::start_with_auth_mode(AuthMode::Password).await
41+
}
42+
43+
async fn start_with_auth_mode(auth_mode: AuthMode) -> Self {
3444
let dir = tempfile::tempdir().expect("tempdir");
3545
let wal_path = dir.path().join("test.wal");
3646
let wal = Arc::new(WalManager::open_for_testing(&wal_path).expect("open wal"));
@@ -128,7 +138,7 @@ impl NativeTestServer {
128138
listener
129139
.run(nodedb::control::server::listener::ListenerRunParams {
130140
state: shared_listener,
131-
auth_mode: AuthMode::Trust,
141+
auth_mode,
132142
tls_acceptor: None,
133143
conn_semaphore: Arc::new(tokio::sync::Semaphore::new(128)),
134144
startup_gate: test_startup_gate,
@@ -145,6 +155,7 @@ impl NativeTestServer {
145155

146156
Self {
147157
addr,
158+
shared,
148159
shutdown_bus,
149160
poller_shutdown_tx,
150161
core_stop_tx,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Authorization parity for materialized HTTP SQL queries.
4+
5+
mod common;
6+
7+
use std::sync::Arc;
8+
use std::time::Duration;
9+
10+
use common::pgwire_harness::TestServer;
11+
use nodedb::config::auth::AuthMode;
12+
use nodedb::control::security::apikey::CreateKeyParams;
13+
use nodedb::control::security::identity::{Permission, Role};
14+
use nodedb::control::security::permission::collection_target;
15+
use nodedb::control::state::SharedState;
16+
use nodedb::types::{DatabaseId, TenantId};
17+
18+
struct AuthenticatedHttpEndpoint {
19+
local_addr: std::net::SocketAddr,
20+
_server: tokio::task::JoinHandle<()>,
21+
}
22+
23+
async fn start_authenticated_http(shared: Arc<SharedState>) -> AuthenticatedHttpEndpoint {
24+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
25+
.await
26+
.expect("bind authenticated HTTP listener");
27+
let local_addr = listener.local_addr().expect("authenticated HTTP address");
28+
let (bus, _) = nodedb::control::shutdown::ShutdownBus::new(Arc::clone(&shared.shutdown));
29+
let handle = tokio::spawn(async move {
30+
nodedb::control::server::http::server::run_with_listener(
31+
listener,
32+
shared,
33+
AuthMode::Password,
34+
None,
35+
bus,
36+
)
37+
.await
38+
.expect("authenticated HTTP server");
39+
});
40+
tokio::time::sleep(Duration::from_millis(40)).await;
41+
42+
AuthenticatedHttpEndpoint {
43+
local_addr,
44+
_server: handle,
45+
}
46+
}
47+
48+
fn create_api_key(shared: &SharedState, username: &str, roles: Vec<Role>) -> String {
49+
let user_id = shared
50+
.credentials
51+
.create_service_account(username, TenantId::new(1), roles, vec![DatabaseId::DEFAULT])
52+
.expect("create database-scoped service account");
53+
shared
54+
.api_keys
55+
.create_key(
56+
CreateKeyParams {
57+
username,
58+
user_id,
59+
tenant_id: TenantId::new(1),
60+
expires_secs: 0,
61+
scope: vec![],
62+
accessible_databases: vec![DatabaseId::DEFAULT],
63+
},
64+
Some(shared.credentials.catalog()),
65+
)
66+
.expect("create API key")
67+
}
68+
69+
async fn post_query(http: &AuthenticatedHttpEndpoint, token: &str, sql: &str) -> reqwest::Response {
70+
reqwest::Client::new()
71+
.post(format!("http://{}/v1/query", http.local_addr))
72+
.header("Authorization", format!("Bearer {token}"))
73+
.json(&serde_json::json!({"sql": sql}))
74+
.send()
75+
.await
76+
.expect("POST authenticated query")
77+
}
78+
79+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
80+
async fn query_rejects_database_outside_api_key_scope() {
81+
let srv = TestServer::start().await;
82+
srv.exec("CREATE DATABASE private_http_db")
83+
.await
84+
.expect("create inaccessible database");
85+
let token = create_api_key(&srv.shared, "http_db_reader", vec![Role::ReadOnly]);
86+
let http = start_authenticated_http(Arc::clone(&srv.shared)).await;
87+
88+
let response = reqwest::Client::new()
89+
.post(format!("http://{}/v1/query", http.local_addr))
90+
.header("Authorization", format!("Bearer {token}"))
91+
.header("X-NodeDB-Database", "private_http_db")
92+
.json(&serde_json::json!({"sql": "SELECT 1"}))
93+
.send()
94+
.await
95+
.expect("POST cross-database query");
96+
97+
assert_eq!(
98+
response.status(),
99+
reqwest::StatusCode::FORBIDDEN,
100+
"HTTP queries must enforce the API key's database scope before planning or execution"
101+
);
102+
}
103+
104+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
105+
async fn query_rejects_cross_database_write_without_mutating_target() {
106+
let srv = TestServer::start().await;
107+
srv.exec("CREATE DATABASE private_write_db")
108+
.await
109+
.expect("create inaccessible write database");
110+
srv.exec("USE DATABASE private_write_db")
111+
.await
112+
.expect("switch to write database as superuser");
113+
srv.exec("CREATE COLLECTION private_write_rows")
114+
.await
115+
.expect("create private write collection");
116+
srv.exec("USE DATABASE default")
117+
.await
118+
.expect("return to default database");
119+
120+
let token = create_api_key(&srv.shared, "http_db_writer", vec![Role::ReadWrite]);
121+
let http = start_authenticated_http(Arc::clone(&srv.shared)).await;
122+
let response = reqwest::Client::new()
123+
.post(format!("http://{}/v1/query", http.local_addr))
124+
.header("Authorization", format!("Bearer {token}"))
125+
.header("X-NodeDB-Database", "private_write_db")
126+
.json(&serde_json::json!({
127+
"sql": "INSERT INTO private_write_rows { id: 'forbidden', value: 5 }"
128+
}))
129+
.send()
130+
.await
131+
.expect("POST cross-database write");
132+
133+
srv.exec("USE DATABASE private_write_db")
134+
.await
135+
.expect("inspect write database");
136+
let rows = srv
137+
.query_text("SELECT id FROM private_write_rows")
138+
.await
139+
.expect("query private write rows");
140+
assert!(
141+
rows.is_empty(),
142+
"a cross-database write must not mutate the target: {rows:?}"
143+
);
144+
assert_eq!(
145+
response.status(),
146+
reqwest::StatusCode::FORBIDDEN,
147+
"cross-database HTTP writes must be rejected"
148+
);
149+
}
150+
151+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
152+
async fn query_rejects_system_catalog_for_non_superuser() {
153+
let srv = TestServer::start().await;
154+
let token = create_api_key(&srv.shared, "http_catalog_reader", vec![Role::ReadOnly]);
155+
let http = start_authenticated_http(Arc::clone(&srv.shared)).await;
156+
157+
let response = post_query(&http, &token, "SELECT * FROM _system.audit_log").await;
158+
159+
assert_eq!(
160+
response.status(),
161+
reqwest::StatusCode::FORBIDDEN,
162+
"system catalog access must require a superuser on every SQL transport"
163+
);
164+
}
165+
166+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
167+
async fn query_honors_explicit_collection_grant_for_custom_role() {
168+
let srv = TestServer::start().await;
169+
srv.exec("CREATE COLLECTION granted_rows")
170+
.await
171+
.expect("create granted collection");
172+
srv.exec("INSERT INTO granted_rows { id: 'visible', value: 9 }")
173+
.await
174+
.expect("seed granted collection");
175+
176+
let username = "http_explicit_reader";
177+
let token = create_api_key(
178+
&srv.shared,
179+
username,
180+
vec![Role::Custom("http_explicit_role".into())],
181+
);
182+
srv.shared
183+
.permissions
184+
.grant(
185+
&collection_target(TenantId::new(1), "granted_rows"),
186+
&format!("user:{username}"),
187+
Permission::Read,
188+
"nodedb",
189+
Some(srv.shared.credentials.catalog()),
190+
)
191+
.expect("grant collection read");
192+
let http = start_authenticated_http(Arc::clone(&srv.shared)).await;
193+
194+
let response = post_query(&http, &token, "SELECT * FROM granted_rows").await;
195+
196+
assert_eq!(
197+
response.status(),
198+
reqwest::StatusCode::OK,
199+
"HTTP authorization must honor PermissionStore grants before built-in role fallback"
200+
);
201+
}

0 commit comments

Comments
 (0)