Repository navigation
Commit 0fd363d
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
File tree
- .changeset
- content/docs
- permissions
- references
- api
- security
- packages
- lint/src
- plugins/plugin-security/src
- qa/dogfood/test
- runtime/src/domains
- spec/src
- migrations/entries/semantic
- security
- ui/packages/app-shell/src/views/metadata-admin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
58 | 69 | | |
59 | 70 | | |
60 | 71 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | | - | |
108 | | - | |
| 107 | + | |
| 108 | + | |
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| |||
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
168 | | - | |
169 | | - | |
| 168 | + | |
| 169 | + | |
170 | 170 | | |
171 | 171 | | |
172 | 172 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1191 | 1191 | | |
1192 | 1192 | | |
1193 | 1193 | | |
1194 | | - | |
1195 | | - | |
| 1194 | + | |
| 1195 | + | |
1196 | 1196 | | |
1197 | 1197 | | |
1198 | 1198 | | |
| |||
1652 | 1652 | | |
1653 | 1653 | | |
1654 | 1654 | | |
1655 | | - | |
1656 | | - | |
| 1655 | + | |
| 1656 | + | |
1657 | 1657 | | |
1658 | 1658 | | |
1659 | 1659 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
50 | | - | |
| 49 | + | |
| 50 | + | |
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
99 | | - | |
| 98 | + | |
| 99 | + | |
100 | 100 | | |
101 | 101 | | |
102 | 102 | | |
| |||
142 | 142 | | |
143 | 143 | | |
144 | 144 | | |
145 | | - | |
146 | | - | |
| 145 | + | |
| 146 | + | |
147 | 147 | | |
148 | 148 | | |
149 | 149 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
112 | 113 | | |
113 | 114 | | |
114 | | - | |
115 | | - | |
116 | | - | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
117 | 119 | | |
118 | 120 | | |
119 | 121 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
224 | 240 | | |
225 | 241 | | |
226 | 242 | | |
| |||
0 commit comments