Skip to content

Commit 47f651e

Browse files
committed
fix(tenant): prevent ghost rows in SHOW TENANTS after DROP TENANT
SHOW TENANTS derives its row set from the union of catalog-registered tenants and every active user's tenant_id. When DROP TENANT removed the catalog row but left the auto-provisioned <name>_admin user in place, the union query resurfaced the dropped tenant as a ghost row — same id, empty name — making it appear the drop had failed or that a nameless tenant still existed. Add reconcile_tenant_users, called before the catalog row is removed: * The lifecycle-owned <name>_admin (auto-provisioned by CREATE TENANT) is hard-dropped through the canonical DROP USER path so ownership reassignment, session invalidation, and catalog removal all run. * Any other user in the tenant is operator-owned. The drop is refused with SQLSTATE 42501 and the remaining usernames are surfaced in the error, so no real account is ever silently hard-deleted by a tenant drop. When no catalog is wired up there is no persisted StoredTenant to surface as a ghost in the union, so reconcile_tenant_users returns early without performing any work.
1 parent ef8b599 commit 47f651e

1 file changed

Lines changed: 96 additions & 0 deletions

File tree

  • nodedb/src/control/server/pgwire/ddl/tenant

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ 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;
1819

1920
use super::super::super::types::sqlstate_error;
2021
use super::super::parse_utils::strip_if_exists;
@@ -77,6 +78,21 @@ pub fn drop_tenant(
7778
));
7879
}
7980

81+
// Reconcile the tenant's users before removing the tenant row.
82+
//
83+
// `SHOW TENANTS` derives its row set from the union of catalog
84+
// tenants and every user's `tenant_id`, so any user left pointing
85+
// at this tenant resurrects it as a ghost row (retained id, empty
86+
// name) after the catalog row is gone. To keep `DROP TENANT`
87+
// consistent with `DROP USER` (hard-delete, disappears from
88+
// `SHOW`), the tenant's users must be reconciled here:
89+
//
90+
// * the lifecycle-owned `<name>_admin` auto-provisioned by
91+
// `CREATE TENANT` is dropped as part of the tenant lifecycle;
92+
// * any other user is real and operator-owned — refuse the drop
93+
// (`42501`) and name them, so nobody is silently hard-deleted.
94+
reconcile_tenant_users(state, identity, tenant_id)?;
95+
8096
let entry = CatalogEntry::DeleteTenant { tenant_id: tid };
8197
let log_index = propose_catalog_entry(state, &entry)
8298
.map_err(|e| sqlstate_error("XX000", &format!("metadata propose: {e}")))?;
@@ -102,3 +118,83 @@ pub fn drop_tenant(
102118

103119
Ok(vec![Response::Execution(Tag::new("DROP TENANT"))])
104120
}
121+
122+
/// Reconcile the users that belong to `tenant_id` before its catalog
123+
/// row is removed, so the tenant cannot survive as a ghost in
124+
/// `SHOW TENANTS` (which unions catalog tenants with every user's
125+
/// `tenant_id`).
126+
///
127+
/// The lifecycle-owned `<name>_admin` auto-provisioned by
128+
/// `CREATE TENANT` is hard-dropped through the canonical `DROP USER`
129+
/// path. Any other user is operator-owned: the drop is refused with
130+
/// `42501` and the remaining users are named, so no real account is
131+
/// ever silently hard-deleted by a tenant drop.
132+
fn reconcile_tenant_users(
133+
state: &SharedState,
134+
identity: &AuthenticatedIdentity,
135+
tenant_id: TenantId,
136+
) -> PgWireResult<()> {
137+
// Tenant identity — its name, hence the `<name>_admin` of the
138+
// lifecycle-owned admin — lives only in the catalog. Without a
139+
// catalog there is no persisted `StoredTenant` to surface as a
140+
// ghost in `SHOW TENANTS`' catalog union, and no name to
141+
// reconstruct the admin from, so there is nothing to reconcile.
142+
let Some(tenant_name) = state
143+
.credentials
144+
.catalog()
145+
.as_ref()
146+
.and_then(|cat| cat.load_all_tenants().ok())
147+
.and_then(|all| {
148+
all.into_iter()
149+
.find(|t| t.tenant_id == tenant_id.as_u64())
150+
.map(|t| t.name)
151+
})
152+
else {
153+
return Ok(());
154+
};
155+
let admin_username = super::create::default_admin_username(&tenant_name);
156+
157+
// The same active-user set `SHOW TENANTS` unions over — reconciling
158+
// exactly this set is what clears the ghost.
159+
let members: Vec<String> = state
160+
.credentials
161+
.list_user_details()
162+
.into_iter()
163+
.filter(|u| u.tenant_id == tenant_id)
164+
.map(|u| u.username)
165+
.collect();
166+
167+
let mut lifecycle_admin = None;
168+
let mut others = Vec::new();
169+
for username in members {
170+
if username == admin_username {
171+
lifecycle_admin = Some(username);
172+
} else {
173+
others.push(username);
174+
}
175+
}
176+
177+
if !others.is_empty() {
178+
others.sort();
179+
return Err(sqlstate_error(
180+
"42501",
181+
&format!(
182+
"cannot drop tenant: {} user(s) still belong to it; drop or \
183+
reassign them first: {}",
184+
others.len(),
185+
others.join(", ")
186+
),
187+
));
188+
}
189+
190+
// Only the lifecycle-owned admin remains (if any): hard-delete it
191+
// through the canonical `DROP USER` handler so ownership
192+
// reassignment, session invalidation, and catalog + redb removal
193+
// all run — the same guarantees `DROP USER` gives directly.
194+
if let Some(admin) = lifecycle_admin {
195+
let parts = ["DROP", "USER", admin.as_str()];
196+
super::super::user::drop_user(state, identity, &parts)?;
197+
}
198+
199+
Ok(())
200+
}

0 commit comments

Comments
 (0)