Summary
NodeDB enforces authorization for a query in three layers on the pgwire transport: a database-access gate (enforce_database_access → identity.can_access_database(db), which models the entire GRANT … ON DATABASE axis), a system-catalog block that denies non-superusers access to _system.* collections and consults PermissionStore::check for collection-level grants, and row-level filters injected by the shared planner. The HTTP query handlers (POST /v1/query and POST /v1/query/stream) run only the third layer plus a coarse, database-blind role_grants_permission loop. They never call can_access_database and never apply the _system.* superuser-only block.
The target database is taken verbatim from client input — the X-NodeDB-Database header or the ?database= query parameter — and resolved against the global catalog with no access check. Any authenticated non-superuser can therefore direct queries over HTTP at any database in their tenant regardless of what they were granted, and reach _system.* catalog collections that pgwire refuses them. The entire database-isolation axis and the system-catalog gate are absent on the HTTP transport.
Technical Detail
1. The target database is taken from client input with no access check:
// nodedb/src/control/server/http/routes/query.rs (resolve_database_id, :47)
let name = headers
.get("x-nodedb-database")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| param.database.clone().filter(|s| !s.is_empty())); // client-controlled
...
match catalog.get_database_id_by_name(&db_name) { // :65 GLOBAL catalog lookup
Ok(Some(id)) => Ok(id), // no can_access_database(id)
...
}
resolve_database_id resolves the name against the global catalog and returns the DatabaseId. Neither query nor query_ndjson calls identity.can_access_database(database_id) anywhere.
2. The only per-task authz on HTTP is a coarse, database-blind role check:
// nodedb/src/control/server/http/routes/query.rs (query, :168)
let required = required_permission(&task.plan);
if !identity.is_superuser
&& !identity.roles.iter().any(|r| role_grants_permission(r, required))
{
return Err(ApiError::Forbidden(...));
}
There is no _system.* block and no PermissionStore::check. role_grants_permission returns true for ReadOnly/ReadWrite/TenantAdmin and for any database-scoped role regardless of which database — permission.rs:81-88 explicitly documents that "the database match is enforced at the call site," and this call site does not enforce it. query_ndjson has no per-task role check at all; only the shared planner runs.
3. pgwire enforces the two missing layers — HTTP does not:
// nodedb/src/control/server/pgwire/handler/core.rs
pub(super) fn enforce_database_access(&self, identity, addr) -> PgWireResult<()> {
if identity.is_superuser { return Ok(()); }
let db = self.sessions.get_current_database(addr)...;
if !identity.can_access_database(db) { // :174 ← absent on HTTP
... return Err(... "permission denied for database" ...);
}
Ok(())
}
pub(super) fn check_permission(&self, identity, plan) -> PgWireResult<()> {
if identity.is_superuser { return Ok(()); }
let collection = extract_collection(plan);
if let Some(coll) = collection && coll.starts_with("_system") { // :221 ← absent on HTTP
... return Err(... "system catalog access requires superuser" ...);
}
if let Some(coll) = collection
&& self.state.permissions.check(identity, required, coll, &self.state.roles, ...) // :246
{ return Ok(()); }
// role fallback ...
}
enforce_database_access is invoked at core.rs:313 (simple query) and prepared/execute.rs:45 (extended); check_permission per task at routing/execute.rs:291.
4. The shared planner does not backstop either gate. Both transports call plan_sql_with_rls (planner/context/query/planning.rs:212-219), which runs inject_rls and inject_permission_tree. inject_permission_tree (planner/rls_injection.rs:320) injects row-level ACL filters and early-returns for superusers — it does not perform the database-access check or the _system superuser-only block. Those live only in the pgwire handler.
Call path (verified)
POST /v1/query (X-NodeDB-Database: <any-db-in-tenant>) {"sql":"SELECT * FROM _system.<...>"}
-> query() http/routes/query.rs:82
-> resolve_database_id() :89 database taken from client header/param
(NO identity.can_access_database)
-> plan_sql_with_rls() :145 RLS + permission-tree ROW filters only
-> per task: role_grants_permission loop :168 db-blind; NO _system block, NO PermissionStore::check
*** cross-database query executes; _system.* reachable ***
vs. pgwire do_query() -> enforce_database_access() -> check_permission() (both gates present)
Attack Scenario
- Attacker holds a valid credential for a normal non-superuser account (e.g. a
ReadOnly or database-scoped role in database A) and can reach the HTTP API.
- Attacker sends
POST /v1/query with header X-NodeDB-Database: B (a database in the same tenant they were never granted ON DATABASE) and a SELECT/INSERT/UPDATE/DELETE statement. pgwire would reject this with 42501 permission denied for database; HTTP resolves B and runs the query. The whole GRANT … ON DATABASE model is bypassed on this transport.
- Attacker sends
SELECT … FROM _system.<catalog> over HTTP. pgwire denies non-superusers the system catalog; HTTP has no such block, so the query is planned and dispatched.
Impact
The database-isolation axis (accessible_databases / GRANT ON DATABASE) and the system-catalog gate are silently absent on the HTTP transport. Any authenticated non-superuser can target any database in their tenant and reach _system.* collections that pgwire refuses. Reads and writes both flow through this path — I:L rather than I:H because the planner's RLS and permission-tree row filters still apply on both transports, limiting which specific rows are returned; the database-access and _system gates are nonetheless fully absent. Note that NODE-004 (collection-grant target drops database_id) compounds this: a grant on a same-named collection in database A also satisfies the row filter for B.
Preconditions / severity notes
- Any authenticated non-superuser suffices; no config change, no key compromise, no special mode.
- HTTP is a first-class production transport; the finding is not gated behind a dev or experimental flag.
query_ndjson carries no per-task role check at all — a user with no role can reach it and rely entirely on row-level filters.
Remediation
Route the HTTP handlers through the same two gates pgwire uses, rather than the inline role_grants_permission-only check:
- Immediately after
resolve_database_id, reject inaccessible databases: if !identity.can_access_database(database_id) { return Err(ApiError::Forbidden(...)); }
- Replace the per-task inline role loop in both
query and query_ndjson with the shared check_permission equivalent (superuser → _system.* block → PermissionStore::check → role_grants_permission fallback), so the system-catalog block and collection-grant checks apply.
The correct fix is to lift pgwire's enforce_database_access + check_permission into a transport-neutral helper called from both pgwire and HTTP, so the two transports cannot drift apart again.
Resolution
Fixed by PR #177, merged into main as commit 9ff09f7d47ffcf3afd1f6525496365eeb5743205.
The fix introduces one transport-neutral authorization boundary across pgwire, HTTP JSON/NDJSON, WebSocket RPC, and native materialized/lazy SQL. It enforces database scope and exhaustively authorizes all resources before metadata exposure, triggers, subscriptions, streams, orchestration, Calvin routing, staging, clone probes, or Data Plane dispatch.
Regression coverage verifies denied reads and writes without mutation, database-scoped API keys, explicit collection grants, multi-resource joins, prepared metadata, cursors, facets, EXPLAIN, LIVE subscriptions, WebSocket upgrade/replay, and native lazy execution. Focused authorization suites and affected transport baselines passed. Please validate against main at the merge commit above or later.
Summary
NodeDB enforces authorization for a query in three layers on the pgwire transport: a database-access gate (
enforce_database_access→identity.can_access_database(db), which models the entireGRANT … ON DATABASEaxis), a system-catalog block that denies non-superusers access to_system.*collections and consultsPermissionStore::checkfor collection-level grants, and row-level filters injected by the shared planner. The HTTP query handlers (POST /v1/queryandPOST /v1/query/stream) run only the third layer plus a coarse, database-blindrole_grants_permissionloop. They never callcan_access_databaseand never apply the_system.*superuser-only block.The target database is taken verbatim from client input — the
X-NodeDB-Databaseheader or the?database=query parameter — and resolved against the global catalog with no access check. Any authenticated non-superuser can therefore direct queries over HTTP at any database in their tenant regardless of what they were granted, and reach_system.*catalog collections that pgwire refuses them. The entire database-isolation axis and the system-catalog gate are absent on the HTTP transport.Technical Detail
1. The target database is taken from client input with no access check:
resolve_database_idresolves the name against the global catalog and returns theDatabaseId. Neitherquerynorquery_ndjsoncallsidentity.can_access_database(database_id)anywhere.2. The only per-task authz on HTTP is a coarse, database-blind role check:
There is no
_system.*block and noPermissionStore::check.role_grants_permissionreturnstrueforReadOnly/ReadWrite/TenantAdminand for any database-scoped role regardless of which database —permission.rs:81-88explicitly documents that "the database match is enforced at the call site," and this call site does not enforce it.query_ndjsonhas no per-task role check at all; only the shared planner runs.3. pgwire enforces the two missing layers — HTTP does not:
enforce_database_accessis invoked atcore.rs:313(simple query) andprepared/execute.rs:45(extended);check_permissionper task atrouting/execute.rs:291.4. The shared planner does not backstop either gate. Both transports call
plan_sql_with_rls(planner/context/query/planning.rs:212-219), which runsinject_rlsandinject_permission_tree.inject_permission_tree(planner/rls_injection.rs:320) injects row-level ACL filters and early-returns for superusers — it does not perform the database-access check or the_systemsuperuser-only block. Those live only in the pgwire handler.Call path (verified)
Attack Scenario
ReadOnlyor database-scoped role in databaseA) and can reach the HTTP API.POST /v1/querywith headerX-NodeDB-Database: B(a database in the same tenant they were never grantedON DATABASE) and aSELECT/INSERT/UPDATE/DELETEstatement. pgwire would reject this with42501 permission denied for database; HTTP resolvesBand runs the query. The wholeGRANT … ON DATABASEmodel is bypassed on this transport.SELECT … FROM _system.<catalog>over HTTP. pgwire denies non-superusers the system catalog; HTTP has no such block, so the query is planned and dispatched.Impact
The database-isolation axis (
accessible_databases/GRANT ON DATABASE) and the system-catalog gate are silently absent on the HTTP transport. Any authenticated non-superuser can target any database in their tenant and reach_system.*collections that pgwire refuses. Reads and writes both flow through this path —I:Lrather thanI:Hbecause the planner's RLS and permission-tree row filters still apply on both transports, limiting which specific rows are returned; the database-access and_systemgates are nonetheless fully absent. Note that NODE-004 (collection-grant target dropsdatabase_id) compounds this: a grant on a same-named collection in databaseAalso satisfies the row filter forB.Preconditions / severity notes
query_ndjsoncarries no per-task role check at all — a user with no role can reach it and rely entirely on row-level filters.Remediation
Route the HTTP handlers through the same two gates pgwire uses, rather than the inline
role_grants_permission-only check:resolve_database_id, reject inaccessible databases:if !identity.can_access_database(database_id) { return Err(ApiError::Forbidden(...)); }queryandquery_ndjsonwith the sharedcheck_permissionequivalent (superuser →_system.*block →PermissionStore::check→role_grants_permissionfallback), so the system-catalog block and collection-grant checks apply.The correct fix is to lift pgwire's
enforce_database_access+check_permissioninto a transport-neutral helper called from both pgwire and HTTP, so the two transports cannot drift apart again.Resolution
Fixed by PR #177, merged into
mainas commit9ff09f7d47ffcf3afd1f6525496365eeb5743205.The fix introduces one transport-neutral authorization boundary across pgwire, HTTP JSON/NDJSON, WebSocket RPC, and native materialized/lazy SQL. It enforces database scope and exhaustively authorizes all resources before metadata exposure, triggers, subscriptions, streams, orchestration, Calvin routing, staging, clone probes, or Data Plane dispatch.
Regression coverage verifies denied reads and writes without mutation, database-scoped API keys, explicit collection grants, multi-resource joins, prepared metadata, cursors, facets, EXPLAIN, LIVE subscriptions, WebSocket upgrade/replay, and native lazy execution. Focused authorization suites and affected transport baselines passed. Please validate against
mainat the merge commit above or later.