Repository navigation
refactor(security): one permission fold — the enforcement door and the access matrix ASK the spec helper (#18785) - #19512
Conversation
Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
…spec helper Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 18 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 b3e9fe35cc3845545f2d2a524a5516a2d4f5326e && git checkout b3e9fe35cc3845545f2d2a524a5516a2d4f5326e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 a5e7c92e8d13d3ca59e8f1031e18ee87d927fd6f && git checkout -B drift-repro 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31 && git merge --no-ff a5e7c92e8d13d3ca59e8f1031e18ee87d927fd6f
node scripts/docs-audit/affected-docs.mjs --json 9dd93190b665549a3b5c5e029c0d6e0bc7b89e31
|
Fixes #18785
Clause-②: no
One rule — "does this effective object permission grant this verb?" — had three implementations. It now has one definition in
@objectstack/spec(objectPermissionGrants) and two consumers that ask it: the enforcement doorPermissionEvaluator.checkObjectPermission, and the access-matrix snapshotbuildAccessMatrix.⛔ The differential ran FIRST — nothing was converged before it
The card's own "Not measured" section named this as the cheap thing to do first, and it forked the rest of the work. It was run against the three implementations as sources, before any edit.
Input enumeration, derived rather than hand-listed. The bit set is read out of the spec's own
EffectiveObjectPermissionSchema.shape— every key whose unwrapped inner type isboolean— which yields exactly the eight declared bits:allowCreate,allowDelete,allowEdit,allowExport,allowRead,allowTransfer,modifyAllRecords,viewAllRecords. The verb targets come from the spec's ownOBJECT_PERMISSION_VERBSvalue range (six). Each bit takes three states —true,false, absent — because every implementation compares with=== trueand absence is the state an author reaches by omission. That is 3^8 = 6561 inputs, the full cartesian product over the declared bits, not a sample.⇒ All three agreed on every input. This is therefore a pure structural convergence and no behaviour changes — which is also why no ruling was needed. Had any cell disagreed, the card said stop, and it would have stopped.
The control — the instrument can tell the two cases apart. "They all returned the same thing" proves nothing unless the harness can see a disagreement, so the same run was repeated twice with exactly ONE cell of the spec fold perturbed, once in each direction:
create-bypassread-narrowmodifyAllRecords(spec says false where the consumers say true)Both directions, both consumers, non-zero. Plus a second exhaustiveness leg the enumeration cannot express:
objectPermissionGrants(undefined, verb)isfalsefor all six targets, never a throw.The evaluator's operation map is checked, not assumed.
checkObjectPermissionis keyed on ObjectQL operations, not onallow*bits, and itsOPERATION_TO_PERMISSIONis module-private. The harness's inverse map is asserted against the exportedcrudBucketForOperationbefore a single cell is compared, so a drifted mapping aborts the run instead of quietly measuring the wrong pairs.The fold as the card records it, verified against each implementation rather than trusted: read bypasses on
viewAllRecords || modifyAllRecords; write bypasses onmodifyAllRecordsalone and never create; export isgrant ∧ read. All three held, on all three sides.What changed
packages/plugins/plugin-security/src/permission-evaluator.ts— the per-set loop's three inline checks (the modify-all write bypass, the read bypass, the bare bit) collapse to oneobjectPermissionGrants(objPerm, permKey)call.OPERATION_TO_PERMISSIONis retypedRecord(string, ObjectPermissionVerbTarget), which is what makes that call type-safe and forces a future operation's bit to be a verb the spec actually models.packages/lint/src/build-access-matrix.ts— the four CRUD columns becomeobjectPermissionGrants(entry, verb). The two super-user columns stay raw bits on purpose: they report what the set declares, which is the context a reviewer reads the CRUD columns against, and folding them would destroy that.Two things deliberately NOT collapsed, both documented at the site:
checkObjectPermission('export', …)asks(∃ set granting export) ∧ (∃ set granting read)across the whole resolved set list — the same answer the/me/permissionsmost-permissive per-object merge hands the client, and the same value the spec cell returns once that merge has happened. Folding it per set would have narrowed the door to∃ set (export ∧ read), denying a caller whose read and export grants arrive from two different sets. A narrowing at the enforcement door is exactly what this card forbids. A pin was added for it.MODIFY_ALL_WRITE_KEYSis kept and exported, as an assertion rather than a decision. It no longer decides anything at runtime; it still states, derived from this file's own dispatch vocabulary, which bits the write-bypass class contains, and the new test holds the spec's fold to it. Deleting it would have thrown away the [security][立项位] M2 权限生命周期(undelete/purge)功能与 allowRestore/allowPurge RBAC 同批建设(evaluator 已 fail-closed,allowTransfer 已 enforced) #1883 derivation (a future destructive op added to the map+set must make the spec go red, not silently lose its bypass) and dangled the prose in four other files that name it.packages/lint/src/index.tswas not touched —buildAccessMatrixwas already exported, and the change is an import, not an export edit.Following #17469, and where this departs
#17469's landing is the model:
driver-sql's rawfield.multiplereads became one call onto the spec'sisMultiValueField, behind a small documented adapter (isMultiValuedColumn) whose docblock names the ruling and says in one line that there is ONE definition.Followed — one definition in the spec, consumers ask it, and each call site carries a docblock that says what restating it would cost rather than just what it does.
Departed, three ways, each on purpose:
isJsonFieldand specisMultiValueFielddisagree formaster_detail/tree/text+multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 needed one because its callers had already applied their owntyperesolution and two spellings of that default is how its drift started. These consumers hold the effective entry itself, so the call is direct and an adapter would be a third spelling with nothing to reconcile.isJsonFieldand specisMultiValueFielddisagree formaster_detail/tree/text+multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 converged a live divergence, so a maintainer had to say which predicate was authoritative. Here the differential above proved agreement before anything moved, so there was nothing to rule on — and per the card, if it had disagreed this PR would not exist.isJsonFieldand specisMultiValueFielddisagree formaster_detail/tree/text+multiple: true, so a consumer using the spec predicate to shape a query gets a 400 #17469 also tightenedFieldSchemato refuse the flag it had been accepting. Nothing here needed a spec edit, and the dispatch is explicit that one would be a different lane.Ablation — each consumer proved to be ASKING, not agreeing by coincidence
The pins were written independently of the spec helper, from the rule itself. Asserting
consumer === objectPermissionGrants(...)would have been a tautology: both sides move together and the pin survives any change to the fold, including a wrong one. Because they restate the rule instead, one changed cell in the spec reddens them.One
scripts/ablation-replace.mjsprocess: mutate → rebuild spec → prove the mutation reacheddist/→ run each consumer → restore, with the restore proven on disk.The ablation cell was
allowEdit's write bypass — one cell, and both consumers went red through it. Named failures:named cells: the fold as the rule states it,exhaustively matches the fold over every declared bit combinationthe Modify-All write-bypass CLASS is exactly what this file derives from its own dispatch mapdist/keeps mutated code live for every later run in the worktree, and--absentis what proves it is gone.The enforcement door is security-relevant — every covering test file, before and after
checkObjectPermissionis exercised by exactly five test files, all in@objectstack/plugin-security. Three other files name it in prose only and call nothing —packages/spec/src/security/permission.test.ts,packages/formula/src/permission-predicate.test.ts,packages/runtime/src/domains/share-links-enforcement-context.test.ts— so they are listed for completeness, not counted.admin-export-wildcard.test.ts,export-permission-axis.test.ts,member-default-explicit-allow.test.ts,security-plugin.test.tsplugin-security/src/permission-evaluator.test.ts— NEW, the file the door never hadlint/src/build-access-matrix.test.ts(the matrix consumer)"Before" is a measurement, not an inference: the two source files were reverted to the merge base and the new test file removed, the four door files were run against that tree, and the revert was undone with
git checkout HEAD --and proved byte-exact (git status --porcelainempty,git diff HEADempty, and each of the four paths'git hash-objectequal to its blob atHEAD). Nothing was removed or weakened — every pre-existing case is still there and still green.Verification
Measured at
a5e7c92e8, base4045781fa.pnpm --filter @objectstack/lint run testpnpm --filter @objectstack/plugin-security run testpnpm --filter @objectstack/lint run typecheckpnpm --filter @objectstack/plugin-security run typecheckpnpm lint(repo-wide eslint, the union — not a narrowing)Gates. Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, every exit code captured before any pipe, reconciled with--ran:Three of them first answered exit 3 — PREREQUISITE NOT MET, which is neither a pass nor a fail. All three named the same prerequisite — no built output on disk — and it was discharged, not reported around:
turbo run buildover all packagespnpm check:dual-build-cjs-loadsdist/pnpm check:i18npnpm check:type-check-debtThe derivation was re-run after a fresh
git fetch origin main(which movedorigin/main4 commits): the family list came back byte-identical, so nothing new was owed.Changeset — measured, not assumed
skip-changesetdoes not apply. Both packages publish, and the changed bytes reachdist:privatefiles[]dist?@objectstack/lintdist,README.md,CHANGELOG.mdsrc/build-access-matrix.tsis built intodist/index.js/.cjs@objectstack/plugin-securitydist,README.md,CHANGELOG.mdsrc/permission-evaluator.tslikewise⇒
.changeset/18785-one-permission-fold.md,patchfor both. Not breaking: no export is removed or renamed, no authorable key changes meaning, and the differential is the evidence that no answer moves.@objectstack/spec/security's./securitysubpath carries both animportand arequirecondition, so the new value import is safe on the CJS side of both dual builds —check:dual-build-cjs-loadsconfirms it against real emitted bytes.Acceptance notes
⛔ No cards filed from here — the seat files everything.
① A fourth site reads the same bits, and its own docblock claims to mirror this door — measured, they differ on one bit. The card's second "Not measured" bullet asked whether any consumer outside these three folds the same bits a fourth time. It does.
packages/lint/src/validate-security-posture.tshasgrantsObjectAccess, whose docblock says verbatim:Its disjunction omits
allowTransfer. Reproduced today, on this branch:allowTransferis authorable and enforced today through the insert/updateowner_iddoor, so the shape is reachable.Seam: spec:allowTransfer → runtime:packages/lint/src/validate-security-posture.ts#grantsObjectAccess. Severity is advisory only: the rule emitswarningand does not gate the build. ⛔ Not touched here — outside this card's file surface, and that exact file is held by open PR #19486. Carrier: PR #19486, which already editsvalidate-security-posture.ts. Dedupe words:grantsObjectAccess·allowTransfer·security-master-detail-ungranted·validate-security-posture·object-level CRUD.② The wildcard-super-user question is stated twice, and both agree — noted, not filed.
resolveObjectPermission(plugin-security) andwildcardGrantsSuperRead(packages/plugins/plugin-hono-server/src/current-user-endpoints.ts) both decide "does the'*'entry carry the super-user read bypass". That is a different question from this card's verb fold, the hono-server copy already documents itself as the single reading for its own file and cites the evaluator, and they agree today. Recorded as the boundary of what this card converged, ⛔ not widened into it. Carrier: none (承接者:无).③ Three sites in
permission-evaluator.tsstill readviewAllRecords || modifyAllRecordsinline, and correctly so.getEffectiveScopeandgetDeclaredScopeanswer the depth axis, andsuperuserBypassSetsanswers "is the bypass bit held" — already the one function every bypass consumer folds through, per #4647. None of them answers "does this entry grant this verb", so none was converged. Stated so the next reader does not mistake the omission for an oversight. Carrier: none (承接者:无).④ A stale code quotation in a file outside this surface — noted, not filed.
packages/spec/src/security/permission.test.tsline 1172 quotes the evaluator's now-removed inline expressionpermKey === 'allowRead' && …in a comment. Prose drift only; the assertions around it are unaffected and still green. ⛔ Not a defect class. Carrier: none (承接者:无).⑤ On the recorded affinity with #18783 — the dispatch asked to say so if I think they are one card. I do not. Triage's reading holds up from inside the work: this card needed a measurement and could be finished the moment the differential came back zero; #18783 is waiting on a decision. Merging them would have parked a finished measurement behind an open question. #18783 remains open and untouched. ⛔ Merging cards is triage's call in any case.
Generated by Claude Code