Skip to content

Commit 3483711

Browse files
committed
test(pgwire): add wire-level tests for per-connection tenant scoping and trust auth
Cover the two regression classes fixed in the preceding commit: - Tenant-scoped users can only see and modify collections owned by their own tenant; a query against a collection belonging to another tenant returns a resolution error, not results from the wrong catalog. - Trust mode rejects usernames that were never provisioned with SQLSTATE 28000 rather than silently admitting them as superusers. Extend pgwire_harness::TestServer with connect_as() to open additional connections under arbitrary credentials, and pre-provision the default "nodedb" superuser so the bootstrap exception does not fire during tests that subsequently create additional users.
1 parent 132f07c commit 3483711

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

nodedb/tests/common/pgwire_harness.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use nodedb::wal::WalManager;
1717
/// A running test server with a connected pgwire client.
1818
pub struct TestServer {
1919
pub client: tokio_postgres::Client,
20+
pub pg_port: u16,
2021
_conn_handle: tokio::task::JoinHandle<()>,
2122
shutdown_bus: nodedb::control::shutdown::ShutdownBus,
2223
poller_shutdown_tx: tokio::sync::watch::Sender<bool>,
@@ -45,6 +46,16 @@ impl TestServer {
4546
nodedb::control::security::credential::store::CredentialStore::open(&catalog_path)
4647
.unwrap(),
4748
);
49+
// Provision the harness superuser `nodedb` so Trust-mode strict
50+
// identity resolution accepts the default test connection. The
51+
// bootstrap exception in the handler only fires when the store
52+
// is empty, which would break as soon as any DDL creates a user.
53+
let _ = credentials.create_user(
54+
"nodedb",
55+
"nodedb",
56+
nodedb::types::TenantId::new(1),
57+
vec![nodedb::control::security::identity::Role::Superuser],
58+
);
4859
let shared = SharedState::new_with_credentials(dispatcher, Arc::clone(&wal), credentials);
4960

5061
// Data Plane core.
@@ -139,6 +150,7 @@ impl TestServer {
139150

140151
Self {
141152
client,
153+
pg_port: pg_addr.port(),
142154
_conn_handle: conn_handle,
143155
shutdown_bus,
144156
poller_shutdown_tx,
@@ -175,6 +187,26 @@ impl TestServer {
175187
}
176188
}
177189

190+
/// Open a second pgwire connection on the same listener under a different
191+
/// username. Returns a client and its background connection task handle.
192+
pub async fn connect_as(
193+
&self,
194+
user: &str,
195+
password: &str,
196+
) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), String> {
197+
let conn_str = format!(
198+
"host=127.0.0.1 port={} user={} password={} dbname=nodedb",
199+
self.pg_port, user, password
200+
);
201+
let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls)
202+
.await
203+
.map_err(|e| pg_error_detail(&e))?;
204+
let handle = tokio::spawn(async move {
205+
let _ = connection.await;
206+
});
207+
Ok((client, handle))
208+
}
209+
178210
/// Execute a SQL statement expecting an error containing the given substring.
179211
pub async fn expect_error(&self, sql: &str, expected_substring: &str) {
180212
match self.client.simple_query(sql).await {
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
//! pgwire wire-level tests for per-connection tenant scoping and Trust-mode
2+
//! identity resolution.
3+
//!
4+
//! Covers the class of bug where the pgwire handler's planning context is
5+
//! built once with a fixed tenant id, so queries from tenant-scoped users
6+
//! plan against the wrong tenant's catalog. Also covers Trust-mode accepting
7+
//! usernames that were never created as if they were superusers.
8+
9+
mod common;
10+
11+
use common::pgwire_harness::TestServer;
12+
13+
/// Helper: superuser-side bootstrap — create a tenant and a tenant-scoped
14+
/// user, plus a collection owned by that tenant. The harness's default
15+
/// connection is Trust/superuser (tenant 1) and is used for setup.
16+
async fn bootstrap_tenant_user(server: &TestServer, user: &str, collection: &str) {
17+
server
18+
.exec("CREATE TENANT acme ID 2")
19+
.await
20+
.expect("CREATE TENANT");
21+
server
22+
.exec(&format!(
23+
"CREATE USER {user} WITH PASSWORD 'x' ROLE readwrite TENANT 2"
24+
))
25+
.await
26+
.expect("CREATE USER");
27+
// Create the collection as the tenant-scoped user so ownership lands on
28+
// tenant 2. This itself exercises the DDL path, which already reads
29+
// identity.tenant_id correctly.
30+
let (svc, _h) = server
31+
.connect_as(user, "x")
32+
.await
33+
.expect("tenant user connect");
34+
svc.simple_query(&format!(
35+
"CREATE COLLECTION {collection} TYPE DOCUMENT STRICT \
36+
(id TEXT PRIMARY KEY, content TEXT NOT NULL)"
37+
))
38+
.await
39+
.expect("tenant user CREATE COLLECTION");
40+
drop(svc);
41+
}
42+
43+
/// Run a simple query under a freshly opened tenant-user connection and
44+
/// return either the rows (first column) or the server error message.
45+
async fn query_as(server: &TestServer, user: &str, sql: &str) -> Result<Vec<String>, String> {
46+
let (client, _h) = server.connect_as(user, "x").await?;
47+
match client.simple_query(sql).await {
48+
Ok(msgs) => {
49+
let mut rows = Vec::new();
50+
for msg in msgs {
51+
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
52+
rows.push(row.get(0).unwrap_or("").to_string());
53+
}
54+
}
55+
Ok(rows)
56+
}
57+
Err(e) => Err(pg_err(&e)),
58+
}
59+
}
60+
61+
fn pg_err(e: &tokio_postgres::Error) -> String {
62+
if let Some(db) = e.as_db_error() {
63+
format!("{}: {}", db.code().code(), db.message())
64+
} else {
65+
format!("{e:?}")
66+
}
67+
}
68+
69+
// ── #29: tenant-scoped planning via shared query_ctx ───────────────────
70+
71+
#[tokio::test]
72+
async fn tenant_user_can_select_own_collection() {
73+
let server = TestServer::start().await;
74+
bootstrap_tenant_user(&server, "svc_sel", "t2_sel").await;
75+
76+
let (svc, _h) = server.connect_as("svc_sel", "x").await.unwrap();
77+
svc.simple_query("INSERT INTO t2_sel (id, content) VALUES ('a', 'alpha')")
78+
.await
79+
.expect("INSERT under tenant user");
80+
81+
let rows = query_as(&server, "svc_sel", "SELECT id FROM t2_sel")
82+
.await
83+
.expect("SELECT under tenant user must not fail with 'unknown table'");
84+
assert_eq!(rows.len(), 1, "expected 1 row, got {rows:?}");
85+
assert!(
86+
rows[0].contains("\"a\""),
87+
"row should contain id 'a': {rows:?}"
88+
);
89+
}
90+
91+
#[tokio::test]
92+
async fn tenant_user_can_insert_into_own_collection() {
93+
let server = TestServer::start().await;
94+
bootstrap_tenant_user(&server, "svc_ins", "t2_ins").await;
95+
96+
let (svc, _h) = server.connect_as("svc_ins", "x").await.unwrap();
97+
svc.simple_query("INSERT INTO t2_ins (id, content) VALUES ('k', 'v')")
98+
.await
99+
.expect("INSERT must succeed for tenant-owned collection");
100+
}
101+
102+
#[tokio::test]
103+
async fn tenant_user_can_update_own_collection() {
104+
let server = TestServer::start().await;
105+
bootstrap_tenant_user(&server, "svc_upd", "t2_upd").await;
106+
107+
let (svc, _h) = server.connect_as("svc_upd", "x").await.unwrap();
108+
svc.simple_query("INSERT INTO t2_upd (id, content) VALUES ('a', 'old')")
109+
.await
110+
.unwrap();
111+
svc.simple_query("UPDATE t2_upd SET content = 'new' WHERE id = 'a'")
112+
.await
113+
.expect("UPDATE must not fail with 'unknown table'");
114+
115+
let rows = query_as(
116+
&server,
117+
"svc_upd",
118+
"SELECT content FROM t2_upd WHERE id = 'a'",
119+
)
120+
.await
121+
.unwrap();
122+
assert_eq!(rows.len(), 1);
123+
assert!(
124+
rows[0].contains("\"new\""),
125+
"row should reflect updated content: {rows:?}"
126+
);
127+
}
128+
129+
#[tokio::test]
130+
async fn tenant_user_can_delete_from_own_collection() {
131+
let server = TestServer::start().await;
132+
bootstrap_tenant_user(&server, "svc_del", "t2_del").await;
133+
134+
let (svc, _h) = server.connect_as("svc_del", "x").await.unwrap();
135+
svc.simple_query("INSERT INTO t2_del (id, content) VALUES ('a', 'x')")
136+
.await
137+
.unwrap();
138+
svc.simple_query("DELETE FROM t2_del WHERE id = 'a'")
139+
.await
140+
.expect("DELETE must not fail with 'unknown table'");
141+
142+
let rows = query_as(&server, "svc_del", "SELECT id FROM t2_del")
143+
.await
144+
.unwrap();
145+
assert!(rows.is_empty(), "row should be deleted, got {rows:?}");
146+
}
147+
148+
#[tokio::test]
149+
async fn tenant_user_prepared_select_resolves_own_collection() {
150+
// Extended protocol goes through NodeDbQueryParser::parse_sql, which
151+
// builds its own OriginCatalog with a hardcoded tenant id. A tenant-2
152+
// user must still be able to prepare and execute a statement against
153+
// a tenant-2 collection.
154+
let server = TestServer::start().await;
155+
bootstrap_tenant_user(&server, "svc_prep", "t2_prep").await;
156+
157+
let (svc, _h) = server.connect_as("svc_prep", "x").await.unwrap();
158+
svc.simple_query("INSERT INTO t2_prep (id, content) VALUES ('a', 'alpha')")
159+
.await
160+
.unwrap();
161+
162+
// `prepare` drives Parse + Describe through NodeDbQueryParser::parse_sql,
163+
// which constructs an OriginCatalog. Before the fix this catalog was
164+
// hardcoded to tenant 1 and could not see tenant-2 collections —
165+
// `prepare` would surface the server's "unknown table" error.
166+
svc.prepare("SELECT content FROM t2_prep WHERE id = 'a'")
167+
.await
168+
.expect("prepare must resolve tenant-owned collection via parser.rs");
169+
}
170+
171+
#[tokio::test]
172+
async fn tenant_user_cannot_see_other_tenants_collection_as_empty() {
173+
// Asymmetric-isolation guard: a tenant-2 user issuing SELECT against a
174+
// tenant-1 collection must NOT silently return an empty result set.
175+
// The correct behavior is the same "unknown table" a cross-tenant
176+
// planner produces the other direction. Silent empty is a data-shape
177+
// leak vector even though no rows cross.
178+
let server = TestServer::start().await;
179+
server
180+
.exec(
181+
"CREATE COLLECTION t1_only TYPE DOCUMENT STRICT \
182+
(id TEXT PRIMARY KEY, secret TEXT NOT NULL)",
183+
)
184+
.await
185+
.unwrap();
186+
server
187+
.exec("INSERT INTO t1_only (id, secret) VALUES ('a', 'classified')")
188+
.await
189+
.unwrap();
190+
191+
server.exec("CREATE TENANT acme ID 2").await.unwrap();
192+
server
193+
.exec("CREATE USER svc_xtn WITH PASSWORD 'x' ROLE readwrite TENANT 2")
194+
.await
195+
.unwrap();
196+
197+
let result = query_as(&server, "svc_xtn", "SELECT secret FROM t1_only").await;
198+
match result {
199+
Err(msg) => {
200+
assert!(
201+
msg.to_lowercase().contains("unknown table"),
202+
"expected 'unknown table' error, got: {msg}"
203+
);
204+
}
205+
Ok(rows) => panic!(
206+
"tenant-2 user must not see tenant-1 collection via silent empty result; got rows={rows:?}"
207+
),
208+
}
209+
}
210+
211+
// ── #30: Trust-mode identity resolution ────────────────────────────────
212+
213+
#[tokio::test]
214+
async fn trust_mode_rejects_unknown_username() {
215+
// Under Trust mode NodeDB skips password verification, but it must
216+
// still resolve the connecting username against the credential store.
217+
// Accepting a fabricated username silently promotes arbitrary clients
218+
// to tenant-1 superuser (see core.rs resolve_identity fallback).
219+
let server = TestServer::start().await;
220+
221+
let result = server
222+
.connect_as("nosuchuser_ever_created", "anything")
223+
.await;
224+
assert!(
225+
result.is_err(),
226+
"Trust mode must reject a username that was never CREATE USER'd; got an accepted connection"
227+
);
228+
}
229+
230+
#[tokio::test]
231+
async fn trust_mode_unknown_user_cannot_run_superuser_ddl() {
232+
// Defense-in-depth regression guard for the same root cause: even if
233+
// a connection is somehow permitted, an unknown username MUST NOT be
234+
// silently fabricated as a superuser identity capable of tenant/user
235+
// management DDL. Today core.rs sets `is_superuser: true` in the
236+
// fallback branch, so `CREATE USER` from a fabricated name succeeds.
237+
let server = TestServer::start().await;
238+
239+
let Ok((client, _h)) = server.connect_as("ghost_admin", "anything").await else {
240+
// If connect_as is already rejected by the prior test's fix, that
241+
// is a strictly stronger guarantee — the bug is still captured.
242+
return;
243+
};
244+
245+
let result = client
246+
.simple_query("CREATE USER mallory WITH PASSWORD 'y' ROLE readwrite TENANT 1")
247+
.await;
248+
assert!(
249+
result.is_err(),
250+
"unknown Trust-mode user must not be granted superuser privileges; CREATE USER succeeded"
251+
);
252+
if let Err(e) = result {
253+
let msg = pg_err(&e);
254+
assert!(
255+
msg.contains("42501") || msg.to_lowercase().contains("permission"),
256+
"expected a permission-denied error, got: {msg}"
257+
);
258+
}
259+
}

0 commit comments

Comments
 (0)