Repository navigation
Commit 8bb2c31
feat(security): the record field-access answer says whether the record itself is writable (#27232)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBHS5r3FWzbMqPq7Ve6wZPUp)_
Fixes #27175
Ruling-ref: 6457467702
Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — `skills/**` names no reader of the record
field-access answer (`git grep -n
"field-access\|getRecordFieldAccess\|fieldAccess" -- skills/` is empty);
the hand-written page that does,
`content/docs/permissions/field-level-security.mdx`, is updated here
## 维护者速读
- 改了什么:记录的字段访问答案(`GET /data/{object}/{id}/field-access`)新增 `writable:
boolean`,表示「这条记录本身能不能被更新」。
- 怎么算的:不是另写一套判断。plugin-security 保留它注册到引擎上的那个授权中间件函数,对这条记录跑一次「按 id 更新、空
payload」的模拟操作,在链尾、引擎执行器(钩子、校验、写库、审计)之前截停;链尾再问 plugin-sharing 的按 id 写闸(抽成
`SharingService.admitsByIdWrite`,其中间件也只调用它)。任何一处拒绝即 `false`。
- 风险与回滚:探测不写任何东西:上下文用副本,不推进权限缓存 epoch(单测钉住),dogfood
钉住无审计行、记录不变。`editable` 不变。回滚即 revert。
-
先看:`packages/plugins/plugin-security/src/record-row-write-probe.ts`,`security-plugin.ts`
的 `isRecordRowWritable`,`plugin-sharing/src/sharing-service.ts` 的
`admitsByIdWrite`。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/record-field-access-when.dogfood.test.ts`,预期 10/10(他人拥有的
`public_read` 行 `writable: false` 且 PATCH 403;可写行 `true` 且 PATCH 200)。
- 需要你做的:无,席位落地;随后 ui 席位接 #27138。
## What changes
**One row-level verdict on the record projection.**
`GetRecordFieldAccessResponseSchema`
(`packages/spec/src/api/protocol.zod.ts`) gains `writable: boolean`:
whether the caller may update this record at all, judged by the write
gate on the stored row. `false` means an update is refused whole,
whatever it changes. The describe names in customer language what `true`
does not cover. `editable` is unchanged and stays field-level; its
describe now points at `writable`.
`ISecurityService.getRecordFieldAccess`
(`contracts/security-service.ts`) documents the member. The shape is a
boolean only: the gate has no closed row-reason vocabulary (its refusals
are `PERMISSION_DENIED` with a catalog sentence, or sharing's
`FORBIDDEN`), and a boolean discloses nothing more than the PATCH would.
**The verdict is the gate's own code, run in a would-pass mode.** A
by-id update is refused for its row by two engine middlewares, so the
verdict asks both, through their own code:
- *plugin-security's authorization middleware.*
`registerAuthzMiddleware` now returns the handler it registers, and
`SecurityPlugin` keeps it (`authzMiddleware`). `isRecordRowWritable`
hands that same function a synthetic by-id `update` of the row (empty
payload, a copy of the caller's context, the opCtx shape
`engine.update()` builds) and a terminal `next()` that ends the probe
with a sentinel only `probeRecordRowWrite` catches
(`record-row-write-probe.ts`). So every step a real update meets runs:
the engine-owned write guard, the delegated-admin gate, principal and
posture resolution, the `requiredPermissions` AND-gate (1.5), the CRUD
grant (2), the access-depth stamp (2.6), the RLS pre-image with the
ownership floor and its sharing deferral (2.7), the controlled-by-parent
master (2.8), and the payload gates over an empty payload (2.5, 3.5,
3.6, 3.7). Any throw is `false`. The handler body is unchanged except
for one guard (below).
- *plugin-sharing's middleware.* Its by-id gate (`canEdit` /
`canDelete`, the delegator's gate, then the `probeAuthoredRowWrite`
deferral) moved into one method, `SharingService.admitsByIdWrite(object,
id, verb, context)`. The middleware now calls it and refuses exactly
when it answers `false`; the refusal messages and log lines are
unchanged. The probe's terminal asks the same method with the context as
the security middleware stamped it (`__writeScope`, an external
principal's grant), which is what the sharing middleware reads in the
real chain (it registers at `kernel:ready`, after the security
middleware, so it runs inside it). `byIdWriteGateInstalled` is set when
the middleware is installed; under `enforce: false` no by-id write meets
the gate, and the probe skips it too.
A transferred record shows why both legs are needed: its former creator
passes the security middleware's ownership floor (`created_by ==
current_user.id`) and is refused only by sharing.
**No side effects.** The engine's executor, where `beforeUpdate` hooks,
validation, the driver write, `afterUpdate` hooks and audit run, sits
after the last middleware, so the probe never reaches it. Before that
point the handler refuses, reads, and stamps the operation and the
context; the probe owns the operation and passes a copy of the context.
Read through the closure for anything else that writes: no emits, no
audit, no counters. The one write to shared state is the permission-set
memo epoch the handler advances on a write when the engine does not own
that seam; it is now skipped for a probe (`isRowWriteProbe`, a
module-private symbol on the operation context that no request can
carry). Caches that any read fills (permission-set resolution) fill as
they would on a read.
**Not in the verdict (declared residuals, named in the describe):**
- a pending approval's record lock: a `beforeUpdate` hook in
plugin-approvals, judged on the values sent (the status mirror field
stays writable), with its own reader, `IApprovalService.getRecordLock`,
which the record detail page already reads;
- conditional read-only rules and validation rules on the values a save
sends;
- an external datasource that refuses writes (engine
`assertWriteAllowed`, object-level);
- other plugins' middlewares for their own objects (mail, attachments).
So `true` never means "this save will succeed"; `false` always means the
gate refuses the whole row.
**Known effect:** a probe that is refused logs the gate's usual refusal
`warn` line (for example "Access denied: not permitted to update this
'rfa_notice' record (row-level security)"), as a refused PATCH does.
Suppressing it would mean probe-awareness at every refusal site; left as
is.
Files:
- `packages/spec/src/api/protocol.zod.ts`,
`packages/spec/src/contracts/security-service.ts`; regenerated
`content/docs/references/api/protocol.mdx`,
`packages/spec/authorable-surface/api.json`.
- `packages/plugins/plugin-security/src/record-row-write-probe.ts`
(new), `security-plugin-authz-middleware.ts` (returns the handler; the
probe epoch guard; header note), `security-plugin.ts`
(`authzMiddleware`, `isRecordRowWritable`, `writable` in
`getRecordFieldAccess`).
- `packages/plugins/plugin-sharing/src/sharing-service.ts`
(`admitsByIdWrite`, `byIdWriteGateInstalled`), `sharing-plugin.ts` (the
middleware calls it; the flag is set where the middleware is installed).
- `packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts`,
`fixtures/record-field-access-fixture.ts`.
- `content/docs/permissions/field-level-security.mdx`: the projection
bullet now describes `writable`.
- Changesets (root `.changeset/`): `@objectstack/spec`,
`@objectstack/plugin-security`, `@objectstack/plugin-sharing`, each
`minor`, `Clause-②: yes (widening)`, `Metadata-protocol: added`. The
spec changeset carries a "Supersedes, in the same release" paragraph for
the unreleased #26594 note that lists the answer's old shape.
`@objectstack/client` (`data.fieldAccess`) and `@objectstack/rest` relay
the answer unchanged and pick the member up from the spec type; their
src does not move.
## Why
#27175 / #27138: a user who may read a shared record but not change it
opens Edit, fills the form, saves, and loses the work to a refusal of
the whole update. The permission contract promises a client never
advertises an edit the gate refuses. Ruling 6457467702 (letter B)
forbids a client-side re-derivation, so the server has to say it, and
triage 6465932161 asks for the gate's own computation returned as one
boolean. With `writable`, ui seat 2 can dispatch #27138:
`RecordFieldAccessScope` reads it and the edit surfaces show the record
read-only when it is false.
## Verification
Head `58e92a401` (four commits plus a merge of `origin/main`
`fd73f2b39`, which touched none of these files). Run on the shared
container under `os-verify-lock`.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 108 derived commands, each run and its exit
recorded. `--ran` reconciliation: 108 run, 0 NOT-MEASURED, 0 UNRUN. 106
exited 0 on the first run. `spec check:skill-examples` and
`check:dual-build-cjs-loads` exited 3 (their prerequisite, built dist of
client-react, studio and the knowledge/embedder packages, was absent);
after building those packages both exited 0. Nothing in the diff changed
between the runs.
- Build: `turbo run build --filter='@objectstack/dogfood^...'
--filter=@objectstack/plugin-sharing
--filter=@objectstack/plugin-security` exit 0, again after the main
merge. `pnpm --filter @objectstack/spec check:generated` exit 0 (it
found `content/docs/references/**` stale once; `--fix` regenerated only
that, committed).
- Tests: plugin-security 236 files, 5104 passed / 45 skipped;
plugin-sharing 57 files, 1280 passed; rest
`record-field-access-route.test.ts` 5/5; `typecheck` of spec,
plugin-security and plugin-sharing exit 0.
- Dogfood: `pnpm --filter @objectstack/dogfood exec vitest run
test/record-field-access-when.dogfood.test.ts` 10/10. New legs: the
`public_read` row another user owns answers `writable: false` and both
PATCHes answer 403; the draft and approved contracts and the member's
own notice answer `true` and their PATCH answers 200; three probes leave
the stored row equal and add no `sys_audit_log` update row, while one
real save adds exactly one (the `AuditPlugin` is booted for this).
- Unit pins (`security-plugin.test.ts`, `[#27175] writable`): `writable`
equals a by-id update through the same handler, inside and outside the
RLS write scope, and with an object grant lacking edit; the epoch is not
advanced and the caller's context is not stamped (a real update through
the same handler does advance it); the sharing leg is asked with
`__writeScope` stamped and decides the verdict both ways, and is skipped
when `byIdWriteGateInstalled` is false; no registered middleware answers
`false`. `sharing-service.test.ts`: the middleware refuses exactly where
`admitsByIdWrite` answers false, for update and delete, four (row, user)
pairs each, and asks the method once per write.
- Ablations (each from a committed state, restored with `git restore`,
rebuilt): `probeRecordRowWrite` forced to `true` reds 3 unit pins and
the dogfood `public_read` leg; the epoch guard removed reds the
no-side-effect pin alone.
## Out-of-scope findings (for seat 1 to file)
- The approvals record lock is not in `writable` (see residuals). The
record detail page reads `getRecordLock`; whether the edit dialog
(`ModalForm` / `DrawerForm` / the edit page) does is the ui half's
question when #27138 is dispatched: an approval-locked record is the
same "edit, then lose the work" case.
- The client (`packages/client/src/index.ts` `data.fieldAccess`) and
rest route comments describe the answer as the field projection only;
accurate, but they do not name `writable`. Left untouched to keep those
packages out of the release.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm
lint` is CI's run.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request. (The branch is the harness-pinned
`claude/project-thread-askd75`, named in 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. (The 维护者速读 block is in
Chinese by the repo's dispatch rule.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1e8e529 commit 8bb2c31
17 files changed
Lines changed: 614 additions & 79 deletions
File tree
- .changeset
- content/docs
- permissions
- references/api
- packages
- plugins
- plugin-security/src
- plugin-sharing/src
- qa/dogfood/test
- fixtures
- spec
- authorable-surface
- src
- api
- contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 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 | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
| 69 | + | |
70 | 70 | | |
71 | | - | |
72 | | - | |
73 | | - | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
74 | 83 | | |
75 | 84 | | |
76 | 85 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1757 | 1757 | | |
1758 | 1758 | | |
1759 | 1759 | | |
1760 | | - | |
| 1760 | + | |
| 1761 | + | |
1761 | 1762 | | |
1762 | 1763 | | |
1763 | 1764 | | |
| |||
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
73 | 81 | | |
74 | 82 | | |
75 | 83 | | |
| |||
92 | 100 | | |
93 | 101 | | |
94 | 102 | | |
| 103 | + | |
95 | 104 | | |
96 | 105 | | |
97 | 106 | | |
| |||
270 | 279 | | |
271 | 280 | | |
272 | 281 | | |
273 | | - | |
274 | | - | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
275 | 287 | | |
276 | 288 | | |
277 | 289 | | |
| |||
309 | 321 | | |
310 | 322 | | |
311 | 323 | | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
312 | 327 | | |
313 | 328 | | |
| 329 | + | |
314 | 330 | | |
315 | 331 | | |
316 | 332 | | |
| |||
663 | 679 | | |
664 | 680 | | |
665 | 681 | | |
666 | | - | |
| 682 | + | |
667 | 683 | | |
668 | 684 | | |
669 | 685 | | |
| |||
793 | 809 | | |
794 | 810 | | |
795 | 811 | | |
796 | | - | |
| 812 | + | |
797 | 813 | | |
798 | 814 | | |
799 | 815 | | |
| |||
1108 | 1124 | | |
1109 | 1125 | | |
1110 | 1126 | | |
1111 | | - | |
| 1127 | + | |
1112 | 1128 | | |
1113 | 1129 | | |
1114 | 1130 | | |
| |||
1758 | 1774 | | |
1759 | 1775 | | |
1760 | 1776 | | |
1761 | | - | |
| 1777 | + | |
| 1778 | + | |
| 1779 | + | |
1762 | 1780 | | |
1763 | 1781 | | |
1764 | 1782 | | |
| |||
1774 | 1792 | | |
1775 | 1793 | | |
1776 | 1794 | | |
1777 | | - | |
| 1795 | + | |
1778 | 1796 | | |
1779 | 1797 | | |
1780 | 1798 | | |
| |||
2381 | 2399 | | |
2382 | 2400 | | |
2383 | 2401 | | |
2384 | | - | |
| 2402 | + | |
| 2403 | + | |
| 2404 | + | |
2385 | 2405 | | |
0 commit comments