Skip to content

Commit 0fd363d

Browse files
fix(spec)!: an object permission that edits or deletes a record also reads it (#30180)
Clause-②: no (narrowing) — the authoring object-permission schema refuses an entry granting `allowEdit: true` or `allowDelete: true` without read (`allowRead`, `viewAllRecords` or `modifyAllRecords`), a permission set it accepted before; the permission evaluator grants edit and delete only together with read; and a by-id update or delete from a caller the read door refuses answers that refusal. Nothing is widened. Metadata-protocol: narrowed Skills-docs: docs moved — `content/docs/permissions/permission-sets.mdx` states the rule under the object permission bits; `skills/**` teaches no entry granting edit or delete without read (its examples grant read beside edit), so no skill moves. ## 维护者速读 - 改了什么:对象权限里「能编辑 / 能删除」必须同时「能读」。规范层拒收只给编辑或删除、不给读的权限条目;运行时把编辑和删除按「读 ∧ 授权」判定;设置界面勾编辑或删除时自动勾上读,取消读时一并取消编辑和删除。 - 为什么改:按维护者 2026-11-04 裁决(第 4 批第 5 项选 B,前端设置界面配套改;第 6 项由席位按授权决定并入验收)。 - 风险与回滚:收窄。已存库的旧权限集照常加载,但其中无读的编辑 / 删除授权不再生效;旧构建产物若含此形状,解析时被拒,需改源码后重建。回滚即回退本 PR。 - 先看:`packages/spec/src/security/permission.zod.ts`(规则与运行时折叠),`packages/plugins/plugin-security/src/permission-evaluator.ts`(按调用者合并后的条目判定),`ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx`(勾选联动)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/security/permission.test.ts -t "also reads it"`,预期 11 条全绿。 - 需要你做的:无,席位落地。 ## What changes **The rule: an object permission that edits or deletes a record also reads it.** - **Spec.** `ObjectPermissionSchema` (and so `PermissionSetSchema`, `definePermissionSet`, `defineStack`, `os validate` / `os build` and the metadata save door) refuses an entry granting `allowEdit` or `allowDelete` with none of `allowRead`, `viewAllRecords`, `modifyAllRecords`: one `custom` issue per bit at the bit's own path, naming both fixes. `objectPermissionGrants` answers edit and delete as `read ∧ grant`, the shape `allowExport` already had. The response-side `EffectiveObjectPermissionSchema` stays tolerant. The `allowEdit` / `allowDelete` descriptions state the rule (reference docs regenerated). ADR-0087 D3 semantic entry `permission-write-without-read-refused` with FROM → TO; no D2 conversion, because either rewrite would change an access decision. - **Runtime (`plugin-security`).** `PermissionEvaluator.checkObjectPermission` asks the fold of the caller's MERGED entry (the most-permissive per-object merge `/me/permissions` hands the client), so the read half may come from another set, exactly as export's does, and the door and the client map give one answer. Object admission (`canReadObject` / `canWriteObject`), `security/explain` and the middleware gate all read through it. A permission set stored before this release still loads; its unread edit or delete grant grants nothing. - **By-id writes.** A by-id update or delete the caller addressed, from a caller whose read of the object is refused (with these grants: a capability the object requires for a read and not for a write), answers that read refusal for every id: a caller who cannot read an object writes none of its records by id. Where a write-class row filter applies, the caller-context re-read already refuses every id with one record-level 403, and that answer (a store fault included) is unchanged. - **`ui/` (`@object-ui/app-shell`).** The permission set editor ticks Read when Edit or Delete is ticked, and unticking any bit that reads (Read, View All or Modify All) unticks Edit and Delete once no bit that reads remains on the entry (clearing Delete beside Modify All would author the pair the spec refuses). - **Docs.** `content/docs/permissions/permission-sets.mdx` states the rule. - **Fixtures.** Every test that authored edit or delete without read now builds that set raw, as a stored pre-upgrade row, and pins what it grants now (nothing). Census: no shipped permission set (packages, examples, skills, docs, platform seeds) carries the shape. Not changed: `allowCreate` and `allowTransfer` are outside this rule. ## Tests - Spec: `permission.test.ts` › "an object permission that edits or deletes a record also reads it" (refusal per bit and path, wildcard, stack schema, every read bit accepted, wire tolerance, the fold). - Runtime: `permission-evaluator.test.ts` (exhaustive fold matrix against an independent reference, stored rows, cross-set read); `by-id-write-unreadable-not-found.test.ts` › "a caller the read door refuses gets that refusal from every by-id write it addresses, a row or none" (read gated on a capability; with and without row-level policies; capability-holder control). - Fixture triage pins the stored-row answer in `controlled-by-parent-*`, `explain-asker-gate`, `predicate-write-unreadable-not-matched`, `org-admin-assignment-boundary.posture`, `get-effective-object-permissions`, `data-conflict-caller-visibility`, `share-links-enforcement-context`; lint's access-matrix reference fold. - `ui/`: `PermissionMatrixEditor.writeImpliesRead.test.tsx` (both directions, saved payload parses). - Dogfood (HTTP door): `object-permission-write-implies-read.dogfood.test.ts` (save door 422 at the bit's path; a stored set without read: explain denies, `PATCH` / `DELETE` one refusal for a row and an id naming none, row untouched; control with read). `upsert-by-id-caller-read.dogfood.test.ts` seeds its edit-without-read personas as stored rows. ## Acceptance notes - **Census.** No shipped permission set grants edit or delete without read: a brace-level scan of `packages/**`, `examples/**`, `skills/**`, `content/docs/**`, `ui/**` found 48 such entries, all in tests or in code guarded by `modifyAllRecords`; the showcase, CRM and multi-package example apps build under the new rule. Every test fixture that authored the shape was triaged: built raw as a stored pre-upgrade row (it now pins what such a row grants), never re-spelled to hide it. - **Cross-set read.** The evaluator merges the caller's resolved entries before asking the fold, so a caller holding edit from one set and read from another keeps edit, as with export. For every pre-existing cell the merged reading equals the old per-set one (each is a disjunction of bits); the exhaustive 3^8-entry matrix pins it. - **What a caller the read door refuses now meets.** Outside the permission model, the one remaining source of a declared read refusal for a caller the write door admits is an object `requiredPermissions` map that gates `read` and not `update`/`delete` (ADR-0066 ⑤). The rule above covers it at the by-id write door; pinned with and without row-level policies, plus a capability-holder control. - **Out of this rule, unchanged:** `allowCreate` and `allowTransfer` without read. - **`access-upgrade-report.ts`** reads a customer set's edit grant through `grantsEditBit` (the declared bit and the read the fold requires), so a stored entry without read, which edits nothing, is not reported; it does not fold in View All / Modify All holders, whose meeting with a package's field denies is a separate question. - **Local `ui/` type-check NOT MEASURED:** `@object-ui/app-shell` `type-check` reports 1096 TS2307 (the `@object-ui/*` workspace packages have no built declarations in this container) and the TS7006 cascade they cause, none on an edited line; CI's `Type Check UI` measures it. ## Verification Readings on head `403f77c0` unless named. `d7bf39c3` changed one code comment and the changeset wording; `f4a273bb` applied the review's should-fixes (editor cascade for every reading bit, `grantsEditBit`) with 113 derived families run, 0 NOT MEASURED, plugin-security 242 files / 5283 tests, the ui targeted set 75 files / 607 tests; `58698caa` is prose only (changeset and docs wording), 54 derived families run, 0 NOT MEASURED. <details><summary>Ablation of the runtime conjunction (one shot, restored)</summary> `objectPermissionGrants`' `allowEdit` cell mutated to `permission.allowEdit === true || modifyAll` through `scripts/ablation-replace.mjs`, spec rebuilt, `ablation-dist-preflight` found the marker in 2 built files. Red: spec `permission.test.ts` 1 failed / 121 ("the fold grants neither verb to such an entry: read ∧ grant"); plugin-security `permission-evaluator.test.ts` 3 failed / 38 (the exhaustive matrix, "update: … a grant without read grants nothing", "update: the read half may arrive from a different set"); dogfood `object-permission-write-implies-read` 1 failed / 8 ("explain denies both"). The by-id `PATCH` pin stayed green under the mutation, because the read-refusal step refuses the write on its own. Restore: blob equals HEAD (`27bdf93c5901`), `git diff HEAD` empty, spec rebuilt, marker absent from all 592 built files, tree clean. </details> <details><summary>Suites</summary> - `@objectstack/spec` `pnpm test`: 812 / 812 files, 24413 tests. - `@objectstack/plugin-security` full: 242 / 242 files, 5282 tests. - `@objectstack/runtime` full: 543 / 543 files, 9826 tests. `@objectstack/verify` full: 48 / 48 files. `@objectstack/core` 139 / 139, `@objectstack/formula` 64 / 64, `@objectstack/lint` 192 / 193 before the reference-fold fix, the one red file (`build-access-matrix.test.ts`) 8 / 8 after it, `@objectstack/plugin-hono-server` 38 / 38. - `ui/`: the permission editor, permission slice and advanced facet suites, `packages/permissions/` and every `ui/` census file: 82 / 82 files, 703 tests; `PermissionMatrixEditor.writeImpliesRead` 9 / 9 at `f4a273bb`. - Typecheck: spec, plugin-security, runtime, lint, dogfood exit 0. </details> <details><summary>Dogfood battery (full, once)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock: 417 passed / 1 failed / 3 skipped files, 3469 tests passed / 1 failed. The one failure is a 5000 ms timeout in `package-commit-revert-path-package` ("control: a writable package's edit commit restores its own row in place"), taken while the gate sweep shared the box; the file alone passes 12 / 12. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 113 families, each run with its exit code captured before any pipe. Three first refused with exit 3 (`check-plugin-teardown-shape --self-test` on the shallow clone, `check:skill-examples` and `check:dual-build-cjs-loads` on unbuilt packages); after `git fetch --unshallow` and building those packages, all three exit 0. `--ran`: "113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED". `spec check:generated`: all 14 artifacts current after merging `main`. </details> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31fe289 commit 0fd363d

28 files changed

Lines changed: 898 additions & 108 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
'@object-ui/app-shell': minor
5+
---
6+
7+
fix(spec)!: an object permission that edits or deletes a record also reads it
8+
9+
Clause-②: no (narrowing) — the authoring object-permission schema refuses an entry granting `allowEdit: true` or `allowDelete: true` without read (`allowRead`, `viewAllRecords` or `modifyAllRecords`), a permission set it accepted before; the permission evaluator grants edit and delete only together with read; and a by-id update or delete from a caller the read door refuses answers that refusal. Nothing is widened.
10+
Metadata-protocol: narrowed
11+
Skills-docs: docs moved — `content/docs/permissions/permission-sets.mdx` states the rule under the object permission bits; `skills/**` teaches no entry granting edit or delete without read (its examples grant read beside edit), so no skill moves.
12+
13+
<!-- adr-0087: registered permission-write-without-read-refused -->
14+
15+
**BREAKING** accept-set narrowing on `ObjectPermissionSchema` (and so `PermissionSetSchema`, `definePermissionSet`, `defineStack`, `os validate` / `os build`, and the metadata save and publish doors for `permission`).
16+
17+
- **Before.** `objects: { crm_lead: { allowEdit: true } }` parsed, and the evaluator granted edit on `crm_lead` to a holder who could not read it.
18+
- **Now.** The schema refuses it: one `custom` issue at `objects.crm_lead.allowEdit` (or `.allowDelete`), naming both fixes. `objectPermissionGrants` answers edit and delete as `read ∧ grant` (the shape `allowExport` already had), and `PermissionEvaluator.checkObjectPermission` asks it of the caller's merged entry, so the read half may come from another set — the same answer `/me/permissions` gives the client.
19+
- **Stored rows.** A permission set stored before this release keeps loading; its unread edit or delete grant grants nothing. The response-side `EffectiveObjectPermissionSchema` stays tolerant. A built artifact carrying the shape is refused when it is parsed and is rebuilt from a corrected source.
20+
- **By-id writes.** A by-id update or delete from a caller whose read of the object is refused — with these grants that is a capability the object requires for a read and not for a write — answers that read refusal for every id it addresses: a caller who cannot read an object writes none of its records by id.
21+
- **`@object-ui/app-shell`.** The permission set editor ticks Read when Edit or Delete is ticked, and unticking any bit that reads (Read, View All or Modify All) unticks Edit and Delete once no bit that reads remains on the entry.
22+
- Unchanged: `allowCreate` and `allowTransfer` are not part of this rule.
23+
24+
**FROM → TO.** `objects: { crm_lead: { allowEdit: true } }` → `objects: { crm_lead: { allowRead: true, allowEdit: true } }` if the set should edit the object, or `objects: { crm_lead: {} }` if it should not. The upgrade-guide entry is `permission-write-without-read-refused` (a semantic migration: no automatic rewrite, because only the author knows which of the two the set means).

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export const SalesUser = definePermissionSet({
5555
| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) |
5656
| `modifyAllRecords` | Edit and delete ALL records regardless of ownership (super-user write); `allowDelete: false` beside it is refused |
5757

58+
An object permission that edits or deletes a record also reads it. An entry
59+
granting `allowEdit: true` or `allowDelete: true` must also grant read —
60+
`allowRead`, `viewAllRecords` or `modifyAllRecords` — or the permission set is
61+
refused, one issue at the bit's own path. The evaluator grants edit and delete
62+
as `read ∧ grant`, merged across the caller's sets like the other bits, so the
63+
read half may come from another set. A set stored before this rule existed
64+
keeps loading, and its unread edit or delete grant grants nothing. In Setup,
65+
ticking Edit or Delete ticks Read, and unticking any bit that reads (Read,
66+
View All or Modify All) unticks Edit and Delete once no bit that reads remains
67+
on the entry.
68+
5869
### `allowExport` — the export axis
5970

6071
Read and export are not the same privilege. Reading a record on screen and

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ const result = AuthProvider.parse(data);
104104
| :--- | :--- | :--- | :--- |
105105
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
106106
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
107-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
108-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
107+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
108+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
109109
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
110110
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
111111
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -165,8 +165,8 @@ This schema accepts one of the following structures:
165165
| :--- | :--- | :--- | :--- |
166166
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
167167
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
168-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
169-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
168+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
169+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
170170
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
171171
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
172172
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,8 +1191,8 @@ This schema accepts one of the following structures:
11911191
| :--- | :--- | :--- | :--- |
11921192
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
11931193
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
1194-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
1195-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
1194+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
1195+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
11961196
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
11971197
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
11981198
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -1652,8 +1652,8 @@ One changed key path of the pending draft, and the last write that changed it.
16521652
| :--- | :--- | :--- | :--- |
16531653
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
16541654
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
1655-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
1656-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
1655+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
1656+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
16571657
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
16581658
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
16591659
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ const result = AdminScopeSchema.parse(data);
4646
| :--- | :--- | :--- | :--- |
4747
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
4848
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
49-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
50-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
49+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
50+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
5151
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
5252
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
5353
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -95,8 +95,8 @@ const result = AdminScopeSchema.parse(data);
9595
| :--- | :--- | :--- | :--- |
9696
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
9797
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
98-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
99-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
98+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
99+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
100100
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
101101
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
102102
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
@@ -142,8 +142,8 @@ const result = AdminScopeSchema.parse(data);
142142
| :--- | :--- | :--- | :--- |
143143
| **allowCreate** | `boolean` | optional (default: `false`) | Create permission |
144144
| **allowRead** | `boolean` | optional (default: `false`) | Read permission |
145-
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission |
146-
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission |
145+
| **allowEdit** | `boolean` | optional (default: `false`) | Edit permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that edits a record also reads it. |
146+
| **allowDelete** | `boolean` | optional (default: `false`) | Delete permission. Requires read on the same entry (allowRead, viewAllRecords or modifyAllRecords): an object permission that deletes a record also reads it. |
147147
| **allowExport** | `boolean` | optional | User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
148148
| **allowTransfer** | `boolean` | optional (default: `false`) | [RBAC-gated; ENFORCED via the insert/update owner_id guard] Change record ownership (assign/reassign/disown owner_id) |
149149
| **allowRestore** | `never` | optional | [REMOVED] `objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — the `restore` ObjectQL operation it claimed to gate has never shipped (roadmap M2), so granting the bit delivered nothing. Delete the key — a dispatched `restore` stays denied fail-closed by the permission evaluator's destructive-operation backstop, and the bit returns with the M2 lifecycle initiative alongside the operation it gates. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |

‎packages/lint/src/build-access-matrix.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ const BIT_STATES: Array<boolean | undefined> = [true, false, undefined];
109109
/** The fold, restated from the rule — deliberately NOT the spec helper. */
110110
function referenceFold(p: Record<string, unknown>, verb: 'create' | 'read' | 'edit' | 'delete'): boolean {
111111
const modifyAll = p.modifyAllRecords === true;
112+
const read = p.allowRead === true || p.viewAllRecords === true || modifyAll;
112113
switch (verb) {
113114
case 'create': return p.allowCreate === true;
114-
case 'read': return p.allowRead === true || p.viewAllRecords === true || modifyAll;
115-
case 'edit': return p.allowEdit === true || modifyAll;
116-
case 'delete': return p.allowDelete === true || modifyAll;
115+
case 'read': return read;
116+
// An object permission that edits or deletes a record also reads it.
117+
case 'edit': return read && (p.allowEdit === true || modifyAll);
118+
case 'delete': return read && (p.allowDelete === true || modifyAll);
117119
}
118120
}
119121

‎packages/plugins/plugin-security/src/access-upgrade-check.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,22 @@ describe('[ADR-0126 §6 wall 3] security.accessUpgradeCheck, as SecurityPlugin r
221221
}]);
222222
});
223223

224+
it('an edit deny is reported against a customer set whose edit grant reads, and not against a stored one without read, which edits nothing', async () => {
225+
const check = await boot({
226+
customerSets: [
227+
{ name: 'cust_deal_editor', label: 'Deal editor', _provenance: 'org', objects: { u2_deal: { allowRead: true, allowEdit: true } } },
228+
// An object permission that edits a record also reads it: a stored set
229+
// granting edit without read grants no edit, so no edit deny is missing.
230+
{ name: 'cust_deal_blind_editor', label: 'Deal editor, no read', _provenance: 'org', objects: { u2_deal: { allowEdit: true } } },
231+
],
232+
});
233+
const report = findings(await ask(check, V1, { setFields: { 'u2_deal.amount': { readable: true, editable: false } } }));
234+
expect(report.uncoveredDenies).toEqual([{
235+
managedSet: V1.set, packageId: VENDOR, customerSet: 'cust_deal_editor', object: 'u2_deal',
236+
fields: [{ field: 'u2_deal.amount', denies: 'edit' }],
237+
}]);
238+
});
239+
224240
it('a finding that predates the upgrade is not the upgrade\'s: the answer is clean', async () => {
225241
const check = await boot({ ledger: [{ type: 'permission', name: 'gone_set', packageId: 'com.other.pkg', active: false }] });
226242
expect(await ask(check, V1)).toEqual({ outcome: 'clean' });

0 commit comments

Comments
 (0)