Skip to content

plugin-security: the already_have_admin short-circuit reads sys_user_permission_set with an UNORDERED cap of 50, so an existing unscoped platform admin can be missed and a SECOND one minted #16861

Description

@os-trump

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions