Skip to content

plugin-security: the first-user promotion picks the oldest authenticable user from an UNORDERED 50-row sys_user window, so on the default driver a seeded job seeker became platform admin and owned every seeded row #16682

Description

@os-sam

Measured on @objectstack/plugin-security 17.3.0 while building an app on the platform (tracked there as objectstack-ai/ats#40; the app's own card notes it could not verify upstream write access, so I am relaying it with the source verified here).

This is not #11979. That card is the Choice-4B design follow-up — config-anchor the single posture, first-user promotion becomes development-only — and it is pm:blocked behind a transitive chain (#11978#11975#13515). This is a defect in the selection as it ships under 4A today, and it survives 4B: development-mode single keeps first-user promotion, and it would keep selecting the same wrong way.

It is also the next failure of the function #14348 last repaired. That card fixed "promotes a row nobody can log in as" by requiring a sys_account. Once an app seeds credentials — which #14348's own fix invites — the requirement is satisfied by many users, and which one wins is decided by something nobody chose.

The selection

packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:571

const allUsers = await tryFind(ql, 'sys_user', {}, 50);
const humanUsers = allUsers.filter(isHumanUser);

const target = await oldestAuthenticable(ql, humanUsers);

and oldestAuthenticable (:557-564) sorts that array:

const oldestAuthenticable = async (ql2: any, users: any[]): Promise<any | undefined> => {
  const byAge = [...users].sort(byCreatedAtAsc);
  for (const user of byAge) {  }

The read has no order and a cap of 50. So "the oldest authenticable user" is really the oldest authenticable user among whatever 50 rows the driver happened to return first. With more than 50 users the intended target may not be in the window at all, and the client-side sort cannot notice — it sorts a sample and reports a global answer.

The comment above the read explains the posture decision carefully and the constant 50 passes without remark.

Measured harm

Same artifact, OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai set, 113 seeded sys_user rows of which 7 carry credentials, the intended owner inserted first:

driver what fills the 50-row window promoted
memory insertion order — owner is row 1 admin@objectos.ai
sqlite (the default) id order — usr_ats_c01 … usr_ats_c50; the owner is not in the window at all candidate01@mail.example

Verbatim from the sqlite boot:

[security] first user promoted to platform admin: candidate01@mail.example

A job-seeker persona received the unscoped admin_full_access grant, and claimSeedOwnership then handed it ownership of every seeded business row. Same code, same config, same data — the answer changed with the storage driver.

The configured owner is right there and this path never asks

PLATFORM_OWNER_EMAIL_ENV is imported into this very file (:73) and used at :447-480 — but only on the walled branch. The single branch at :571 does not consult it. So a deployment that has declared who the owner is can still have someone else promoted, which is the part that turns a nondeterminism bug into a security one.

Three fixes, cheapest first

  1. Order server-side and drop the captryFind(ql, 'sys_user', {}, …) with an order on created_at ascending, so the sort is over all users rather than a sample. Smallest change; makes the result driver-independent; does not alter the policy.
  2. Prefer the declared owner when one is registered, before falling back to oldest-authenticable. The anchor already exists and is already imported in this file — this is the fix that makes the promotion match what the operator asked for.
  3. What platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback #11979 rules, when it unblocks.

(1) and (2) are worth doing regardless of when (3) lands, because (3) explicitly preserves first-user promotion for development.

Suggested regression test

Seed more than 50 users where the intended owner sorts last by id and first by created_at, and assert the same user is promoted on the memory and sqlite drivers. That is the shape that fails today and the shape a client-side sort over a capped window cannot pass.

What the app did meanwhile, and why it is not a fix

It pinned the owner row to win under every ordering at once — id usr_ats_0_owner (collates before every other id), inserted first, created_at a year older than everyone. Re-measured green on both drivers. That is a seed working around a rule it should not have to know, and it holds only while no other seeded user sorts first on all three axes.

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

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions