Skip to content

Commit f9f27a0

Browse files
feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row (#27063)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBFrHTwT2queiHnTbzxnSn3Y)_ Part of #26594 Ruling-ref: 6457467702 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: updated here — content/docs/permissions/field-level-security.mdx (the readers bullet now names the record-scoped projection); skills/objectstack-data/rules/security.md names no reader of the lock, so no skills/** edit (this PR is not governed) ## 维护者速读 - 改了什么:新增「按某条记录」的字段投影 `GET /api/v1/data/{object}/{id}/field-access`(SDK `client.data.fieldAccess`),回答这条记录上哪些字段可读、哪些可写;协议里 `when` 的描述同步改为:无行读取方维持按锁上报,按记录的投影按存储行判断。 - 为什么改:裁决 B。带 `when` 的字段锁(如「批准后锁定」)在草稿上服务端允许写,但表单读的是无行的 `/auth/me/permissions`,只能按锁显示,用户被告知不能改其实能改的字段。 - 风险与回滚:纯新增,旧接口一字节不变;可写判断直接调用写闸本身(`computeForbiddenFieldWrites`),不存在第二套求值;读不到的记录回答与读记录接口相同的 404。回滚即 revert。 - 先看:`packages/plugins/plugin-security/src/security-plugin.ts` 的 `getRecordFieldAccess`、`security-plugin-field-readers.ts` 的 `readRowForbiddenWrites`、`packages/rest/src/data-action-routes.ts` 的新路由。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts`,预期 7/7 通过(草稿上 summary 可写、已批准行上锁定,逐字段与 PATCH 结果一致)。 - 需要你做的:无,席位落地。 ## What changes **The door: a record-addressed route**, `GET /api/v1/data/:object/:id/field-access`, answering `{ success: true, data: { object, recordId, readable, editable } }` (`GetRecordFieldAccessResponseSchema`). Why a route and not a parameter: - The record form does not read a field projection today. Its edit dialog reads `/auth/me/permissions` (`MePermissionsProvider` → `checkField` → `fieldWriteGate` in `ui/packages/plugin-form`), and no HTTP route serves `getReadableFields` / `getWritableFields` at all. So there was no projection read to add a parameter to, and the ruling keeps `/auth/me/permissions` row-less. - A record id belongs in the path the data family already addresses records by (`/data/:object/:id/clone`, `/shares`). The route reads the record first through the same `getData` call `GET /data/:object/:id` makes, so a missing row and a row the caller cannot read answer that route's own 404 (same code, same body), and a refused read answers its 403. The projection never says more than the read. **One judge.** `editable` is the field-level write gate itself, **`SecurityPlugin#computeForbiddenFieldWrites`** (body in `security-plugin-object-inputs.ts`), the function step 2.5 of the engine middleware throws from and the one `canWriteObject` asks. The record-scoped answer asks it for an update of the stored row naming every field, with the row read as the caller reads it (`readRowById`, the reader the gate's own pre-image comes from through `getCallerPreImage`). The gate refuses per field, so the fields it names are exactly the ones a by-id update of that row may not write. Sets, posture and the delegator intersection come from `resolveProjectionFieldMask`, the derivation the gate's inputs and the object-wide projections already share. Nothing evaluates `when` a second time. **Why `readable` comes from the read mask, not from `/auth/me/permissions`.** `readable` is `getReadableFields`' answer, computed from the same mask (`computeReadableFields` over the mask already resolved for this call). Reads are judged row-blind by contract (`when` reaches the edit vote only), so the row adds nothing there. The read mask is what the middleware's result masker actually applies: it folds a field's `requiredPermissions`, intersects a delegator's mask, and keeps a partially masked field as a served column. `/auth/me/permissions` is a raw merge of the sets' `fields` entries and applies none of those, so it can disagree with what the record read returns. Files: - `packages/spec/src/security/permission.zod.ts`: the `when` description names the record-scoped projection as the reader that judges the row; the row-less readers (`/auth/me/permissions`, the object-wide field projections, the write preview) keep reporting the lock. - `packages/spec/src/contracts/security-service.ts`: optional `ISecurityService.getRecordFieldAccess(object, recordId, context)` → answer, `null` (no row for this caller), or `undefined` (no answer). - `packages/spec/src/api/protocol.zod.ts`: `GetRecordFieldAccessResponseSchema` and its `GetRecordFieldAccessResponse` / `GetRecordFieldAccessResponseParsed` aliases (ADR-0122). Regenerated artifacts: api-surface, export-origins, declaration-map, authorable-surface, json-schema manifest, references docs, strictness-ledger count (`api/` 447 → 448). - `packages/plugins/plugin-security`: `getRecordFieldAccess` on the plugin and on the registered `security` service; the projection mask gains `readRowForbiddenWrites(row)`, the gate call above. `getWritableFields` and every other row-less reader are unchanged. - `packages/rest`: the route in `data-action-routes.ts` with an empty closed query-parameter set (`RECORD_FIELD_ACCESS_PARAMS`), 501 when no security service offers the method, ledgered `sdk` with `responseSchema`. - `packages/client`: `data.fieldAccess(object, id)`. - `content/docs/permissions/field-level-security.mdx`: the readers bullet. - Changesets: `@objectstack/spec`, `@objectstack/plugin-security`, `@objectstack/rest`, `@objectstack/client`, each minor with both declaration lines. No ADR-0087 marker: nothing stored changes meaning. PR 2 (`Fixes #26594`) makes the ui record form's edit dialog read this route; it lands after this one. ## Tests - `packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts` (real HTTP + security stack, fixture `fixtures/record-field-access-fixture.ts`, `rfa_contract.summary` locked `when: "status in ['approved']"`, `ref_no` locked unconditionally, a private `rfa_memo`): - measure (the card's symptom, kept as its record): `/auth/me/permissions` reports `summary` `{ readable: true, editable: false }`, while `PATCH summary` answers 200 on the draft and 403 on the approved row; - on the draft the projection lists `summary` editable and `ref_no` not; on the approved row neither; - field by field on both rows, `editable.includes(field)` equals the PATCH verdict (200 vs 403), allowed and refused; - a private row the member cannot read, and a missing id: same status (404) and the same body as `GET /data/rfa_memo/:id`; - row-less reader unchanged; `?fields=` refused with 400 `VALIDATION_ERROR`. - The answer is parsed against `GetRecordFieldAccessResponseSchema` on the real mount (the ledger's conformance coverage). - `packages/plugins/plugin-security/src/security-plugin.test.ts` (`[#26594] getRecordFieldAccess`): unlocked on an unnamed row, locked on a named row, equal to the middleware's step 2.5 verdict for every field on both rows, `null` for an absent or invisible row, `getWritableFields` unchanged and reading no row, `undefined` for an unresolvable schema, system context, and the registered service answering from the method. The member pin gains `getRecordFieldAccess: 'optional'`. - `packages/rest/src/record-field-access-route.test.ts`: relay and arguments, the read refusal answered without asking the service, a row lost between reads answering the read path's 404, 501 without the method, and the closed parameter set (400, neither the read nor the service called). - `packages/client/src/client.test.ts`: URL, method and envelope unwrap. ## Acceptance notes - The measure legs ran on this branch, not on a separate `origin/main` build. They exercise `/auth/me/permissions` (`plugin-hono-server`) and the step 2.5 write gate, and this diff changes neither code path. The ablation below is the main-equivalent reading of the projection itself. - `readable` is the object-wide read projection by contract, not a row judgement, and says so in the schema. ## Verification Head `3af86eab0` (main `cb8988ee1` merged in; the tree the runs below read). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 109 derived families, each run and its exit recorded; `--ran` reconciliation: 109 run, 0 NOT-MEASURED, 0 UNRUN. All 109 exit 0 on the merged tree. (On the tree before the last main merge, `check:skill-examples` and `check:dual-build-cjs-loads` first answered exit 3, missing `dist/` for packages this PR does not touch; built, both reran exit 0.) - Under `os-verify-lock`, after the last merge: `@objectstack/plugin-security` test, `@objectstack/rest` test, `@objectstack/client` test, `@objectstack/dogfood` typecheck, and the dogfood test above, all exit 0; `@objectstack/spec` `check:generated` exit 0. - Under `os-verify-lock`, before the last merge: `@objectstack/spec` test (22130 passed) and typecheck, `@objectstack/plugin-security` test (5056 passed) and typecheck, `@objectstack/rest` test (6552 passed) and typecheck, `@objectstack/client` test (679 passed) and typecheck, all exit 0. - Ablation: with the pre-image the projection hands the gate replaced by a null row (marker confirmed in `dist/`), the dogfood file goes 2 red / 5 green: the draft unlock and the field-by-field equality fail, as they should. Restored, the marker is absent from every rebuilt `dist/` file and the file is 7/7. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [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. - [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_01WGufzfXsZBPnnuWWQGXm48 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d90657b commit f9f27a0

30 files changed

Lines changed: 811 additions & 24 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/client': minor
3+
---
4+
5+
feat(client): `data.fieldAccess(object, id)` reads the field projection of one record
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`client.data.fieldAccess(object, id)` calls `GET /data/:object/:id/field-access` and resolves to `GetRecordFieldAccessResponse`: the fields the caller may read, and the fields the write gate admits on an update of that stored row. A field permission whose `when` names only some rows is unlocked on the rows it does not name. A row that is absent or unreadable rejects with the record read's 404.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
feat(rest): `GET /data/:object/:id/field-access` serves the field projection of one record
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
A record's edit form can ask which fields it may write on THAT record: the route answers `{ success: true, data: { object, recordId, readable, editable } }` (`GetRecordFieldAccessResponseSchema`), with `editable` judged on the stored row by the write gate through the security service's `getRecordFieldAccess`. The record is read first exactly as `GET /data/:object/:id` reads it, so an absent row or one the caller cannot read answers that route's 404 and the projection discloses nothing more. The route takes no query parameter (a located 400 names any). Without a security service that offers the method it answers 501, and `/auth/me/permissions` remains the row-less reader.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): `getRecordFieldAccess` — a record's field projection from the write gate's own verdict
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
The `security` service answers `getRecordFieldAccess(object, recordId, context)`. It reads the row as the caller reads it (the reader the write gate's pre-image comes from) and asks the field-level write gate itself, `computeForbiddenFieldWrites`, for an update of that row naming every field, over the same permission sets, posture and delegator intersection the gate takes. A field permission whose `when` names only some rows therefore locks the field only on those rows, and the answer cannot disagree with a write of that row. A row that is absent or unreadable answers `null`. `getWritableFields` and every other row-less reader are unchanged and still apply each row-conditioned entry as its lock.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a record-scoped field projection, judged on the stored row by the write gate
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`GetRecordFieldAccessResponseSchema` (`@objectstack/spec/api`) declares the answer of `GET /api/v1/data/{object}/{id}/field-access`: `{ object, recordId, readable, editable }`, the field projection of ONE record. `ISecurityService` declares its source as the optional `getRecordFieldAccess(object, recordId, context)`: `editable` is the write gate's own verdict on an update of the stored row, so a field permission whose `when` names only some rows locks the field only on those rows, and `readable` is the row-blind read projection. `null` means the caller has no row there (absent or unreadable, deliberately the same answer); `undefined` means no answer.
11+
12+
The `FieldPermissionSchema.when` description now names that reader: the row-less readers (`/auth/me/permissions`, the object-wide field projections, the write preview on an update) keep reporting a row-conditioned entry as its lock, and the record-scoped projection judges it on the row. Nothing an author writes changes meaning.

‎content/docs/permissions/field-level-security.mdx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,19 @@ fields: {
5252
address, a stored row the caller cannot read, a condition that does not
5353
resolve for the request — the entry is applied as its **lock**: no edit
5454
grant on that write, and a WARN line says why.
55-
- **Readers that hold no row** (`/auth/me/permissions`, the writable-field
56-
projections, `os explain`) report a row-conditioned entry as its lock, so
57-
a client never advertises an edit the gate refuses. `os explain` names
58-
each such entry as a row-conditioned lock in its `fls` layer.
55+
- **Readers that hold no row** (`/auth/me/permissions`, the object-wide
56+
writable-field projections, `os explain`) report a row-conditioned entry
57+
as its lock, so a client never advertises an edit the gate refuses.
58+
`os explain` names each such entry as a row-conditioned lock in its `fls`
59+
layer.
60+
- **The record-scoped projection holds the row.**
61+
`GET /api/v1/data/{object}/{id}/field-access` (`client.data.fieldAccess`)
62+
answers the fields the caller may read and the fields it may write on
63+
**that** record, and its `editable` list is the write gate's own verdict on
64+
an update of the stored row. So a lock that applies from a stage onward
65+
disables the field only on the rows its condition names — this is the
66+
answer a record's edit form reads. A row that is absent or that the caller
67+
cannot read answers the record read's `404`.
5968
- **Union without deny is unchanged.** A matched conditioned lock is still
6069
out-voted by another set's unconditioned `editable: true`; an entry
6170
without `when` votes on every row, as before (ADR-0066 ⑧, note of

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ fields:
261261

262262
> There is no separate `create` flag for fields. `editable` governs both create-time and update-time writes; read stripping is governed by `readable`.
263263
264-
> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when).
264+
> `when` narrows **where** the entry votes: on a matching row it votes as declared, on every other row it is absent (as if the set had not declared the field). The write gate judges the stored row before an update and each incoming row of an insert, in process; a row it cannot judge (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) applies the entry as its lock. `readable: false` beside a `when` is refused at parse time, and `objectstack validate` judges the condition like an RLS predicate. Readers that hold no row (`/auth/me/permissions`, `os explain`) report the entry as its lock; the record-scoped projection (`GET /data/{object}/{id}/field-access`, the answer a record's edit form reads) judges the stored row exactly as the write gate does. See [Field-level security](/docs/permissions/field-level-security#row-conditioned-edit-locks-when).
265265
266266
### Static `readonly` fields on the write path
267267

0 commit comments

Comments
 (0)