You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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 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
Order server-side and drop the cap — tryFind(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.
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.
(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.
Measured on
@objectstack/plugin-security17.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
singleposture, first-user promotion becomes development-only — and it ispm:blockedbehind a transitive chain (#11978 → #11975 → #13515). This is a defect in the selection as it ships under 4A today, and it survives 4B: development-modesinglekeeps 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:571and
oldestAuthenticable(:557-564) sorts that array:The read has no
orderand 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
50passes without remark.Measured harm
Same artifact,
OS_PLATFORM_OWNER_EMAIL=admin@objectos.aiset, 113 seededsys_userrows of which 7 carry credentials, the intended owner inserted first:admin@objectos.ai✅usr_ats_c01 … usr_ats_c50; the owner is not in the window at allcandidate01@mail.example❌Verbatim from the sqlite boot:
A job-seeker persona received the unscoped
admin_full_accessgrant, andclaimSeedOwnershipthen 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_ENVis imported into this very file (:73) and used at:447-480— but only on the walled branch. Thesinglebranch at:571does 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
tryFind(ql, 'sys_user', {}, …)with anorderoncreated_atascending, so the sort is over all users rather than a sample. Smallest change; makes the result driver-independent; does not alter the policy.singleposture — 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_ata 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.