fix(core): the effective map folds a super-user wildcard per set only — no cell checkObjectPermission refuses (#20136) - #20165
Conversation
…erged-bypass fold over a set's own narrower entry, no create on modifyAllRecords alone Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…_OVER_GRANT rows flip, rows b, d-h join the parity table Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…ding 18783, 18931 and 20134 notes the fix made false Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 24 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 07458450c0307b68190a264e9c8a73f0869ddef4 && git checkout 07458450c0307b68190a264e9c8a73f0869ddef4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84880f92243889d3e1daf9d4926d75277c9b5a94 6527062a22436a391ac47ce36efcd8b08d4fd2ac && git checkout -B drift-repro 84880f92243889d3e1daf9d4926d75277c9b5a94 && git merge --no-ff 6527062a22436a391ac47ce36efcd8b08d4fd2ac
node scripts/docs-audit/affected-docs.mjs --json 84880f92243889d3e1daf9d4926d75277c9b5a94 |
Contract reviewServed-tier: Scope: PR #20165 (card #20136, p1, security), 3 commits, 9 files (+258/−99): ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20136
Clause-②: no
The effective object-permission map (
buildEffectiveObjectPermissionsin@objectstack/core: theobjectsslot ofGET /auth/me/permissions, and whatISecurityService.getEffectiveObjectPermissionshands tocurrent_user.can()on the write path) no longer grants any cell thatPermissionEvaluator.checkObjectPermissionrefuses, for every super-user shape measured. This completes the family's closing assertion: 0 over-granted and 0 under-granted cells over every super-user subject in the parity table.What was wrong
Before its per-set super-user fold, the builder ran
foldWildcardSuperUserover the MERGED map. That pass put the merged bypass bits on every entry:resolveObjectPermissionanswers that set with its explicit entry (the walledorganization_admin's read-only RBAC rows and write-denied identity tables, an explicit{}entry, an export-only entry);allowCreateonmodifyAllRecordsalone, which the spec'sobjectPermissionGrantsgives no create cell.Mechanism hypotheses H3 are both CONFIRMED, by reading and by measurement: removing that pass alone takes every row below to 0, and adds 0 under-granted cells anywhere.
What changes
packages/core/src/security/effective-object-permissions.ts:buildEffectiveObjectPermissionsno longer callsfoldWildcardSuperUser. The super-user fold isfoldSuperUserWildcardGrantsalone (added by core: effective-map super-user entries never carrytransfer(or a super-read wildcard's plain bits) —current_user.can(obj, 'transfer')is false foradmin_full_accesswhere enforcement answers true viamodifyAllRecords#20134): per set, onto the entries that set does not name, exactly the bitsobjectPermissionGrantssays that set's wildcard grants. Another set's super-user wildcard still widens an entry bit by bit, ascheckObjectPermissioncombines sets. The seed, plain-wildcard coverage, clamp and annotation are untouched.foldWildcardSuperUserkeeps its name, signature and body. It is a released export of@objectstack/plugin-hono-server(re-exported from core). Its docblock now says it is not a step of the map, and names where it is broader (own narrower entry, create) and narrower (transfer, a super-user wildcard's own plain bits) than the server. Retiring it is an export removal, so it is left as an open question below rather than done here.wildcardGrantsSuperRead, the seed, plain coverage, the clamp, the builder) now name the per-set fold.Tests:
plugin-securityget-effective-object-permissions.test.ts:KNOWN_OVER_GRANT, its flip-trigger assertion and its "names a row" test are DELETED rather than emptied. The table now holds every subject EXACT in both directions, with the managed-write clamp's narrowing as its only allowance. Each enumeration shape is a named subject, tagged with its row ([#20136 row a]to[#20136 row h], plus the existing one-set row), so a regression names its row. Rows b, d, e, f, g and h are new. TheapiOperationscolumn runs over them too, which is where row e's offered-but-refusedexportis held. One spelled-out case: the walled org admin may not create, edit or deletesys_position, andcan()says so, with and withoutmember_default;sys_useredit without it.coreeffective-object-permissions.test.ts: a[#20136]block (own read-only entry,{}entry, export-only entry under a super-read wildcard,modifyAllRecordswithout create, another set's wildcard still widening). Two composition fixtures that relied on the over-grant are re-spelled: the clamp and fold cases now put the narrower entry in a SECOND set, so they still exercise the fold.plugin-hono-server: the endpoint fixture'ssys_memberdeny moves to the second set, so its "clamp over the fold" assertion keeps exercising the fold. There is a new endpoint case, a super-user set's own narrower entry served as that set's answer, byte-equal to the builder.fold-wildcard-superuser.test.tsgets a note that it pins the standalone released helper.Changesets:
.changeset/20136-super-user-fold-per-set.md(@objectstack/corepatch), plus three one-clause DELIBERATE CORRECTIONS of pending notes, listed below.Measurement
The map answer is the real formula
current_user.can()overtoEvalPermissions(map). It is compared withPermissionEvaluator.checkObjectPermissionon all 10 verbs.overmeans the map grants a cell the evaluator refuses, andunderthe reverse. As in the existing pin, create, edit and delete refused on a guarded managed object is the managed-write clamp and is not counted. The fixture has 61 registered objects: every object schema exported by@objectstack/platform-objectsand by plugin-security'sobjects, plus 4 app objects (public,apiMethods-restricted, private, andapiEnabled: false).49144fccc8, with core'sdist/built from it.6527062a22.dist/) is byte-identical (sha256) to true base on all 27 subjects.Enumeration (over-granted cells, base to head)
organization_admin+member_defaultorganization_adminalone'*': { modifyAllRecords: true }alone{}explicit entry inside a super-user set'*': { viewAllRecords }over its own{ allowExport: true }entryexportoffered and refused)'*': { viewAllRecords }over its own narrower entrymodifyAllRecords-only'*'beside a set naming the object narroweradmin_full_access+organization_admin+member_default'*'and a narrowercrm_accountcreate+importon every object the clamp does not narrow, 16 on this fixture and 18 on the card's 63-object fixture.modifyAllRecordsand no export.Parity (all 27 subjects)
The 18 subjects of the #20083/#20134/#20135 table, rows a to h, a super-read wildcard alone,
admin_full_accessalone, and a super-read wildcard beside a plain one:checkObjectPermissiongrants became refused in the map.apiOperationscolumn (offered and refused, and served but hidden): row e was 1 offered-and-refused at base (crm_accountexport). It is 0 at head, and every other cell is 0 at both.Reach, at a public door (H2)
The write path is PR #20079's
ObjectQLwrite path: the real engine, with the realSecurityPluginresolver (the function it registers on the engine) as its resolver.crm_case.stagehas options gated oncurrent_user.can(…).checkObjectPermissionmember_defaultcan('sys_position','edit')VALIDATION_FAILED/invalid_optionmember_defaultcan('sys_position','create')can('sys_position','edit'),can('sys_position','create')can('sys_user','edit')'*': { modifyAllRecords }can('sys_position','create'),can('crm_account','create')admin_full_access+member_defaultandmember_default, and every cell the server grantsGET /api/v1/auth/me/permissionswas served by the realregisterCurrentUserEndpointson a Hono app over the same resolved sets and registry.objects.sys_positioncreate / edit / deleteobjectssha256member_default7d78b7069a14→7c41ba445efa6cf54b268f58→65a3ee709036'*': { modifyAllRecords }87f9b1e48f79→e5a31ecba130admin_full_access+member_defaultmember_defaultCollateral (H4)
modifyAllRecords. That includesadmin_full_accessalone and besidemember_default,organization_admin_no_bypass, and row h.falsetotrue. Onlytruetofalse:true→falseallowEdit×6,allowCreate×5,allowDelete×5allowEdit×8,allowCreate×5,allowDelete×5allowCreate×16allowRead,allowCreate,allowEdit,allowDelete×1 eachallowRead×1allowRead×1allowCreate×1allowCreate,allowEdit,allowDelete×1 eachapiOperationschange: row e'scrm_accountgoes from no annotation (default-allow, which offeredexport) to the closure withoutexport.permission-evaluator.tsand all of plugin-security's non-test source have 0 changed lines, so nocheckObjectPermissionanswer changes.Reverse verification
The fix was committed first. The mutate leg restored the producer file from base (
git restore --source=49144fccc8), rebuilt core, and proved the marker live withablation-dist-preflight(present in 2 built files). On that tree:[#20136]cases and the 2 re-spelled composition cases;apiOperationscolumn;Row h and every other subject stayed green. The direction is the expected one (to red). The restore used
git checkout HEAD --, proven by the file's hash equalling its HEAD blob and by an emptygit diff HEAD. Core was then rebuilt, with the marker proven absent fromdist/.Tests and gates, at
6527062a22pnpm --filter @objectstack/core test: 53 files, 1358 passed.test:repo: 3 files, 48 passed.pnpm --filter @objectstack/plugin-security test: 135 files, 2730 passed.pnpm --filter @objectstack/plugin-hono-server test: 27 files, 324 passed.typecheckfor core, plugin-security and plugin-hono-server (tsc, the scripts/typecheck projects,check:test-typecheckover the test layer): exit 0.access-security.jsonchecklist dogfood files plusorganization-update-door.dogfood.test.ts, 12 files, 165 passed.node scripts/pm/dispatch-gates.mjs --commandsderived 64 commands from this diff. All 64 were run, and--ranreconciles 64 of 64 with 0 NOT-MEASURED.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (8 unrelated packages had nodist/). After building them it exited 0.check-empty-changeset --base origin/mainexits 1, by design: it is the DELIBERATE CORRECTION class below, and the gate stays red until a person confirms.node scripts/check-issue-citations.mjs --base 49144fccc8: exit 0, 8 citations resolve.eslint --no-inline-configon the 5 changed TS files: 0 errors, 0 warnings, 5 files counted from--format json. All 5 are in the config's population (--print-configresolves each). The config enables no type-aware linting (noparserOptions.project), so this diff cannot move a verdict on an untouched file.Deliberate corrections of pending release notes, for confirmation
check-empty-changesetis red on these three by design. Each is one clause, and each sentence was made false by this PR:.changeset/18783-server-can-option-visibility.md: "The map still differs fromcheckObjectPermission… an entry the super-user set itself names narrower can read as granted." now reads: "The map also differed … read as granted, which is closed in this same release (.changeset/20136-super-user-fold-per-set.md).".changeset/20134-super-user-entries-every-bit.md: "Both over-grants are left exactly as they were." now reads: "…left exactly as they were by this change, and both are closed in this same release (.changeset/20136-…).".changeset/18931-me-permissions-unrestricted-export-annotation.md: "Its CRUD bits are foldedtrue" now reads: "Its CRUD bits are folded to what its wildcard grants (all four for the built-in admin sets)". The note's population includes a bare'*': { modifyAllRecords }, which no longer readscreate.No released note was touched.
Cross-lane
For super-user subjects, two things change: the bytes
/auth/me/permissionsserves, and the write path'scurrent_user.can()answer. Both change only on the cells listed above, and both move toward what the server enforces. For the seat to relay todomain:clianddomain:servicesat landing. The REST door,checkObjectPermissionandannotateEffectiveApiOperationsare untouched.Open question
foldWildcardSuperUseris still exported, but nothing in this repository now calls it outside its own pins. Over a merged map it cannot be made to match the server, because the map has lost which set named which object. Retiring it removes a released export of@objectstack/plugin-hono-server, which is a breaking change (Clause-②: yes (narrowing)). That is a decision for the seat or the maintainer, not a rider on this fix. Recommendation: retire it in its own change, because an exported helper documented as the map's fold is how the merged reading could come back.Acceptance notes
can()gate is PR feat(objectql,plugin-security,core): the server answers current_user.can() in an option's visibleWhen #20079's, whose changeset is still pending. On/auth/me/permissionsonly map bits narrow, and the server's answer to every request is unchanged.KNOWN_OVER_GRANTinto an empty set. This PR deletes it instead: an empty allowance ledger is a mechanism for re-admitting a divergence, and the family's assertion is exact.Generated by Claude Code