Repository navigation
feat(security): the record field-access answer says whether the record itself is writable - #27232
Conversation
…tself is writable The record projection gains writable: the verdict of the gate itself on a by-id update of the stored row. plugin-security runs the authorization middleware it registered over a synthetic by-id update with an empty payload and ends the chain before the engine executor; plugin-sharing's by-id gate is now one service method its middleware refuses on, asked at the chain's end with the stamped context. Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy Co-Authored-By: Claude <noreply@anthropic.com>
… the probe's lack of side effects Unit pins in plugin-security and plugin-sharing, the dogfood legs on the public_read row and its controls, the docs paragraph and the changesets. Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy Co-Authored-By: Claude <noreply@anthropic.com>
… created notice id as the other legs do Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 151 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 584fc2b908cad98fca1af8cc9e802cee5e95aaa9 && git checkout 584fc2b908cad98fca1af8cc9e802cee5e95aaa9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ff8154cf694c616de5d467b35af21e037de1509 7047dcbc9ba4b828db0e9da0bd62b87f6911555f && git checkout -B drift-repro 9ff8154cf694c616de5d467b35af21e037de1509 && git merge --no-ff 7047dcbc9ba4b828db0e9da0bd62b87f6911555f
node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509
|
Contract reviewServed-tier: Inputs: card #27175 (body; triage 6465932161; claim 6466687477; amendment 6466781443; dev report 6468120932), PR #27232 (body, 17-file list, net diff against Check-runs on the head at render time: green — ① Derived judgments
② Semver levelThree changesets, each Clause-②: yes (widening) ③ Boundary flags
④ Design judgment
What a real Where the two could disagree, and what the tree says:
Findings:
No must-fix. The verdict's two legs are the write's two legs, run as the write runs them, on a row address the write resolves identically, with nothing written and nothing bumped; the three should-fixes are wording on the published describe and a log line, none of which makes Implemented-by: VERDICT: PASS Rendered 2026-10-28T11:56Z. |
…debug The writable describe, the contract TSDoc, the docs page and the spec changeset now say what false means for a stored row outside a row-level check (an update leaving the row as stored is refused; a save moving the checked value back inside can pass), and name two more things true does not cover: a platform object's own gates and an object the API serves read-only. A refused probe logged the gate's refusal warning with operation 'update', so every open of a read-only record looked like a denied write. The refusal sites a probe can reach now go through logGateRefusal, which logs a probe at debug with probe: true and a real write at warn as before. The sharing leg is asked through admitsByIdWrite, which logs no refusal, so no sharing line fires for a probe. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
…-askd75 # Conflicts: # content/docs/permissions/field-level-security.mdx
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy
席位核对 · ACCEPT ·
|
Requested by Yibo Wang · project thread
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,表示「这条记录本身能不能被更新」。SharingService.admitsByIdWrite,其中间件也只调用它)。任何一处拒绝即false。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)。What changes
One row-level verdict on the record projection.
GetRecordFieldAccessResponseSchema(packages/spec/src/api/protocol.zod.ts) gainswritable: boolean: whether the caller may update this record at all, judged by the write gate on the stored row.falsemeans an update is refused whole, whatever it changes. The describe names in customer language whattruedoes not cover.editableis unchanged and stays field-level; its describe now points atwritable.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 arePERMISSION_DENIEDwith a catalog sentence, or sharing'sFORBIDDEN), 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:
registerAuthzMiddlewarenow returns the handler it registers, andSecurityPluginkeeps it (authzMiddleware).isRecordRowWritablehands that same function a synthetic by-idupdateof the row (empty payload, a copy of the caller's context, the opCtx shapeengine.update()builds) and a terminalnext()that ends the probe with a sentinel onlyprobeRecordRowWritecatches (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, therequiredPermissionsAND-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 isfalse. The handler body is unchanged except for one guard (below).canEdit/canDelete, the delegator's gate, then theprobeAuthoredRowWritedeferral) moved into one method,SharingService.admitsByIdWrite(object, id, verb, context). The middleware now calls it and refuses exactly when it answersfalse; 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 atkernel:ready, after the security middleware, so it runs inside it).byIdWriteGateInstalledis set when the middleware is installed; underenforce: falseno 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
beforeUpdatehooks, validation, the driver write,afterUpdatehooks 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):
beforeUpdatehook 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;assertWriteAllowed, object-level);So
truenever means "this save will succeed";falsealways means the gate refuses the whole row.Known effect: a probe that is refused logs the gate's usual refusal
warnline (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; regeneratedcontent/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,writableingetRecordFieldAccess).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 describeswritable..changeset/):@objectstack/spec,@objectstack/plugin-security,@objectstack/plugin-sharing, eachminor,Clause-②: yes (widening),Metadata-protocol: added. The spec changeset carries a "Supersedes, in the same release" paragraph for the unreleased Record form: a row-conditioned field lock (when) disables the field on every row, though the write gate admits it on rows the condition does not name #26594 note that lists the answer's old shape.@objectstack/client(data.fieldAccess) and@objectstack/restrelay 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:RecordFieldAccessScopereads it and the edit surfaces show the record read-only when it is false.Verification
Head
58e92a401(four commits plus a merge oforigin/mainfd73f2b39, which touched none of these files). Run on the shared container underos-verify-lock.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 108 derived commands, each run and its exit recorded.--ranreconciliation: 108 run, 0 NOT-MEASURED, 0 UNRUN. 106 exited 0 on the first run.spec check:skill-examplesandcheck:dual-build-cjs-loadsexited 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.turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/plugin-sharing --filter=@objectstack/plugin-securityexit 0, again after the main merge.pnpm --filter @objectstack/spec check:generatedexit 0 (it foundcontent/docs/references/**stale once;--fixregenerated only that, committed).record-field-access-route.test.ts5/5;typecheckof spec, plugin-security and plugin-sharing exit 0.pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts10/10. New legs: thepublic_readrow another user owns answerswritable: falseand both PATCHes answer 403; the draft and approved contracts and the member's own notice answertrueand their PATCH answers 200; three probes leave the stored row equal and add nosys_audit_logupdate row, while one real save adds exactly one (theAuditPluginis booted for this).security-plugin.test.ts,[#27175] writable):writableequals 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__writeScopestamped and decides the verdict both ways, and is skipped whenbyIdWriteGateInstalledis false; no registered middleware answersfalse.sharing-service.test.ts: the middleware refuses exactly whereadmitsByIdWriteanswers false, for update and delete, four (row, user) pairs each, and asks the method once per write.git restore, rebuilt):probeRecordRowWriteforced totruereds 3 unit pins and the dogfoodpublic_readleg; the epoch guard removed reds the no-side-effect pin alone.Out-of-scope findings (for seat 1 to file)
writable(see residuals). The record detail page readsgetRecordLock; whether the edit dialog (ModalForm/DrawerForm/ the edit page) does is the ui half's question when Record edit dialog: on a row the caller may read but not write (sharing / RLS / ownership), the fields are offered as editable and the save is refused whole — the dialog never asks whether the row is writable #27138 is dispatched: an approval-locked record is the same "edit, then lose the work" case.packages/client/src/index.tsdata.fieldAccess) and rest route comments describe the answer as the field projection only; accurate, but they do not namewritable. Left untouched to keep those packages out of the release.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses. (No formatter ran.pnpm lintis CI's run.)main, one pull request. (The branch is the harness-pinnedclaude/project-thread-askd75, named in the claim.)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_01WGufzj5HG79HLtAhJJ2vzy