Skip to content

plugin-security promotes the OLDEST human sys_user row, so an app that seeds a people directory grants platform admin to a row nobody can log in as #14348

Description

@claude

Found while implementing #14157 (the dev-admin seed gating on user rows instead of on a login). Filed unassigned; this is a CODE READING at 909a441, not an end-to-end measurement — the composed boot was not driven.

The reading

bootstrapPlatformAdmin (packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, the single posture) selects its promotion target as the OLDEST human sys_user row:

507  const allUsers = await tryFind(ql, 'sys_user', {}, 50);
508  const humanUsers = allUsers.filter(isHumanUser);
513  const target = oldestOf(humanUsers);        // sorted by created_at, first wins
526  logger.info('[security] first user promoted to platform admin: ' + target.email)

isHumanUser there is the mirror of isHumanUserRow — it excludes only the legacy usr_system service account. A person declared in defineStack({ data }) is an ordinary human row with no sys_account, and the declarative seed is awaited inside AppPlugin.start(), so those rows are always OLDER than any account created at kernel:ready or later.

So on an app that seeds people, the reading is that admin_full_access is granted to person0@demo.example — a directory row with no credential, which nobody can sign in as — while the account that CAN sign in is not promoted. sys_user_permission_set then carries a grant that can never be exercised, and the loginable admin lands in an app it cannot read.

Two things make this worth a card rather than a footnote:

  1. It is the SECOND half of the shape maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 fixes. That card moved the dev-admin seed off "any human row" and onto "a login exists"; this site still treats a directory row as a candidate for the platform-admin grant. After maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 lands, objectstack dev on a people-seeding app DOES mint a loginable admin — and on this reading that admin still would not be promoted, so the CLI's declared "then promote it to platform admin" is only half delivered. maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 deliberately did not touch it: choosing between "oldest human", "oldest human WITH an account", and "the account the seed just provisioned" is a design decision, not a mechanical repair.
  2. The grant is WRITTEN, not merely computed — an unusable row lands in the permission tables and stays there.

What to measure before fixing

  • Drive a real composed boot (objectstack dev) on an app whose defineStack({ data }) declares sys_user rows, then read sys_user_permission_set: which user_id holds admin_full_access, and does that user have any sys_account row?
  • Check the ordering: does bootstrapPlatformAdmin run before or after AuthPlugin's kernel:ready seed? The selection is over whatever population exists when it runs.
  • claimSeedOwnership hands seeded business records to the promoted admin, so a wrong target also mis-assigns ownership.

Direction, not a decision

The narrow reading of "first user" that the code comment gives ("the real admin login") suggests the target should be the oldest human that can actually authenticate — one with a sys_account row — which is the same row-versus-login correction #14157 made one package over. Adjacent but distinct: #11979 is about config-anchoring the single posture itself, not about which row it selects.

Generated by Claude Code


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions