Repository navigation
Commit f9f27a0
feat(spec,plugin-security,rest,client): a record-scoped field projection judges a
<!-- 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>when lock on the stored row (#27063)1 parent d90657b commit f9f27a0
30 files changed
Lines changed: 811 additions & 24 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol/objectql
- references
- api
- security
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- client/src
- plugins/plugin-security/src
- qa/dogfood/test
- fixtures
- rest/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- contracts
- security
| 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 | + | |
| 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 | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
59 | 68 | | |
60 | 69 | | |
61 | 70 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
264 | | - | |
| 264 | + | |
265 | 265 | | |
266 | 266 | | |
267 | 267 | | |
| |||
0 commit comments