Uh oh!
There was an error while loading. Please reload this page.
feat(security): bind the last-admin standing-key lists to the authz resolver's measured read surface (#8734) - #8801
Conversation
…ver's measured read surface (#8734) The break-glass guard's three standing-key lists are a cache of which columns resolveAuthzContext reads when deriving administrator standing. Nothing bound them together — the correspondence was a comment, and it had already gone false once (#6084's comment named 'active' as invisible; #8613 made it a resolution-time predicate). Two links replace the prose: 1. packages/core declares ADMIN_STANDING_SURFACE beside the resolver and asserts it EQUALS what the real resolveAuthzContext reads, observed at runtime through a recording engine (property accesses + where keys, per table). Observation rather than static extraction because the reads that matter live in helpers: isRowActive reads 'active', isGrantActive reads the ADR-0091 bounds. 2. plugin-auth exports its lists plus STANDING_KEYS_BY_TABLE / STANDING_KEY_EXCLUSIONS, and a gate requires every measured column to be either standing-bearing or excluded with a reason. No third state. No guard behaviour changes: every list keeps its exact values, and the gate is one-directional so it can only ever demand the guard judges more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also reference the affected code. These are read-only:
|
qq9340100
commented
Aug 15, 2026
ACCEPT — PM review, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8734
plugin-auth's last-administrator guard (ADR-0024 D5.2) decides whether a pending write can empty the administrator population by testing the payload against three standing-key lists. A payload touching none of them is skipped without any reads — so a columnresolveAuthzContextstarts reading that a list omits is a write class the guard silently stops judging, on the one path whose failure mode is an installation-wide administrator lockout with no in-product recovery.Nothing bound the two together. The correspondence lived in a comment, and it had already gone false once: #6084 wrote — naming
activeexplicitly — that everything a permission-set write touches other thannameis invisible to "who is an administrator". True when written; #8613 madeactivea resolution-time predicate and the sentence became false. Nothing mechanical would have caught it, because the guard's own tests stay green precisely when the guard is never consulted.Which shape, and why
The card sketched three. Measured, then chosen:
resolveAuthzContexttakesql: any,tryFindreturns whole rows, and every column read is an untyped property access. No shared constant can force the resolver to consume it — a newrow.newColumncompiles and runs whatever the constant says. The dependency direction does exist (plugin-authalready importsisGrantActive/isRowActivefrom@objectstack/core), so shape 2 is reachable; it is just not sufficient.check:script extracting column literals) would have to inline the helpers to work.activeis never named at the resolver's own call site —isRowActive(r)reads it — and neither are the ADR-0091 bounds (isGrantActive(row, now)). A static extractor reading the caller and not the callee missessys_permission_set.activeandsys_position.activeare unenforced too — both Deactivate dialogs promise access stops, and it does not #8613 for the same reason the comment did. It would also need real dataflow analysis to learn thatps.namein aforoverpsRowsis a read ofsys_permission_set.name.What landed is shape 2 made enforceable by runtime observation, in two links:
@objectstack/coredeclaresADMIN_STANDING_SURFACEbeside the resolver — every table the administrator-derivation path reads, each classifiedderivesorreads-onlywith its reason, and for the deriving tables every column read. It is asserted equal to what the realresolveAuthzContextreads, observed by driving it over a recording engine that records every property access and everywherekey per table. The declaration is a measurement, not a wish.plugin-authconsumes that measurement and requires every column to have an answer: standing-bearing (in a list) or excluded with the reason it cannot empty the administrator population. There is no third state — the third state is exactly whatactivewas between break-glass 不变量的第四条路径无守卫:删/改名admin_full_access那条sys_permission_set行,一次废掉所有 platform admin #6084 andsys_permission_set.activeandsys_position.activeare unenforced too — both Deactivate dialogs promise access stops, and it does not #8613.Observation also fixes the discoverability half the card names ("whoever next edits the resolver happening to remember the guard exists in another package"): link 1 lives in
packages/coreand fails in the resolver author's ownpnpm test, on source, with no build.The gate can fail — demonstrated, four ways
Each leg was run from a committed state and restored; the direction was predicted before running.
activefromPERMISSION_SET_STANDING_KEYS(the #8613 regression, inverted)expected [ 'active' ] to deeply equal []ps.locked_outon the platform-admin pathexpected [ 'active', 'id', 'locked_out', …(5) ] to deeply equal [ 'active', 'id', 'name', …(4) ]locked_outin core (the natural next step)expected [ 'locked_out' ] to deeply equal []sys_admin_delegationderives, plugin-auth RED:expected [ 'sys_member', …(2) ] to include 'sys_admin_delegation'Leg B also demonstrated the reverse direction on its way out: a column left declared after its read was removed goes red as a stale declaration.
Would this have caught #8613?
Yes, at the earlier link.#8613 added
isRowActive(r)to thesys_permission_setread, which makesactiveobservable — link 1 goes red on that PR, naming the column. Declaring it turns link 2 red, becauseactivewas in neither the list nor a ledger. Landing #8613 green would have required writing down, explicitly, that deactivatingadmin_full_accesscannot empty the administrator population — which is false, and which is what the old comment asserted by accident. Leg A above is that scenario replayed.Sibling lists: covered, all three
MEMBER_STANDING_KEYS,GRANT_STANDING_KEYSandPERMISSION_SET_STANDING_KEYSare all keyed intoSTANDING_KEYS_BY_TABLEand all three are checked. Keying by table is what also closes the table-level hole: a resolver deriving standing from a new table is invisible to any column-set comparison, since the table is absent from both sides (leg C).Ruling 2: the guard is not narrowed, and cannot be by this gate
Every list keeps exactly the values it had;
touchesAnyis called with the same constants. The gate is one-directional by construction — it never asserts that a standing key is a column the resolver reads. That direction looks like the natural other half and is not:GRANT_STANDING_KEYScarriesuserIdalthough the resolver reaches those rows through awhereonuser_idand never touches the camelCase spelling on the row. Enforcing it would pressure a break-glass guard to drop entries. This gate can only ever demand that the guard judges more.Ruling 4: nothing asymmetric in the lists, one dormant finding next door
The lists are correct today — every column the resolver reads on the three deriving tables is either judged or has a stated reason it cannot matter, and no reason required inventing.
One thing surfaced that is not about the lists and was not touched here: in
resolveUserAuthzGrants,isGrantActiveis applied tosys_memberrows only when buildingaccessible_org_ids; the org-administrationroleprojection that feedspositionsis not window-filtered. Dormant —sys_memberdeclares novalid_from/valid_untiltoday — and it is a resolver question, not a guard one. Recorded separately as afindingin #8802, and captured inSTANDING_KEY_EXCLUSIONSas the reason those bounds are not standing-bearing, with a note that the resolver leads and the list follows.Verification
pnpm --filter @objectstack/core --filter @objectstack/plugin-auth test— core 838 passed (35 files), plugin-auth 1238 passed (54 files)pnpm --filter @objectstack/plugin-auth typecheck— cleanscripts/pm/dispatch-gates.mjsagainst the actual changed paths and run:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:kernel-hook-pairs,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check:nul-bytes,check:query-options-erasure,check:type-check-coverage, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset— all pass.check:type-check-debt(the--re-measureratchet) — it refuses outright on an unbuilt worktree, so the full closure was built first (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and then it ran: 33 ledger entries re-measured, 1926 raw tsc errors, none above its recorded number. The one informational surplus it reports (@objectstack/lint, -1) is pre-existing and in a package this diff does not touch.Verified at
5ae537580.Generated by Claude Code