Found while implementing #16682. It is the same defect class as that card, in the same function, on a different read — and #16682's triage scoped that card to the promotion read, so this is filed rather than folded in.
The read
packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, the holders read that feeds the already_have_admin short-circuit (line 394 on origin/main at the time of writing; it sits just above the :426 exact read that #16682's triage cites as the CONTRAST showing how a careful read in this file looks):
const existingAdminLinks = await tryFind(
ql,
'sys_user_permission_set',
{ permission_set_id: adminPsId },
50,
);
const humanUnscopedHolders = existingAdminLinks.filter(
(r) => !r.organization_id && r.user_id !== SystemUserId.SYSTEM,
);
if (!walled && humanUnscopedHolders.length > 0) {
return { ..., reason: 'already_have_admin', ... };
}
No order, a cap of 50, and the predicate that actually decides — !organization_id — is applied client-side, to whatever 50 rows the driver returned first.
Why the population reaches 50
admin_full_access is not only the platform-admin set. Every organization-scoped grant of that same set writes a sys_user_permission_set row carrying the same permission_set_id, so the rows this query counts grow with the number of org admins, not with the number of platform admins. A tenant install with fifty-odd org admins fills the window with rows that all fail the !organization_id filter.
The consequence
If the one unscoped human holder is not among the 50 rows the driver hands back:
humanUnscopedHolders is empty,
- the
already_have_admin short-circuit does not fire,
- selection runs and
tryInsert mints a second unscoped admin_full_access grant,
- and
claimSeedOwnership then re-owns seeded business rows to the newly promoted user.
That is the opposite direction from #16682's harm but the same root: an unordered, capped read whose truncation decides a security answer. It is also silent — the boot logs a successful promotion, exactly as it does on a genuinely fresh install.
The already_have_admin short-circuit is load-bearing beyond this path: #14348's case D pins it as the guarantee that a fresh selector can never re-point an existing platform admin ("Moving an already-granted platform admin is reserved to the maintainer"). A short-circuit that can be skipped by row-count is that guarantee failing open.
Not fixed in #16682's PR, deliberately
#16682's triage ruled its scope explicitly (fixes 1 + 2 at the promotion read, and ⛔ do not touch :426). The in-place-fix carve-out does not cover this one either: repairing it changes when the short-circuit fires, which is permission-boundary behaviour and needs its own tests, so it is a separate change rather than a rider.
Suggested shape (not a ruling)
The mechanical fix is to stop deciding client-side: put organization_id: null in the where so the driver answers the question the code is actually asking, or — if null matching is not uniform across driver families — order the read and bound it the way #16682's PR bounds the candidate scan, warning when the bound is reached. Either way the guard should be able to say how many rows it examined.
Reproduction sketch
Seed one unscoped human admin_full_access grant plus 60 organization-scoped grants of the same set, arrange for the unscoped row to sort last under the driver's natural order, and run bootstrapPlatformAdmin under the single posture: it returns adminPromoted: true and a second unscoped grant row exists. With fewer than 50 total grant rows the same fixture returns already_have_admin.
Found while implementing #16682. It is the same defect class as that card, in the same function, on a different read — and #16682's triage scoped that card to the promotion read, so this is filed rather than folded in.
The read
packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, the holders read that feeds thealready_have_adminshort-circuit (line 394 onorigin/mainat the time of writing; it sits just above the:426exact read that #16682's triage cites as the CONTRAST showing how a careful read in this file looks):No
order, a cap of50, and the predicate that actually decides —!organization_id— is applied client-side, to whatever 50 rows the driver returned first.Why the population reaches 50
admin_full_accessis not only the platform-admin set. Every organization-scoped grant of that same set writes asys_user_permission_setrow carrying the samepermission_set_id, so the rows this query counts grow with the number of org admins, not with the number of platform admins. A tenant install with fifty-odd org admins fills the window with rows that all fail the!organization_idfilter.The consequence
If the one unscoped human holder is not among the 50 rows the driver hands back:
humanUnscopedHoldersis empty,already_have_adminshort-circuit does not fire,tryInsertmints a second unscopedadmin_full_accessgrant,claimSeedOwnershipthen re-owns seeded business rows to the newly promoted user.That is the opposite direction from #16682's harm but the same root: an unordered, capped read whose truncation decides a security answer. It is also silent — the boot logs a successful promotion, exactly as it does on a genuinely fresh install.
The
already_have_adminshort-circuit is load-bearing beyond this path: #14348's case D pins it as the guarantee that a fresh selector can never re-point an existing platform admin ("Moving an already-granted platform admin is reserved to the maintainer"). A short-circuit that can be skipped by row-count is that guarantee failing open.Not fixed in #16682's PR, deliberately
#16682's triage ruled its scope explicitly (fixes 1 + 2 at the promotion read, and ⛔ do not touch
:426). The in-place-fix carve-out does not cover this one either: repairing it changes when the short-circuit fires, which is permission-boundary behaviour and needs its own tests, so it is a separate change rather than a rider.Suggested shape (not a ruling)
The mechanical fix is to stop deciding client-side: put
organization_id: nullin thewhereso the driver answers the question the code is actually asking, or — if null matching is not uniform across driver families — order the read and bound it the way #16682's PR bounds the candidate scan, warning when the bound is reached. Either way the guard should be able to say how many rows it examined.Reproduction sketch
Seed one unscoped human
admin_full_accessgrant plus 60 organization-scoped grants of the same set, arrange for the unscoped row to sort last under the driver's natural order, and runbootstrapPlatformAdminunder thesingleposture: it returnsadminPromoted: trueand a second unscoped grant row exists. With fewer than 50 total grant rows the same fixture returnsalready_have_admin.