Repository navigation
fix(plugin-security): explain reports a Modify All-superseded row-conditioned field lock as binding on no row - #29637
Conversation
…ditioned field lock as binding on no row The FLS layer of the access explanation listed every `when`-conditioned field entry as a lock applied everywhere a row is not judged. A caller whose sets hold Modify All on the object gets the super-user field vote after the per-entry votes under every verdict, so that lock binds on no row for them. conditionedFieldEntries now names the superseding sets from the same superuserFieldVote getFieldPermissions applies, and the layer words those entries as superseded. View All and the control keep the existing sentence. Claude-Session: https://claude.ai/code/session_01ADHkmSGbDCYYvHbU6am1V2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 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 — 17 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 7971e1cd249c0b76b62e85886cf2b9d072a4f3d3 && git checkout 7971e1cd249c0b76b62e85886cf2b9d072a4f3d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 322cf9068f143f39cbfbc7527294db24f01cd649 8ec800e416727581b828060a616db4ef9d45feca && git checkout -B drift-repro 322cf9068f143f39cbfbc7527294db24f01cd649 && git merge --no-ff 8ec800e416727581b828060a616db4ef9d45feca
node scripts/docs-audit/affected-docs.mjs --json 322cf9068f143f39cbfbc7527294db24f01cd649 |
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Contract reviewServed-tier: Face: none of the four (no ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentNot owed on this face (no schema, governed or narrowing path). The one design question the card raises, whether the explain engine should derive the override from the vote or restate the rule, is answered in ①.2: it derives, from the one function both merges apply, with the attribution from the one predicate that function reads. The member is placed where the report's other overrides are attributed (on the evaluator's output, read by the engine), which is the shape VERDICT: PASS Must-fix: none.
Generated by Claude Code |
…dening as Clause-② yes (widening) Claude-Session: https://claude.ai/code/session_01ADHkmSGbDCYYvHbU6am1V2 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Hand-off · seat 2 signs offSeat 2 is signing off on the maintainer's order. This PR moves to the next
Generated by Claude Code |
|
domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf 维护者速读
席位核对ACCEPT ·
Generated by Claude Code |
Requested by Steve · project thread
Fixes #29610
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
os explain的字段级说明层,对持有 Modify All Data 的调用者,不再把带when行条件的字段锁说成"按行判、其余处一律锁",改为单列一句"已被 Modify All 字段票覆盖,锁在任何行都不生效",并点名持票的权限集。when判定下都把每个字段投为可编辑;说明文字却还在讲锁,管理员排查字段权限时会被误导。packages/plugins/plugin-security/src/permission-evaluator.ts(conditionedFieldEntries读同一个superuserFieldVote)、explain-engine.ts的conditionedSentence、explain-engine.test.ts三条[#29610]用例。pnpm --filter @objectstack/plugin-security exec vitest run src/explain-engine.test.ts -t 29610,预期 3 条全绿。What changes
The FLS layer of the access explanation listed every field permission that carries a
whenas "judged per row by the write gate, applied as the lock everywhere else". For a caller whose sets hold Modify All Data on the object that was untrue: since ruling of record 6573563853 letter A (PR #29387),getFieldPermissionsapplies the super-user field vote after the per-entry votes, under everywhenverdict, so such a lock binds on no row. Measured through the public explain route (see Verification): the write gate admits the field for that caller while the report called it locked.PermissionEvaluator.conditionedFieldEntriesnow names, per entry, the sets whose Modify All vote supersedes it (supersededBy). It readssuperuserFieldVotefrom@objectstack/core, the same functiongetFieldPermissionsapplies, and attributes the sets throughsuperuserBypassSets(the oneholdsSuperuserBypasspredicate). No extraction was needed: the vote already lives in core and both readers now call it. The explain engine only words the result.N row-conditioned field permission(s) are superseded by the Modify All Data field vote [SETS], which votes every field editable on every row, so the lock binds on no row: [...]. Entries the vote does not supersede keep the existing sentence, counted on their own.readable: falsebeside awhen), so the edit lock still binds per row. Measured: the write gate refuses the field on a matching row for a View All holder.Forced test edit:
security-plugin.test.ts[#25866] conditionedFieldEntries lists the row-conditioned entries of the object, as authoredpins the entry shape withtoEqual, so both expected entries gainsupersededBy: [].Tests
explain-engine.test.ts, next to the[#25866]row-condition test:[#29610] a Modify All set supersedes a row-conditioned lock— the entry is listed as superseded by the vote, names the set, and is not called "judged per row".[#29610] a View All set does NOT supersede it— read-side control: the row-conditioned sentence stays.[#29610] a caller without the vote keeps the row-conditioned sentence— control.Acceptance notes
ConditionedFieldEntrygains a required output membersupersededBy. It is reachable only as the return type of the exportedPermissionEvaluator.conditionedFieldEntries; nothing in the repo builds one besides the evaluator. The seat ruled this public return-shape enlargement a widening (contract review 6591028293, claim amended in 6591039615), so the PR body and the changeset declareClause-②: yes (widening);Metadata-protocol: noneand the patch level stand.whendescription and the authorization guide still say only declaring sets vote; a View All / Modify All set now votes on every field #29543 (whendescription wording).Verification
Head
8ec800e4(fixca1b6e6a, the round-2 changeset declaration commit, and merges oforigin/main; the latest merge broughtplugin-securityfiles from main, so the package runs and gates below were repeated on this head).Measurement through the public explain route, before and after (scratch dogfood boot, not committed)
A throwaway dogfood boot measured three callers that each hold a
when-conditioned field entry on one object: one without the vote (control), one with View All, one with Modify All. Each read the explain route's FLS layer, and the write gate's answer for that field on a row the condition names was read beside it.5bd615ef): all three were told the entry is "judged per row by the write gate, applied as the lock everywhere else", Modify All included.ca1b6e6a): control and View All unchanged; Modify All reads "2 row-conditioned field permission(s) are superseded by the Modify All Data field vote [the MA set], which votes every field editable on every row, so the lock binds on no row".Red first on main, ablation
Tests 1 failed | 2 passed— the Modify All case:expected 'No field-level masking applies. 1 fie…' not to contain 'judged per row by the write gate'.scripts/ablation-replace.mjsonpermission-evaluator.ts: the vote read was disabled with a string-literal guard. Anchor 1 to 0, blob f0269b4c to 57f704a0. Same failure,Tests 1 failed | 2 passed. Restored: blob equals HEAD f0269b4c,git diff HEADempty. The test imports the evaluator fromsrcby relative path, so no dist leg applies.Package runs and gates
pnpm --filter @objectstack/plugin-security typecheckexit 0;pnpm --filter @objectstack/plugin-security test: Test Files 241 passed (241), Tests 5205 passed, 45 skipped (5250). Lock VERDICT command-exit 0, onca1b6e6a,1fcfe8f5and8ec800e4(5206 passed on the last, one test more from main).--no-inline-configonpackages/plugins/plugin-securityexit 0 (the package has no lint script).dispatch-gates --commandson8ec800e4(and before it on1fcfe8f5): 62 derived, 62 run, all exit 0;--ranreconciliation: 62 run, 0 NOT-MEASURED, 0 UNRUN. On1fcfe8f5three gates first exited on a missing prerequisite (check-plugin-teardown-shape --self-testexit 3 needed the pinned fixture commit fetched;check:dts-closureexit 1 andcheck:dual-build-cjs-loadsexit 3 needed a declaration build of every package) and were re-run green after it.!in the title, no(narrowing)arm).