Repository navigation
fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses - #30219
Conversation
… refused, whatever rows it addresses The predicate update and delete a caller addresses are answered the read door's own refusal when that caller's read of the object is refused, before the write runs, as the by-id update and delete already are. Nothing changes. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
… at the flow trigger door; changeset and docs state the rule Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…by-id control compares a row with an id naming none Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
… no other case addresses Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 0daefc4fb6630d9a88bb7b3c8ca993868397d51d && git checkout 0daefc4fb6630d9a88bb7b3c8ca993868397d51d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 5dea55fda88256ac633800affcc09f3ab2b9c674 && git checkout -B drift-repro ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 && git merge --no-ff 5dea55fda88256ac633800affcc09f3ab2b9c674
node scripts/docs-audit/affected-docs.mjs --json ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7
|
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 2046ac7 · 2026-11-05T02:17Z |
… refuses is refused, by id or by predicate Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: Delta review by the same reviewer, on top of the FAIL record at ① Derived judgments
② Semver levelUnchanged from the first record: ③ Boundary flags
④ Design judgmentUnchanged from the first record: the narrowing takes no declared capability away at any consumer and makes the by-id leg's rule real on the predicate leg; the break attempts there (readable-field predicate, write policy plus capability, Implemented-by: VERDICT: PASS |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj 席位核对ACCEPT · head
|
Clause-②: no (narrowing) — a predicate update or delete (
multi: true, addressed by awhere) from a caller whose read of the object is refused is refused with that read refusal before the write runs, where it was admitted before; nothing is widened.Metadata-protocol: none
Skills-docs: updated here —
content/docs/permissions/permissions-matrix.mdx(the write paragraph) andcontent/docs/permissions/authorization.mdx(the per-operationrequiredPermissionsbullet) state the rule;skills/**teaches no read-gated, write-openrequiredPermissionsmap, so no skill moves.维护者速读
requiredPermissions只卡读、不卡改删,且调用者有写授权而无读能力」这一种组合;能读的调用者、系统写、钩子与级联写不变。回滚即回退本 PR。packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts(读被拒时抛出读拒绝),packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts(第 3 步按条件写的接线),packages/plugins/plugin-security/src/security-plugin.ts(契约注释)。pnpm --filter @objectstack/plugin-security exec vitest run predicate-write -t "read door refuses is refused",预期 1 个文件、9 条用例全绿,其余跳过。What changes
The rule: a write from a caller the read door refuses is refused, whatever rows it addresses.
@objectstack/plugin-security. The predicate update and delete a caller addresses ask the read door's own question of the caller's predicate before the write runs (readableRowsScopeForPredicateWrite). When that read is refused (a declared 4xx), the refusal is now thrown, so the write answers the read door's refusal and changes nothing. The by-id update and delete refuse the same caller too (step 2.7). With the permission model's own grants, the caller who meets this exists in one shape: an object whoserequiredPermissionsmap gatesreadand notupdate/delete(ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires.permissions-matrix.mdxandauthorization.mdxstate the rule.patchfor@objectstack/plugin-security,!, ADR-0087 dispositionnot-required (no-migration-prescription): no metadata an author writes changes and no stored row is rewritten.Tests
@objectstack/plugin-security, the predicate-write suite, new top-level describe "a write from a caller the read door refuses is refused, whatever rows it addresses" (realObjectQLover a real SQL driver, the real middleware, asingleinstall):code,status, message), and no row changes;@objectstack/dogfood, the predicate-write file, new top-level describe at an HTTP door: arunAs: 'user'flow started atPOST /automation/:name/triggerwhoseupdate_record/delete_recordnode runs withmulti: trueover a filter from the start input. The writer's update and delete answer400 FLOW_FAILEDcarrying the read refusal as the node's inner refusal, for a filter naming rows and for one naming none, and no row changes; a principal holding the capability keeps its update and delete (control); the by-idPATCH/DELETEgive the writer one refusal for its own row and for an id naming none (control, unchanged).Acceptance notes
requiredPermissionsmap that gatesreadand not a write verb: every object-levelrequiredPermissionsinpackages/**andexamples/**is astring[], which gates every verb. The shape is authorable, so the rule is for customer metadata.readDoorAnswerForGateRefusalkeeps answering the by-id write only. Nothing is written either way.singleposture. Both rigs aresingleinstalls; the change reads no tenancy input, so the controls hold the same way under every posture.403for every id (step 2.7's answer where a write-class row filter applies, unchanged); the predicate door answers the read door's own403. Both are one answer whatever the write addresses, and both write nothing.test:reponot run locally (declared to CI'sTest Core): the diff edits no file of that project.main. See Verification: the full dogfood battery's one failure is a by-idsys_user_permission_setcontrol inupdate-by-id-refusal-order.dogfood.test.tsthatmain's474c030b13changed the answer for; it stays red with this diff's guard removed.Verification
Readings on head
2046ac7fdd(this branch merged withorigin/main474c030b13) unless named.5dea55fda8changes prose only (comments, docblocks, test headers, the changeset bullet and two docs sentences): 92 derived families run, 0 NOT MEASURED; the predicate-write unit file 26 / 26.Red before the fix (unit pins on the unfixed source)
The new describe run against the branch point's source with only the tests added: 6 failed / 26 (both verbs, with and without row-level policies, and the by-id comparison). With the fix: 26 / 26.
One-shot ablation (mutate and restore, rebuilt each leg)
The guard line in
security-plugin-addressed-writes.ts(if (answer.refused) throw answer.error;) replaced throughscripts/ablation-replace.mjs(anchor 1 to 0, blobd14ca8206269to8b335291908e);@objectstack/plugin-securityrebuilt;ablation-dist-preflightfound the marker in 2 built files. Mutated, on headf6401266b7: the unit file 6 failed / 26; the dogfood file 3 failed / 16; red, then restored (the by-id control now reads a row no other case addresses,84c82fd1c9). Restored by the tool: blob equals HEAD,git diff HEADempty, whole tree clean; rebuilt; preflight--absentexit 0 over 6 built files; unit 26 / 26, dogfood file 16 / 16 on84c82fd1c9.Suites
@objectstack/plugin-securityfull, under the verify lock: 242 / 242 files, 5296 passed, 45 skipped (exit 0).@objectstack/plugin-securitytypecheck: exit 0.@objectstack/dogfoodtypecheck: exit 0.Dogfood battery (full, once)
pnpm --filter @objectstack/dogfood testunder the verify lock on2046ac7fdd: 417 passed / 1 failed / 3 skipped files; 3487 passed / 1 failed / 34 skipped tests (verdict: command-exit 1). The one failure:update-by-id-refusal-order.dogfood.test.ts, "control: an organization administrator, who reads the platform grant object, keeps the administration gate's sentence for a row and for no row" — the id naming no row now answers404 RECORD_NOT_FOUNDwhere the pin expects the gate's403. That answer ismain's474c030b13(readDoorAnswerForGateRefusalanswers the read door's not-found for an assignment row the administrator's read does not return). Control leg: the same file with this diff's guard removed (the ablation above, rebuilt, preflight marker present) fails the same case, 1 failed / 19. Not this diff's consequence; reported to the seat.Derived gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 92 families, each run with its exit code captured before any pipe. Three first refused with exit 3 (check:skill-examplesandcheck:dual-build-cjs-loadson unbuilt packages outside the diff;check:type-check-debtwhen its closure build could not take the verify lock); after building those packages and re-running under the lock, all three exit 0.--ranreconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).check-adr-0087-registration: one declared-breaking changeset,not-required (no-migration-prescription).