Skip to content

Commit 56076fd

Browse files
docs(spec): the field-permission when description and the authorization guide name the View All / Modify All field vote (#29599)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ875yZfH2NNQuxrKN8gzJqox)_ Fixes #29543 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:只改文字。`FieldPermission.when` 的 `.describe`、`FieldPermissionSchema` 的 TSDoc 注释、`authorization.mdx` 的「FLS posture (ADR-0066 ⑧)」段,都补上超级用户投票:持「查看全部」的权限集对该对象每个字段投可读,持「修改全部」的投可读+可编辑,「查看全部」从不给编辑,其余照旧是无 deny 的并集。参考页按生成器重生成。 - 为什么改:PR #29387 已让运行时这样合并字段权限,但这三处仍写「只有声明了的集合投票」,读者会以为应用集的 `readable: false` 还能挡住管理员。 - 风险与回滚:无 schema 形状变化,无接受集变化。回滚 = revert 本 PR。 - 先看:`packages/spec/src/security/permission.zod.ts`(`when` 的 describe 与 L928 起的 docblock)→ `content/docs/permissions/authorization.mdx` 的 FLS posture 段 → 重生成的 `content/docs/references/security/permission.mdx`。 - 两分钟验证:`git diff origin/main -- content/docs/references`,预期只有 3 行 `when` 表格行变化,内容与 describe 一致。 - 需要你做的:无,席位落地。 ## What changes - **`FieldPermission.when` `.describe`** (`packages/spec/src/security/permission.zod.ts`). The sentence "the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged" now reads that the merge is unchanged and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The rest of the description is untouched. - **`FieldPermissionSchema` TSDoc docblock** (same file, from L928). It carried the same stale "declaring sets vote" sentence; it now names the super-user vote and cites the ADR-0066 ⑧ note of 2026-11-02 and ruling of record 6573563853. This hunk is outside the original claim surface; the seat added it by claim amendment 6586583239 (os-dev rule 3 bounded fix: same defect class, mechanical prose, same file, same gate family). - **`content/docs/permissions/authorization.mdx`**, the "FLS posture (ADR-0066 ⑧)" callout. It now names the View All / Modify All vote, says that no set's `readable: false` hides a field from a caller who holds a View All or Modify All set on its object, that a field's `requiredPermissions` capability gate still applies to every caller, and that a set which must withhold a field from its own holders grants the object without those two bits. - **Regenerated** (`pnpm --filter @objectstack/spec check:generated --fix`, which proved only `check:docs` stale): `content/docs/references/security/permission.mdx` (2 `when` rows) and `content/docs/references/api/protocol.mdx` (1 `when` row, line 1670). The protocol.mdx row is a generated artifact; ⛔ no protocol source (`packages/spec/src/api/protocol.zod.ts`, spec `identity/protocol.ts`, metadata-protocol `protocol.ts`) is edited, per the freeze that holds until 2026-11-03 06:00Z. - **Changeset** `.changeset/29543-field-permission-superuser-vote.md`: `patch` for `@objectstack/spec`. The new text ships in `dist/` (`grep -rl "never binds a Modify All holder" packages/spec/dist` hits two chunks; the old "only declaring sets vote" text hits none) and in the published `src/**/*.zod.ts`. ## Why PR #29387 (#29106, ruling of record 6573563853, letter A) shipped the super-user field vote, and the ADR-0066 D2 ⑧ note of 2026-11-02 (PR #29459) records it. Its contract review 6579089871 named both texts as should-fix follow-ups. An author, AI author or reviewer who reads the old text believes an app set's `readable: false` still hides a field from an admin. **Measured against the merged code** (origin/main ff827bc): - `holdsSuperuserBypass` (`packages/core/src/security/effective-object-permissions.ts`): reads `objects[objectName] || objects['*']`; `view` = `viewAllRecords || modifyAllRecords`, `modify` = `modifyAllRecords` only. Hence "its own entry for the object, otherwise its `'*'` wildcard" and "View All never grants an edit". - `superuserFieldVote`: `{ readable: true, editable: true }` for a Modify All holder, `{ readable: true, editable: false }` for View All, `null` otherwise. - `PermissionEvaluator.getFieldPermissions` (`packages/plugins/plugin-security/src/permission-evaluator.ts`) and `buildEffectiveFieldPermissions` (core) apply the vote AFTER the declared-entry votes, whatever each entry's `when` verdict was (`matched`, `unjudged` or `unmatched`). So the vote is row-blind: a `when` lock (`editable: false` on matched rows, or the lock a row-blind reader reports) is overridden to `editable: true` for a Modify All holder. An `unmatched` entry is absent, and an absent field is unrestricted anyway. Hence "on every row, whatever `when` says" and "a row-conditioned lock never binds a Modify All holder". The write gate (`computeForbiddenFieldWrites`) and the record-scoped field projection read `getFieldPermissions` with a judge, so the description's later sentences on those readers stay true. - `requiredPermissions`: `foldFieldRequiredPermissions` runs after the merge in every caller (review 6579089871 ④), hence "still applies to every caller". ## Tests No test pinned the old wording (`git grep` of "only declaring", "declaring sets vote" and "co-held set" outside `content/docs/references` finds only the edited source lines). No behaviour changes, so no new pin and no ablation. - `pnpm --filter @objectstack/spec test`, under the verify lock: green on de2dfac and again on the merge head a4a9545 (counts under Verification). - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: before the fix `✗ check:docs` only (1 of 14 stale); after `check:generated --fix`, `git status` shows exactly the two reference pages. ## Acceptance notes - Observation, not filed: `os explain`'s FLS layer (`explain-engine.ts`, the `conditionedSentence`) lists every row-conditioned entry as "locked when …", and says it is "applied as the lock everywhere else". For a caller who holds a Modify All set on the object that lock is overridden by the super-user vote, so the sentence overstates the lock for that caller. This is report prose, not a gate; it is out of this card's surface and reach is not measured. Left to the seat to judge. ## Verification All readings below are on head `a4a95452` (main merged at e832ddb after the edit commit de2dfac), worktree `objectstack-issue-29543`. - `pnpm --filter @objectstack/spec check:generated`: `✓ All 14 generated artifacts are up to date` after a rebuild of the lint / objectql / formula / client-react / spec closures (`Tasks: 38 successful, 38 total`). - `pnpm --filter @objectstack/spec test`: `Test Files 800 passed (800)`, `Tests 23664 passed | 1 todo (23665)`, lock `VERDICT command-exit 0`. (On de2dfac before the merge: 799/799 files, 23662 tests.) - `node scripts/pm/dispatch-gates.mjs --commands` derived 97 gate families at a4a9545; each was run and its exit code captured before any pipe. `--ran` reconciliation: `97 derived, 96 run, 1 NOT-MEASURED, 0 UNRUN`, exit 0. - 96 exit 0. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3 `PREREQUISITE NOT MET` (it needs every package's `dist/`; 34 packages unbuilt in this worktree). Left to CI. A describe string cannot change what a CJS entry loads. - References diff vs main: exactly three `when` table rows (`references/security/permission.mdx` ×2, `references/api/protocol.mdx` ×1), each equal to the new describe. - Freeze check: `git diff origin/main...HEAD --name-only` lists no `protocol.zod.ts`, `identity/protocol.ts` or metadata-protocol `protocol.ts`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Describe text only.) - [x] 2. Every name I added uses the casing for its kind. (No name added.) - [x] 3. No formatter ran; `pnpm lint` passes. (Lint runs in CI.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (The branch is the card thread's platform branch, per the claim.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbXETMiU6EPkjZvr57y --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXETMiU6EPkjZvr57y)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81cd9eb commit 56076fd

5 files changed

Lines changed: 38 additions & 11 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the `FieldPermission.when` description names the View All / Modify All field vote
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Description text only: no key, accepted value or default of any schema changes. The published `when` description (and the `FieldPermissionSchema` docblock) said that only the sets declaring a field vote on it. Since `@objectstack/plugin-security` and `@objectstack/core` shipped the super-user field vote, a set holding the super-user bypass on an object (its own entry for the object, otherwise its `'*'` wildcard) votes on every field of that object, on every row: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The description now says so, and the generated JSON Schema and reference pages carry it. Nothing to migrate.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,17 @@ implementation detail:
153153
<Callout type="warn">
154154
**FLS posture (ADR-0066 ⑧):** runtime field security is block-list-shaped —
155155
an **undeclared** field is visible by default, and field grants union (one
156-
set's `readable: true` out-votes another's `false`). Until the muting layer
157-
lands, protect sensitive fields by granting them only in the sets that need
158-
them, and treat "sensitive field on a public object" as a review smell.
156+
set's `readable: true` out-votes another's `false`). A set that holds View All
157+
(`viewAllRecords`) on an object also votes `readable: true` on every field of
158+
that object, and a set that holds Modify All (`modifyAllRecords`) votes
159+
`readable: true` and `editable: true`; View All never grants an edit. So no
160+
set's `readable: false` hides a field from a caller who holds a View All or
161+
Modify All set on its object; a field's `requiredPermissions` capability gate
162+
still applies to every caller. Otherwise union without deny is unchanged.
163+
Until the muting layer lands, protect sensitive fields by granting them only
164+
in the sets that need them, grant the object without those two bits to a set
165+
that must withhold a field from its own holders, and treat "sensitive field on
166+
a public object" as a review smell.
159167
</Callout>
160168

161169
## Package provenance & composition (ADR-0086)

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1667,7 +1667,7 @@ One changed key path of the pending draft, and the last write that changed it.
16671667
| :--- | :--- | :--- | :--- |
16681668
| **readable** | `boolean` | optional (default: `true`) | Field read access |
16691669
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
1670-
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
1670+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
16711671

16721672

16731673
---

‎content/docs/references/security/permission.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const result = AdminScopeSchema.parse(data);
6969
| :--- | :--- | :--- | :--- |
7070
| **readable** | `boolean` | optional (default: `true`) | Field read access |
7171
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
72-
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
72+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
7373

7474

7575
---
@@ -159,7 +159,7 @@ const result = AdminScopeSchema.parse(data);
159159
| :--- | :--- | :--- | :--- |
160160
| **readable** | `boolean` | optional (default: `true`) | Field read access |
161161
| **editable** | `boolean` | optional (default: `false`) | Field edit access |
162-
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets and the rule that only declaring sets vote are unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
162+
| **when** | `string` | optional | Row condition narrowing where this entry votes, authored in canonical CEL like `rowLevelSecurity[].using` (a field of this object compared with a literal or a `current_user.*` value using `==`, `!=`, `<`, `<=`, `>`, `>=` or `in`, combined with `&&` / `\|\|`; e.g. status in ["approved", "signing", "active"]). Absent, the entry votes on every row. Present, the entry votes as declared on the rows the condition matches and is ABSENT on every other row, so the most-permissive merge across the caller's sets is unchanged: a set that locks a field only from a stage onward does not lock the rows before it, and a co-held set that says nothing about the field still lifts nothing, unless it holds the super-user bypass on the object (its own entry for the object, otherwise its `'*'` wildcard). Such a set votes on every field of the object, on every row, whatever `when` says: `viewAllRecords` votes `readable: true`, and `modifyAllRecords` votes `readable: true` and `editable: true`, so a row-conditioned lock never binds a Modify All holder. View All never grants an edit. Otherwise union without deny is unchanged. The write gate judges the condition on the stored row BEFORE an update (so a lock on `status in [...]` holds on an approved row even when the write changes `status`) and on the incoming row of an insert, as the caller sent it. It reaches the EDIT vote only — reads are judged row-blind — so `readable: false` beside a `when` is refused at parse time: keep `readable: true` (the default) on a row-conditioned entry. Where no row can be judged (a predicate update with no row address, a stored row the caller cannot read, a condition that does not resolve for the request) the entry is judged as its lock: it contributes no edit grant on that write. Validated like an RLS predicate at authoring and publish time: the fields it names must exist on the entry's object and it may not reach across a relation; a condition that cannot be lowered is refused there, never dropped at runtime. Readers that hold no row (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) report a row-conditioned entry as its lock, so a client never advertises an edit the gate refuses. The record-scoped field projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) holds the row: it judges the condition on the stored row exactly as the write gate does on an update of that row, so a lock that applies only from a stage onward disables the field only on the rows it names. |
163163

164164
### Nested Shape: `PermissionSet.rowLevelSecurity[number]`
165165

0 commit comments

Comments
 (0)