fix(plugin-security): fail closed on a non-object row in the platform-admin promotion predicate - #12556
Conversation
…omotion predicate (#12515) `bootstrapPlatformAdmin`'s local `isHumanUser` used a bare truthiness check followed by two property comparisons, so a truthy NON-object input scored human: `.id` and `.role` are both `undefined` on a non-object and both comparisons pass. The consolidated owner of the same question, `isHumanUserRow` in plugin-auth, requires `typeof row === 'object'` and answers non-human. The disagreement fell the wrong way on the copy that PERFORMS platform-admin promotion — it failed OPEN. Mirror `isHumanUserRow` exactly rather than inventing a stricter rule: over-tightening a promotion predicate means an install that cannot promote its first admin. Measured against a real SqlDriver over the shipped SysUser declaration first — every row a real `sys_user` read yields is a plain object, zero truthy non-objects, zero verdicts moved by the guard. The same guard already filters the byte-identical read in plugin-auth's dev-admin seed, so it is the incumbent on this population. Extend the cross-package agreement pin to the non-object class it previously had to exclude (it would have failed). The 14 existing agreement cases are unmoved. Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 11b0b2882e37c025e50687dc267445360a752570 && git checkout 11b0b2882e37c025e50687dc267445360a752570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 52a98238876fd5183ff087ce6cca1adb195a9636 235de87c8c26f4c2dc4a32af79df0db6821b3acb && git checkout -B drift-repro 52a98238876fd5183ff087ce6cca1adb195a9636 && git merge --no-ff 235de87c8c26f4c2dc4a32af79df0db6821b3acb
node scripts/docs-audit/affected-docs.mjs --json 52a98238876fd5183ff087ce6cca1adb195a9636
|
Fixes #12515
Two owners answer "is this
sys_userrow a HUMAN?", and they disagreed on truthynon-object input. The copy that disagreed is the one that performs
platform-admin promotion, and it disagreed in the fail-OPEN direction.
On
'usr_alice',42ortrue,.idand.roleare bothundefined, so bothcomparisons pass and the input scores human.
isHumanUserRow(
plugin-auth/src/audience-posture.ts:317), the #11767-consolidated owner of thesame question, requires
typeof row === 'object'and answers non-human.Shape ruled at triage's 07:20Z grading and re-confirmed at the 09:14Z unlock:
option B — align the plugin-security copy to the fail-closed rule — plus the
card's option C, extending #12512's agreement pin to the class it had to
exclude. Option A (close on record) was rejected: the asymmetry has no scheduled
retirement, since consolidation was explicitly declined at #11959's grading.
The change
!!completes the mirror:isHumanUserRowreturns a real boolean, where the oldclosure echoed a falsy input back. Both consumers (
:426,:472) read it inboolean position, so that half is decision-identical.
⭐ The measurement that had to come first
Over-tightening this predicate is worse than the bug it fixes: a guard that
excluded a row shape the real
sys_userread legitimately produces would mean aninstall unable to promote its first admin — locked out of itself, the same
symptom class the parent card exists to prevent, arriving from the other
direction. So the guard was measured before it was chosen, and it mirrors
isHumanUserRowrather than inventing a stricter rule.Leg 1 — what the real read yields. A real
SqlDriver(sqlite:memory:),real DDL from the shipped
SysUserdeclaration in@objectstack/platform-objects,read through the exact production call shape:
Leg 2 — the guard is already the incumbent on this exact population.
plugin-auth's dev-admin seed (auth-plugin.ts:1704) filters the byte-identicalread through
isHumanUserRowtoday:auth-plugin.ts:1704find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } })isHumanUserRowbootstrap-platform-admin.ts:471find('sys_user', { where: {}, limit: 50 }, { context: { isSystem: true } })isHumanUserSystemObjectName.USER === 'sys_user'. Same object, same empty where, same limit,same system context. If this guard excluded a legitimately-produced row, that seed
would already be misfiring and the audience gate already refusing sign-ups.
⇒ No reachable answer changes. The 14 existing agreement cases are unmoved.
The pin extension
NON_OBJECT_CORPUSis a separate array fromCORPUS, deliberately.CORPUS's stated contract is that every entry is a shape a real read can return,and these are not — filing them in would have quietly falsified that promise and
blurred how a failure should be read. A red in
CORPUSmeans a reachable answermoved; a red in
NON_OBJECT_CORPUSmeans the fail-closed guard was dropped.Seven cases: a bare id string, the system account's own id as a bare string
(fail-open would have promoted the service account), a number,
true, a function,and two falsy non-objects whose decision already agreed. Each asserts
isHumanUserRowisfalseabsolutely rather than only that the two agree —two predicates can agree by both failing open, which is the outcome this group
exists to forbid. Plus a group-level anti-vacuity test that fails if the truthy
members are ever quietly removed.
Dissolution verification
Guard reverted, pin left extended. Both legs rebuilt — the pin resolves
@objectstack/plugin-securitythrough itsexportsmap, i.e. throughdist/, soa verdict read without rebuilding would have measured the wrong artifact.
Mutation confirmed on disk before anything was read (removed-text occurrences 0,
injected-text occurrences 1), and confirmed absent from the built artifact:
Result — red on the non-object rows specifically, not a whole-file failure:
reason: nonerather thanno_users: the ablated code did not merelymis-classify the string, it promoted it to platform admin.
Restored and verified:
disk == index == HEAD(blob945365e67553c357611091e92211ca68ad9551b8on both sides,git diff HEADempty),rebuilt, marker present in
distagain.Verification (all at
235de87c)plugin-securitysuiteplugin-authsuitetypecheck(both packages)Done, both script names echoedcheck:i18nOK (9 package(s) — all bundles in sync)check:type-check-debt --re-measureOK — 32 ledger entr(ies) re-measured, none above its recorded numberpnpm lint(whole repo,eslint . --no-inline-config)Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the real changed set, exit codes captured before any pipe.
check:i18nfirst returnedPREREQUISITE NOT MET — the workspace CLI is not built(exit 1 while checking nothing). That is NOT MEASURED, not a red: the CLI was built
and it was re-run to the green above.
check:type-check-debtlikewise refusedoutright until
@objectstack/service-knowledgewas built.Pin file type coverage — measured, not assumed
plugin-auth'stsconfig.jsonexcludes**/*.test.ts, sopnpm typechecksaysnothing about the pin file. Proven rather than assumed, via
tsc --noEmit --listFiles:0 of 81 test files appear in that program.
The pin file is compiled — by the program
check:type-check-debt --re-measurebuilds over the hidden test layer. Reproducing that program (same tsconfig, test
exclusion lifted) lists the pin file among all 81 test files and reports 97
errors, matching plugin-auth's recorded
TEST_DEBTof 97 to the unit, with zeroof them in the pin file. That ledger entry has zero margin ("surplus: none —
every entry sits exactly at its measurement, so any new error is red"), so the
green re-measure is a real statement about this file.
Generated by Claude Code