Skip to content

Commit 38bfc30

Browse files
committed
fix(tenant): reject duplicate CREATE TENANT names
A duplicate tenant name previously succeeded as a silent no-op, minting a second tenant id that shares the name. That makes every by-name lookup (ownership fallback, admin provisioning, DROP TENANT) ambiguous and strands the older tenant's objects. Now a duplicate name errors unless IF NOT EXISTS is given, in which case it remains a no-op.
1 parent ffaead5 commit 38bfc30

3 files changed

Lines changed: 114 additions & 16 deletions

File tree

nodedb/src/control/cluster/recovery_check/integrity.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,29 @@
2323
//! - Every `StoredRlsPolicy.collection` exists as a
2424
//! `StoredCollection` row.
2525
//!
26-
//! None of these are auto-repaired. Redb is not the source of
27-
//! truth — the raft log is — and the safe recovery for any
28-
//! redb corruption is "re-run the applier from the log",
29-
//! which is the operator's job. The integrity check reports
30-
//! every violation and the sanity-check wrapper aborts
31-
//! startup on any non-empty violation list.
26+
//! This module only *detects*; it never repairs. Redb is not the
27+
//! source of truth — the raft log is — and the general recovery
28+
//! for redb corruption is "re-run the applier from the log",
29+
//! which is the operator's job.
30+
//!
31+
//! Three ownership/grant classes are healed before the abort gate,
32+
//! because they are reachable from ordinary DDL rather than from
33+
//! storage corruption, and each would otherwise leave an existing
34+
//! data directory permanently unbootable with no repair path.
35+
//! `verify_and_repair` runs `repair_integrity::heal_orphan_rows`
36+
//! to a fixpoint over this module's output:
37+
//!
38+
//! - a primary row whose `StoredOwner` is missing — the owner row
39+
//! is rebuilt from the primary's in-band owner;
40+
//! - `owner(...)` → `user(...)` dangling — the owner row is
41+
//! restored from the primary's in-band owner when that user
42+
//! still exists, otherwise reassigned to the tenant's resolved
43+
//! administrative principal;
44+
//! - `permission(...)` → `user(...)` dangling — the grant is
45+
//! revoked, since a grant to a nonexistent user confers nothing.
46+
//!
47+
//! Every other violation above is reported and left alone.
48+
//! Startup aborts only on violations that survive the pass.
3249
3350
use std::collections::HashSet;
3451

nodedb/src/control/server/shared/ddl/neutral/tenant/create.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,22 @@ pub fn create_tenant(
9898
let name = parts[2];
9999
let opts = parse_tenant_options(&parts[3..])?;
100100

101-
// `IF NOT EXISTS`: if a tenant with this name already exists, the
102-
// statement is a no-op success — do not allocate a second id.
103-
if if_not_exists
104-
&& state
105-
.credentials
106-
.catalog()
107-
.find_tenant_by_name(name)
108-
.map_err(|e| ddl_err("XX000", format!("catalog read: {e}")))?
109-
.is_some()
101+
// Tenant names are unique. A duplicate is a no-op success under
102+
// `IF NOT EXISTS` and an error otherwise — never a second tenant id
103+
// sharing the name, which would make the name ambiguous for every
104+
// by-name lookup (ownership fallback, admin provisioning, DROP TENANT)
105+
// and silently strand the older tenant's objects.
106+
if state
107+
.credentials
108+
.catalog()
109+
.find_tenant_by_name(name)
110+
.map_err(|e| ddl_err("XX000", format!("catalog read: {e}")))?
111+
.is_some()
110112
{
111-
return Ok(status("CREATE TENANT"));
113+
if if_not_exists {
114+
return Ok(status("CREATE TENANT"));
115+
}
116+
return Err(ddl_err("42710", format!("tenant '{name}' already exists")));
112117
}
113118

114119
// Pick the tenant id. An explicit `ID <n>` is honored verbatim;

nodedb/tests/pgwire_tenant_scoping.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,79 @@ async fn show_tenants_includes_tenant_name() {
574574
"SHOW TENANTS must report the tenant name; row was {acme:?}"
575575
);
576576
}
577+
578+
/// Tenant names are unique: a second `CREATE TENANT` with the same name is
579+
/// rejected rather than minting a second tenant id sharing that name. A
580+
/// duplicate name makes every by-name lookup ambiguous (ownership fallback,
581+
/// admin provisioning, `DROP TENANT`) and strands the older tenant's objects.
582+
#[tokio::test]
583+
async fn duplicate_tenant_name_is_rejected() {
584+
let server = TestServer::start().await;
585+
server
586+
.exec("CREATE TENANT probe")
587+
.await
588+
.expect("first CREATE TENANT must succeed");
589+
590+
let err = server
591+
.exec("CREATE TENANT probe")
592+
.await
593+
.expect_err("duplicate tenant name must be rejected");
594+
assert!(
595+
err.contains("already exists"),
596+
"duplicate CREATE TENANT must report the name conflict, got: {err}"
597+
);
598+
599+
// IF NOT EXISTS remains a no-op success on the same name.
600+
server
601+
.exec("CREATE TENANT IF NOT EXISTS probe")
602+
.await
603+
.expect("CREATE TENANT IF NOT EXISTS on an existing name must succeed");
604+
605+
let rows = server
606+
.query_named_rows("SHOW TENANTS")
607+
.await
608+
.expect("SHOW TENANTS");
609+
let named: Vec<_> = rows
610+
.iter()
611+
.filter(|r| r.get("name").map(String::as_str) == Some("probe"))
612+
.collect();
613+
assert_eq!(
614+
named.len(),
615+
1,
616+
"exactly one tenant may carry the name 'probe'; rows were {named:?}"
617+
);
618+
}
619+
620+
/// A superuser can administer a pre-existing tenant-homed collection it does
621+
/// not own by switching the session's effective tenant. Without `SET TENANT`
622+
/// the superuser is scoped to its own tenant and the collection is correctly
623+
/// invisible; with it, ownership is satisfied by superuser status.
624+
#[tokio::test]
625+
async fn superuser_can_drop_tenant_homed_collection_after_set_tenant() {
626+
let server = TestServer::start().await;
627+
bootstrap_tenant_user(&server, "acme_user", "acme_notes").await;
628+
629+
// Unqualified, the superuser session does not see the tenant's collection.
630+
let unscoped = server.exec("DROP COLLECTION acme_notes").await;
631+
assert!(
632+
unscoped.is_err(),
633+
"superuser must not resolve a tenant-homed collection without SET TENANT"
634+
);
635+
636+
let (svc, _h) = server
637+
.connect_as("nodedb", "nodedb")
638+
.await
639+
.expect("superuser connect");
640+
svc.simple_query("SET TENANT = 'acme'")
641+
.await
642+
.expect("superuser SET TENANT");
643+
svc.simple_query("DROP COLLECTION acme_notes")
644+
.await
645+
.expect("superuser must drop a tenant-homed collection it does not own");
646+
647+
let remaining = svc
648+
.simple_query("SELECT id FROM acme_notes")
649+
.await
650+
.expect_err("dropped collection must no longer resolve");
651+
let _ = remaining;
652+
}

0 commit comments

Comments
 (0)