Repository navigation
Commit fc55892
fix(plugin-security): explain reports a Modify All-superseded row-conditioned field lock as binding on no row (#29637)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi96ws2DLQgZkin2u7GFC6oDF)_
Fixes #29610
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:`os explain` 的字段级说明层,对持有 Modify All Data 的调用者,不再把带 `when`
行条件的字段锁说成"按行判、其余处一律锁",改为单列一句"已被 Modify All 字段票覆盖,锁在任何行都不生效",并点名持票的权限集。
- 为什么改:裁决 6573563853 字母 A(#29387 已落地)后,Modify All 的字段票在逐条票之后、任何 `when`
判定下都把每个字段投为可编辑;说明文字却还在讲锁,管理员排查字段权限时会被误导。
- 风险与回滚:只改说明文字,执行面零改动;回滚即 revert 本 PR。View All 与无票调用者的句子与今天一致。
-
先看:`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 `when` as "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), `getFieldPermissions` applies the super-user field vote
after the per-entry votes, under every `when` verdict, 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.
- **Seam (no second copy of the rule).**
`PermissionEvaluator.conditionedFieldEntries` now names, per entry, the
sets whose Modify All vote supersedes it (`supersededBy`). It reads
`superuserFieldVote` from `@objectstack/core`, the same function
`getFieldPermissions` applies, and attributes the sets through
`superuserBypassSets` (the one `holdsSuperuserBypass` predicate). No
extraction was needed: the vote already lives in core and both readers
now call it. The explain engine only words the result.
- **Wording: annotate, not drop.** Superseded entries move into their
own sentence, in the "Superseded by the View/Modify All Data bypass
[...]" form the record-level sharing attribution already uses: `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.
- **View All supersedes nothing.** It votes only the read bit, which a
conditioned entry already casts (the spec refuses `readable: false`
beside a `when`), so the edit lock still binds per row. Measured: the
write gate refuses the field on a matching row for a View All holder.
- **D10.** Each principal's entries are judged against its own sets. The
agent and delegator masks intersect, so a delegator's lock still binds
when only the agent holds the vote.
- The hidden/partial mask is untouched. Enforcement is unchanged.
Forced test edit: `security-plugin.test.ts` `[#25866]
conditionedFieldEntries lists the row-conditioned entries of the object,
as authored` pins the entry shape with `toEqual`, so both expected
entries gain `supersededBy: []`.
## 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
- `ConditionedFieldEntry` gains a required output member `supersededBy`.
It is reachable only as the return type of the exported
`PermissionEvaluator.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 declare
`Clause-②: yes (widening)`; `Metadata-protocol: none` and the patch
level stand.
- Related and not in scope: #29544 (lint for the super-user bit beside a
mask), #29543 (`when` description wording).
## Verification
Head `8ec800e4` (fix `ca1b6e6a`, the round-2 changeset declaration
commit, and merges of `origin/main`; the latest merge brought
`plugin-security` files from main, so the package runs and gates below
were repeated on this head).
<details><summary>Measurement through the public explain route, before
and after (scratch dogfood boot, not committed)</summary>
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.
- Write gate: control refused, View All refused, Modify All admitted
(the ruled behaviour).
- Before (main `5bd615ef`): all three were told the entry is "judged per
row by the write gate, applied as the lock everywhere else", Modify All
included.
- After (`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".
</details>
<details><summary>Red first on main, ablation</summary>
- Red on main (tests added, fix absent): `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'`.
- Ablation via `scripts/ablation-replace.mjs` on
`permission-evaluator.ts`: the vote read was disabled with a
string-literal guard. Anchor 1 to 0, blob f0269b4 to 57f704a0. Same
failure, `Tests 1 failed | 2 passed`. Restored: blob equals HEAD
f0269b4, `git diff HEAD` empty. The test imports the evaluator from
`src` by relative path, so no dist leg applies.
</details>
<details><summary>Package runs and gates</summary>
- `pnpm --filter @objectstack/plugin-security typecheck` exit 0; `pnpm
--filter @objectstack/plugin-security test`: Test Files 241 passed
(241), Tests 5205 passed, 45 skipped (5250). Lock VERDICT command-exit
0, on `ca1b6e6a`, `1fcfe8f5` and `8ec800e4` (5206 passed on the last,
one test more from main).
- Lint: root eslint `--no-inline-config` on
`packages/plugins/plugin-security` exit 0 (the package has no lint
script).
- `dispatch-gates --commands` on `8ec800e4` (and before it on
`1fcfe8f5`): 62 derived, 62 run, all exit 0; `--ran` reconciliation: 62
run, 0 NOT-MEASURED, 0 UNRUN. On `1fcfe8f5` three gates first exited on
a missing prerequisite (`check-plugin-teardown-shape --self-test` exit 3
needed the pinned fixture commit fetched; `check:dts-closure` exit 1 and
`check:dual-build-cjs-loads` exit 3 needed a declaration build of every
package) and were re-run green after it.
- Dogfood battery: not owed (no `!` in the title, no `(narrowing)` arm).
</details>
<!-- signed: domain:server#2 · os-steve ·
session_01ADHkmSGbDCYYvHbU6am1V2 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c318490 commit fc55892
5 files changed
Lines changed: 122 additions & 10 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
308 | 308 | | |
309 | 309 | | |
310 | 310 | | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
311 | 374 | | |
312 | 375 | | |
313 | 376 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2959 | 2959 | | |
2960 | 2960 | | |
2961 | 2961 | | |
| 2962 | + | |
| 2963 | + | |
| 2964 | + | |
| 2965 | + | |
| 2966 | + | |
| 2967 | + | |
| 2968 | + | |
| 2969 | + | |
| 2970 | + | |
| 2971 | + | |
2962 | 2972 | | |
2963 | 2973 | | |
2964 | 2974 | | |
2965 | 2975 | | |
| 2976 | + | |
| 2977 | + | |
| 2978 | + | |
| 2979 | + | |
| 2980 | + | |
2966 | 2981 | | |
2967 | | - | |
2968 | | - | |
2969 | | - | |
2970 | | - | |
2971 | | - | |
2972 | | - | |
2973 | | - | |
| 2982 | + | |
| 2983 | + | |
| 2984 | + | |
| 2985 | + | |
| 2986 | + | |
| 2987 | + | |
| 2988 | + | |
| 2989 | + | |
| 2990 | + | |
2974 | 2991 | | |
2975 | 2992 | | |
2976 | 2993 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
38 | 50 | | |
39 | 51 | | |
40 | 52 | | |
| |||
525 | 537 | | |
526 | 538 | | |
527 | 539 | | |
528 | | - | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
529 | 543 | | |
530 | 544 | | |
531 | 545 | | |
532 | 546 | | |
533 | 547 | | |
534 | 548 | | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
535 | 554 | | |
536 | 555 | | |
537 | 556 | | |
| |||
543 | 562 | | |
544 | 563 | | |
545 | 564 | | |
| 565 | + | |
546 | 566 | | |
547 | 567 | | |
548 | 568 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2839 | 2839 | | |
2840 | 2840 | | |
2841 | 2841 | | |
2842 | | - | |
| 2842 | + | |
2843 | 2843 | | |
2844 | 2844 | | |
2845 | | - | |
| 2845 | + | |
2846 | 2846 | | |
2847 | 2847 | | |
2848 | 2848 | | |
| |||
0 commit comments