Repository navigation
feat(spec,plugin-security,rest,client): a record-scoped field projection judges a when lock on the stored row - #27063
Conversation
… gate on one record Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
… over the write gate's own verdict Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 155 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0c7a3f3e1cb21ba243d2932936386b07cef37a5 && git checkout a0c7a3f3e1cb21ba243d2932936386b07cef37a5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 69df290ea5b36d6fe83ed09a4d555751b296c8a8 d7059c339e715eb90a3950bd1b720fe31a57920e && git checkout -B drift-repro 69df290ea5b36d6fe83ed09a4d555751b296c8a8 && git merge --no-ff d7059c339e715eb90a3950bd1b720fe31a57920e
node scripts/docs-audit/affected-docs.mjs --json 69df290ea5b36d6fe83ed09a4d555751b296c8a8
|
…w-less readers of a when lock Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
|
That is the open regression #27004. Since #26930, a predicate delete walks each matched row, so this cap test runs close to its 5 s budget under load. #27037 tracks it as a queue flake. This PR's diff never reaches that path: it adds one GET route and a security-service method, and touches no objectql delete, driver, or this test. The same No fix PR for #27004 exists yet, so there is nothing to port. I'll re-run the failed jobs once, after the CI run finishes. Generated by Claude Code |
Contract reviewServed-tier: Reviewed head ① Derived judgmentsOne judge (ruling B) — holds.
No leak — holds. The route (data-action-routes.ts:891-943) runs Unchanged without the record — holds. The diff touches no row-less reader: The contract — holds. Declarations — hold. Tests and dogfood. Gates. The dev's 109/109, 0 NOT-MEASURED reading was taken on ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…eside the object grant Claude-Session: https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48 Co-authored-by: Claude <noreply@anthropic.com>
维护者速读 (席位终稿)
席位意见:接受。契约复核 PASS(6460514485),三点都已确认:只有一个判官、不泄露记录是否存在、不看行的读口不变。复核提出的 席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
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(SDKclient.data.fieldAccess),回答这条记录上哪些字段可读、哪些可写;协议里when的描述同步改为:无行读取方维持按锁上报,按记录的投影按存储行判断。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:/auth/me/permissions(MePermissionsProvider→checkField→fieldWriteGateinui/packages/plugin-form), and no HTTP route servesgetReadableFields/getWritableFieldsat all. So there was no projection read to add a parameter to, and the ruling keeps/auth/me/permissionsrow-less./data/:object/:id/clone,/shares). The route reads the record first through the samegetDatacallGET /data/:object/:idmakes, 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.
editableis the field-level write gate itself,SecurityPlugin#computeForbiddenFieldWrites(body insecurity-plugin-object-inputs.ts), the function step 2.5 of the engine middleware throws from and the onecanWriteObjectasks. 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 throughgetCallerPreImage). 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 fromresolveProjectionFieldMask, the derivation the gate's inputs and the object-wide projections already share. Nothing evaluateswhena second time.Why
readablecomes from the read mask, not from/auth/me/permissions.readableisgetReadableFields' answer, computed from the same mask (computeReadableFieldsover the mask already resolved for this call). Reads are judged row-blind by contract (whenreaches 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'srequiredPermissions, intersects a delegator's mask, and keeps a partially masked field as a served column./auth/me/permissionsis a raw merge of the sets'fieldsentries and applies none of those, so it can disagree with what the record read returns.Files:
packages/spec/src/security/permission.zod.ts: thewhendescription 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: optionalISecurityService.getRecordFieldAccess(object, recordId, context)→ answer,null(no row for this caller), orundefined(no answer).packages/spec/src/api/protocol.zod.ts:GetRecordFieldAccessResponseSchemaand itsGetRecordFieldAccessResponse/GetRecordFieldAccessResponseParsedaliases (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:getRecordFieldAccesson the plugin and on the registeredsecurityservice; the projection mask gainsreadRowForbiddenWrites(row), the gate call above.getWritableFieldsand every other row-less reader are unchanged.packages/rest: the route indata-action-routes.tswith an empty closed query-parameter set (RECORD_FIELD_ACCESS_PARAMS), 501 when no security service offers the method, ledgeredsdkwithresponseSchema.packages/client:data.fieldAccess(object, id).content/docs/permissions/field-level-security.mdx: the readers bullet.@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, fixturefixtures/record-field-access-fixture.ts,rfa_contract.summarylockedwhen: "status in ['approved']",ref_nolocked unconditionally, a privaterfa_memo):/auth/me/permissionsreportssummary{ readable: true, editable: false }, whilePATCH summaryanswers 200 on the draft and 403 on the approved row;summaryeditable andref_nonot; on the approved row neither;editable.includes(field)equals the PATCH verdict (200 vs 403), allowed and refused;GET /data/rfa_memo/:id;?fields=refused with 400VALIDATION_ERROR.GetRecordFieldAccessResponseSchemaon 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,nullfor an absent or invisible row,getWritableFieldsunchanged and reading no row,undefinedfor an unresolvable schema, system context, and the registered service answering from the method. The member pin gainsgetRecordFieldAccess: '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
origin/mainbuild. 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.readableis the object-wide read projection by contract, not a row judgement, and says so in the schema.Verification
Head
3af86eab0(maincb8988ee1merged 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;--ranreconciliation: 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-examplesandcheck:dual-build-cjs-loadsfirst answered exit 3, missingdist/for packages this PR does not touch; built, both reran exit 0.)os-verify-lock, after the last merge:@objectstack/plugin-securitytest,@objectstack/resttest,@objectstack/clienttest,@objectstack/dogfoodtypecheck, and the dogfood test above, all exit 0;@objectstack/speccheck:generatedexit 0.os-verify-lock, before the last merge:@objectstack/spectest (22130 passed) and typecheck,@objectstack/plugin-securitytest (5056 passed) and typecheck,@objectstack/resttest (6552 passed) and typecheck,@objectstack/clienttest (679 passed) and typecheck, all exit 0.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 rebuiltdist/file and the file is 7/7.Human Minimum
Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic.pnpm lintpasses. (No formatter ran.pnpm lintis CI's run.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzfXsZBPnnuWWQGXm48