Skip to content

Commit 39a23d2

Browse files
committed
fix(tenant): enforce existence gate for unknown numeric tenant ids
Promote the `tenant_exists` helper from drop.rs into tenant/mod.rs so DROP, ALTER, and PURGE all share a single, consistent existence check. Previously a bogus numeric tenant id bypassed the name-not-found early return: DROP proposed a delete for a phantom id, ALTER silently seeded a default quota for it, and PURGE dispatched a destructive operation. Now all three enforce the existence gate after the system-tenant guard (42501 keeps precedence), matching the behavior for unknown names — both paths return 42704 or a no-op under IF EXISTS. Expand name_resolution.rs regression coverage with: ALTER on a quoted/empty name, full PURGE paths (success, IF EXISTS no-op, 42501, catalog-unavailable), and unknown-numeric-id parity tests for DROP/ALTER/PURGE.
1 parent 0ae2385 commit 39a23d2

5 files changed

Lines changed: 268 additions & 44 deletions

File tree

nodedb/src/control/server/pgwire/ddl/tenant/alter.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,18 @@ pub fn alter_tenant(
3939
}
4040

4141
// Accept either a numeric id or a tenant name (mirrors CREATE/SHOW/DROP).
42-
let tenant_id = super::resolve_tenant_ref(state, parts[2])?.ok_or_else(|| {
43-
sqlstate_error(
42+
let tenant_id = super::resolve_tenant_ref(state, parts[2])?
43+
.ok_or_else(|| sqlstate_error("42704", &format!("tenant '{}' does not exist", parts[2])))?;
44+
45+
// Existence gate, uniform across numeric ids and resolved names: altering an
46+
// unknown tenant must error rather than silently seed a default quota for a
47+
// phantom id.
48+
if !super::tenant_exists(state, tenant_id)? {
49+
return Err(sqlstate_error(
4450
"42704",
4551
&format!("tenant '{}' does not exist", parts[2]),
46-
)
47-
})?;
52+
));
53+
}
4854

4955
if !parts[3].eq_ignore_ascii_case("SET") || !parts[4].eq_ignore_ascii_case("QUOTA") {
5056
return Err(sqlstate_error(

nodedb/src/control/server/pgwire/ddl/tenant/drop.rs

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,10 @@ use crate::control::metadata_proposer::propose_catalog_entry;
1515
use crate::control::security::audit::AuditEvent;
1616
use crate::control::security::identity::AuthenticatedIdentity;
1717
use crate::control::state::SharedState;
18-
use crate::types::TenantId;
1918

2019
use super::super::super::types::sqlstate_error;
2120
use super::super::parse_utils::strip_if_exists;
22-
23-
/// Whether a tenant id currently exists, consulting the redb catalog when
24-
/// one is wired up and falling back to the in-memory quota table otherwise.
25-
fn tenant_exists(state: &SharedState, tenant_id: TenantId) -> PgWireResult<bool> {
26-
if let Some(catalog) = state.credentials.catalog() {
27-
let present = catalog
28-
.load_all_tenants()
29-
.map_err(|e| sqlstate_error("XX000", &format!("catalog read: {e}")))?
30-
.iter()
31-
.any(|t| t.tenant_id == tenant_id.as_u64());
32-
return Ok(present);
33-
}
34-
let tenants = match state.tenants.lock() {
35-
Ok(t) => t,
36-
Err(p) => p.into_inner(),
37-
};
38-
Ok(tenants.has_quota(tenant_id))
39-
}
21+
use super::tenant_exists;
4022

4123
pub fn drop_tenant(
4224
state: &SharedState,
@@ -60,7 +42,9 @@ pub fn drop_tenant(
6042
}
6143

6244
// Accept either a numeric id or a tenant name; mirror the existing
63-
// CREATE TENANT name-resolution path.
45+
// CREATE TENANT name-resolution path. A name that matches no tenant yields
46+
// `None` here; an unknown numeric id resolves to a candidate that the
47+
// existence gate below rejects, so both forms behave identically.
6448
let tenant_id = match super::resolve_tenant_ref(state, parts[2])? {
6549
Some(tid) => tid,
6650
None => {
@@ -80,11 +64,17 @@ pub fn drop_tenant(
8064
return Err(sqlstate_error("42501", "cannot drop system tenant (0)"));
8165
}
8266

83-
// `IF EXISTS`: dropping a tenant that does not exist is a no-op success.
84-
// (Numeric-id-not-in-catalog path; the name-not-found case was handled
85-
// above.)
86-
if if_exists && !tenant_exists(state, tenant_id)? {
87-
return Ok(vec![Response::Execution(Tag::new("DROP TENANT"))]);
67+
// Existence gate, uniform across numeric ids and resolved names: an unknown
68+
// tenant is a no-op under `IF EXISTS`, otherwise `42704` — never a silent
69+
// delete proposal for a tenant that does not exist.
70+
if !tenant_exists(state, tenant_id)? {
71+
if if_exists {
72+
return Ok(vec![Response::Execution(Tag::new("DROP TENANT"))]);
73+
}
74+
return Err(sqlstate_error(
75+
"42704",
76+
&format!("tenant '{}' does not exist", parts[2]),
77+
));
8878
}
8979

9080
let entry = CatalogEntry::DeleteTenant { tenant_id: tid };

nodedb/src/control/server/pgwire/ddl/tenant/mod.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ use super::super::types::sqlstate_error;
4444
///
4545
/// The numeric form is the legacy fast path and requires no catalog access;
4646
/// any `u64`-parseable token returns `Ok(Some(TenantId::new(id)))` whether or
47-
/// not that id currently exists (callers that need existence still rely on
48-
/// their own `tenant_exists`-style check).
47+
/// not that id currently exists. Resolving a token to an id does **not** assert
48+
/// the tenant exists — callers must gate the operation through [`tenant_exists`]
49+
/// so that an unknown numeric id and an unknown name behave identically (both
50+
/// `42704`, or an `IF EXISTS` no-op). Skipping that check reintroduces the
51+
/// id/name asymmetry where a bogus numeric id silently "succeeds".
4952
///
5053
/// A non-numeric token is treated as a tenant name and resolved via
5154
/// [`find_tenant_by_name`]. Single-quoted names are unwrapped, mirroring the
@@ -92,3 +95,24 @@ pub(super) fn resolve_tenant_ref(
9295
.map_err(|e| sqlstate_error("XX000", &format!("catalog read: {e}")))?
9396
.map(|stored| TenantId::new(stored.tenant_id)))
9497
}
98+
99+
/// Whether `tenant_id` currently exists, consulting the redb catalog when one
100+
/// is wired up and falling back to the in-memory quota table otherwise.
101+
///
102+
/// Shared by `DROP`, `ALTER`, and `PURGE TENANT` so existence is enforced the
103+
/// same way for numeric ids and resolved names — see [`resolve_tenant_ref`].
104+
pub(super) fn tenant_exists(state: &SharedState, tenant_id: TenantId) -> PgWireResult<bool> {
105+
if let Some(catalog) = state.credentials.catalog() {
106+
let present = catalog
107+
.load_all_tenants()
108+
.map_err(|e| sqlstate_error("XX000", &format!("catalog read: {e}")))?
109+
.iter()
110+
.any(|t| t.tenant_id == tenant_id.as_u64());
111+
return Ok(present);
112+
}
113+
let tenants = match state.tenants.lock() {
114+
Ok(t) => t,
115+
Err(p) => p.into_inner(),
116+
};
117+
Ok(tenants.has_quota(tenant_id))
118+
}

nodedb/src/control/server/pgwire/ddl/tenant/purge.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,23 @@ pub async fn purge_tenant(
3737
}
3838

3939
// Accept either a numeric id or a tenant name (mirrors CREATE/SHOW/DROP).
40-
let tenant_id = super::resolve_tenant_ref(state, parts[2])?.ok_or_else(|| {
41-
sqlstate_error(
42-
"42704",
43-
&format!("tenant '{}' does not exist", parts[2]),
44-
)
45-
})?;
40+
let tenant_id = super::resolve_tenant_ref(state, parts[2])?
41+
.ok_or_else(|| sqlstate_error("42704", &format!("tenant '{}' does not exist", parts[2])))?;
4642
let tid = tenant_id.as_u64();
4743

4844
if tid == 0 {
4945
return Err(sqlstate_error("42501", "cannot purge system tenant (0)"));
5046
}
5147

48+
// Existence gate, uniform across numeric ids and resolved names: refuse to
49+
// dispatch the destructive meta op for a tenant that does not exist.
50+
if !super::tenant_exists(state, tenant_id)? {
51+
return Err(sqlstate_error(
52+
"42704",
53+
&format!("tenant '{}' does not exist", parts[2]),
54+
));
55+
}
56+
5257
if !parts[3].eq_ignore_ascii_case("CONFIRM") {
5358
return Err(sqlstate_error(
5459
"42601",

nodedb/tests/pgwire_auth_tenants/name_resolution.rs

Lines changed: 206 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
//! shared `resolve_tenant_ref` helper, mirroring the already-shipped
99
//! `CREATE TENANT <name>` / `SHOW TENANT <name>` paths.
1010
11-
use crate::common::pgwire_auth_helpers::{ddl_err, ddl_ok, make_state_with_catalog, superuser};
11+
use crate::common::pgwire_auth_helpers::{
12+
ddl_err, ddl_ok, make_state, make_state_with_catalog, superuser,
13+
};
1214

1315
// ─── DROP TENANT by name ─────────────────────────────────────────────────────
1416

@@ -89,12 +91,7 @@ async fn alter_tenant_by_numeric_id_still_works() {
8991
let su = superuser();
9092

9193
ddl_ok(&state, &su, "CREATE TENANT acme_alter_num ID 7145").await;
92-
ddl_ok(
93-
&state,
94-
&su,
95-
"ALTER TENANT 7145 SET QUOTA max_qps = 250",
96-
)
97-
.await;
94+
ddl_ok(&state, &su, "ALTER TENANT 7145 SET QUOTA max_qps = 250").await;
9895
}
9996

10097
/// `ALTER TENANT <name> SET QUOTA ...` — name resolves to id.
@@ -129,3 +126,205 @@ async fn alter_tenant_unknown_name_errors() {
129126
"expected 42704/does not exist, got: {err}"
130127
);
131128
}
129+
130+
/// `ALTER TENANT '<name>' SET QUOTA ...` — single-quoted name resolves to id,
131+
/// matching the quoted-name path already covered on DROP.
132+
#[tokio::test]
133+
async fn alter_tenant_by_quoted_name() {
134+
let state = make_state_with_catalog();
135+
let su = superuser();
136+
137+
ddl_ok(&state, &su, "CREATE TENANT acme_alter_quoted ID 7147").await;
138+
ddl_ok(
139+
&state,
140+
&su,
141+
"ALTER TENANT 'acme_alter_quoted' SET QUOTA max_qps = 250",
142+
)
143+
.await;
144+
}
145+
146+
/// `ALTER TENANT '' SET QUOTA ...` (empty quoted name) → `42601`, the same
147+
/// resolver guard exercised on DROP.
148+
#[tokio::test]
149+
async fn alter_tenant_empty_name_errors() {
150+
let state = make_state_with_catalog();
151+
let su = superuser();
152+
153+
let err = ddl_err(&state, &su, "ALTER TENANT '' SET QUOTA max_qps = 250").await;
154+
assert!(
155+
err.contains("42601") && err.contains("numeric id or a tenant name"),
156+
"expected 42601 empty-name error, got: {err}"
157+
);
158+
}
159+
160+
// ─── PURGE TENANT by name ────────────────────────────────────────────────────
161+
//
162+
// `purge_tenant` resolves the tenant reference, rejects the system tenant, and
163+
// only then dispatches the destructive meta op to the Data Plane. The
164+
// resolution and guard branches all return before that dispatch, so they are
165+
// covered here without a Data Plane; the post-dispatch happy path is exercised
166+
// by the executor-level purge tests.
167+
168+
/// `PURGE TENANT <name> CONFIRM` on an unknown name → `42704`, before any
169+
/// destructive dispatch.
170+
#[tokio::test]
171+
async fn purge_tenant_unknown_name_errors() {
172+
let state = make_state_with_catalog();
173+
let su = superuser();
174+
175+
let err = ddl_err(&state, &su, "PURGE TENANT no_such_tenant CONFIRM").await;
176+
assert!(
177+
err.contains("does not exist") && err.contains("42704"),
178+
"expected 42704/does not exist, got: {err}"
179+
);
180+
}
181+
182+
/// `PURGE TENANT '' CONFIRM` (empty quoted name) → `42601` resolver guard.
183+
#[tokio::test]
184+
async fn purge_tenant_empty_name_errors() {
185+
let state = make_state_with_catalog();
186+
let su = superuser();
187+
188+
let err = ddl_err(&state, &su, "PURGE TENANT '' CONFIRM").await;
189+
assert!(
190+
err.contains("42601") && err.contains("numeric id or a tenant name"),
191+
"expected 42601 empty-name error, got: {err}"
192+
);
193+
}
194+
195+
/// `PURGE TENANT <name>` with a malformed confirmation token resolves the name
196+
/// first, then fails the `CONFIRM` gate with `42601`. The `42601`/CONFIRM error
197+
/// (rather than `42704`) proves the name resolved — covering the by-name purge
198+
/// path up to the Data Plane boundary.
199+
#[tokio::test]
200+
async fn purge_tenant_by_name_resolves_then_requires_confirm() {
201+
let state = make_state_with_catalog();
202+
let su = superuser();
203+
204+
ddl_ok(&state, &su, "CREATE TENANT acme_purge_name ID 7148").await;
205+
let err = ddl_err(&state, &su, "PURGE TENANT acme_purge_name PLEASE").await;
206+
assert!(
207+
err.contains("42601") && err.contains("CONFIRM"),
208+
"expected name to resolve then hit the CONFIRM gate, got: {err}"
209+
);
210+
}
211+
212+
/// `PURGE TENANT <id>` (numeric) for an existing tenant with a malformed
213+
/// confirmation token — the legacy numeric path resolves and passes the
214+
/// existence gate, then fails the `CONFIRM` gate. Regression that the resolver
215+
/// refactor kept the numeric fast path intact for purge.
216+
#[tokio::test]
217+
async fn purge_tenant_by_numeric_id_resolves_then_requires_confirm() {
218+
let state = make_state_with_catalog();
219+
let su = superuser();
220+
221+
ddl_ok(&state, &su, "CREATE TENANT acme_purge_num ID 7149").await;
222+
let err = ddl_err(&state, &su, "PURGE TENANT 7149 PLEASE").await;
223+
assert!(
224+
err.contains("42601") && err.contains("CONFIRM"),
225+
"expected numeric id to resolve then hit the CONFIRM gate, got: {err}"
226+
);
227+
}
228+
229+
// ─── Unknown numeric id parity (id form must match name form) ─────────────────
230+
//
231+
// A numeric id that matches no tenant must behave exactly like an unknown
232+
// name: `42704`, or an `IF EXISTS` no-op for DROP. Before the existence gate
233+
// these silently proceeded (DROP proposed a delete, ALTER seeded a default
234+
// quota, PURGE dispatched a destructive op) — the id/name asymmetry.
235+
236+
/// `DROP TENANT <unknown_id>` without `IF EXISTS` → `42704`, matching the
237+
/// unknown-name behavior.
238+
#[tokio::test]
239+
async fn drop_tenant_unknown_numeric_id_without_if_exists_errors() {
240+
let state = make_state_with_catalog();
241+
let su = superuser();
242+
243+
let err = ddl_err(&state, &su, "DROP TENANT 999001").await;
244+
assert!(
245+
err.contains("42704") && err.contains("does not exist"),
246+
"expected 42704 for unknown numeric id, got: {err}"
247+
);
248+
}
249+
250+
/// `DROP TENANT IF EXISTS <unknown_id>` is a no-op success, matching the
251+
/// unknown-name `IF EXISTS` behavior.
252+
#[tokio::test]
253+
async fn drop_tenant_if_exists_unknown_numeric_id_is_noop() {
254+
let state = make_state_with_catalog();
255+
let su = superuser();
256+
257+
ddl_ok(&state, &su, "DROP TENANT IF EXISTS 999002").await;
258+
}
259+
260+
/// `ALTER TENANT <unknown_id> SET QUOTA ...` → `42704`, never a silent
261+
/// default-quota seed for a phantom id.
262+
#[tokio::test]
263+
async fn alter_tenant_unknown_numeric_id_errors() {
264+
let state = make_state_with_catalog();
265+
let su = superuser();
266+
267+
let err = ddl_err(&state, &su, "ALTER TENANT 999003 SET QUOTA max_qps = 250").await;
268+
assert!(
269+
err.contains("42704") && err.contains("does not exist"),
270+
"expected 42704 for unknown numeric id, got: {err}"
271+
);
272+
}
273+
274+
/// `PURGE TENANT <unknown_id> CONFIRM` → `42704`, never a destructive dispatch
275+
/// for a tenant that does not exist.
276+
#[tokio::test]
277+
async fn purge_tenant_unknown_numeric_id_errors() {
278+
let state = make_state_with_catalog();
279+
let su = superuser();
280+
281+
let err = ddl_err(&state, &su, "PURGE TENANT 999004 CONFIRM").await;
282+
assert!(
283+
err.contains("42704") && err.contains("does not exist"),
284+
"expected 42704 for unknown numeric id, got: {err}"
285+
);
286+
}
287+
288+
// ─── System tenant + catalog-unavailable guards ──────────────────────────────
289+
290+
/// `DROP TENANT 0` — the system tenant is protected with `42501` regardless of
291+
/// the resolver refactor.
292+
#[tokio::test]
293+
async fn drop_system_tenant_numeric_errors() {
294+
let state = make_state_with_catalog();
295+
let su = superuser();
296+
297+
let err = ddl_err(&state, &su, "DROP TENANT 0").await;
298+
assert!(
299+
err.contains("42501") && err.contains("system tenant"),
300+
"expected 42501 system-tenant guard, got: {err}"
301+
);
302+
}
303+
304+
/// `PURGE TENANT 0 CONFIRM` — the system tenant cannot be purged.
305+
#[tokio::test]
306+
async fn purge_system_tenant_numeric_errors() {
307+
let state = make_state_with_catalog();
308+
let su = superuser();
309+
310+
let err = ddl_err(&state, &su, "PURGE TENANT 0 CONFIRM").await;
311+
assert!(
312+
err.contains("42501") && err.contains("system tenant"),
313+
"expected 42501 system-tenant guard, got: {err}"
314+
);
315+
}
316+
317+
/// `DROP TENANT <name>` with no catalog wired up → `42601`, directing the
318+
/// caller to use a numeric id. Exercises the catalog-unavailable branch of
319+
/// `resolve_tenant_ref` that the catalog-backed fixtures cannot reach.
320+
#[tokio::test]
321+
async fn drop_tenant_by_name_without_catalog_errors() {
322+
let state = make_state();
323+
let su = superuser();
324+
325+
let err = ddl_err(&state, &su, "DROP TENANT some_tenant_name").await;
326+
assert!(
327+
err.contains("42601") && err.contains("catalog"),
328+
"expected 42601 catalog-unavailable error, got: {err}"
329+
);
330+
}

0 commit comments

Comments
 (0)