Repository navigation
fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one - #29845
Conversation
… row answers the same for a hidden row and a missing one The ADR-0090 D12 administration gate answers the by-id update or delete of a caller whose read of the assignment object is refused with that read refusal; a caller who reads the object keeps the gate's own sentence. Clause-②: no Metadata-protocol: none Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
…covers Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 cc845f936ca2f27d685a7069c7f731d25d155d1c && git checkout cc845f936ca2f27d685a7069c7f731d25d155d1c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 7e516c3dcdf34536b4aa39aa1e25b3b1e604a06f && git checkout -B drift-repro 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209 && git merge --no-ff 7e516c3dcdf34536b4aa39aa1e25b3b1e604a06f
node scripts/docs-audit/affected-docs.mjs --json 42ff86f67723f5cd6b275bc4ddbb10f05e8c4209
|
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 7e516c3 · 2026-11-03T07:17Z |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Contract reviewServed-tier: Inputs: the PR body, its five-file list, the net diff against ① Derived judgmentsThe change is one What answers change, by caller class, for a by-id update or delete on a governed object:
② Semver level
③ Boundary flags
④ Design judgment
No must-fix. Two rule-level observations are recorded in ③ (the prose naming two of three governed objects; the two unpinned classes), neither of which changes what this head enforces. Implemented-by: VERDICT: PASS |
|
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj 席位核对ACCEPT · head
|
Clause-②: no
Metadata-protocol: none
维护者速读
sys_user_permission_set、sys_user_position)上的 ADR-0090 D12 管理门,拒绝"按 id 更新/删除"时,若调用者连这个对象都读不了,改为回答该调用者读门的拒绝(与他按 id 读这行得到的答复完全相同);读得到这个对象的调用者(含组织管理员)仍看到 D12 原句,平台管理员照常写入。packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts(readDoorAnswerForGateRefusal与 D12 调用点的 try/catch);packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts(文件末尾新 describe);packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts(带令牌整答复比较 + 组织管理员对照)pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts,预期 23 个用例全绿。What changes
The rule: a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one — the same status, code and sentence, with a version token (
If-Match,expectedVersion) and without one.security-plugin-authz-middleware.ts— the ADR-0090 D12 delegated-administration gate refuses a write to the permission-assignment objects on the caller and the object alone, before any grant is read, in words written for the write. Its call is now wrapped: when it refuses (a declared 4xx) the by-id update or delete the caller addressed (addressedByIdWriteId— not a nested write, not the referential clear, routed by id by the engine; never the record field-access answer's synthetic update, whose verdict is admitted or refused alone), the read door's own question is asked of that id (getCallerPreImage, the caller's by-id read through the engine — no second evaluator):readRowByIdswallows engine failures intonull, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505).content/docs/protocol/kernel/http-protocol.mdx, the update refusal section: one sentence saying the permission-assignment writes answer a caller refused reading the object with that read refusal, and a caller who reads it with the administration refusal.@objectstack/plugin-securitypatch.Caller classes (by-id update/delete on an assignment object):
Clause-② is
no, patch — a refusal sentence swapped for another for one caller class (as #28615). Every changed answer was and stays403 PERMISSION_DENIED; nothing refused is admitted and nothing admitted is refused, so no accept set narrows. Metadata-protocol: none.Tests
by-id-write-unreadable-not-found.test.ts, a new top-level describe in the file that pins "a row the caller cannot read answers what a nonexistent id answers", with its own rig: both assignment objects, a realObjectQLover SQL, the realSecurityPlugin). Per object × verb, for a caller with no grant and one with write-without-read: the caller's by-id read is refused (precondition), and the write of the row and of an id naming none both equal that refusal — code, status, message and name; the row is untouched. Controls: a caller who reads the object keeps the gate's sentence for the row and for no row; a tenant-level administrator updates a row and gets404 RECORD_NOT_FOUNDfor no row; an insert keeps the gate's sentence.update-by-id-refusal-order.dogfood.test.ts): the stale-token families now compare status, code and sentence for every family, the two objects the member may neither read nor write included (they compared status and code only). New control: an organization administrator, who readssys_user_permission_set, keeps the gate's "never edited in place" sentence onPATCHfor a row and for no row.Acceptance notes
Verification
Head
7e516c3dcdon merge baseb3d141505(origin/main); the builds, suites, pins, ablation and dogfood battery ran at04e42a7b06, and7e516c3dcdchanges only the changeset's title line (git diff --stat 04e42a7b06 7e516c3dcd:.changeset/write-refusal-read-door-parity.md | 2 +-). The gate union ran at7e516c3dcd. Exit codes captured before any pipe. Locked runs throughscripts/pm/os-verify-lock.shon a shared box.Before the fix (main at b3d1415): the plugin pin is red
pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.tswith the new describe and the unfixed middleware:Tests 8 failed | 15 passed (23)— the 8 failures are exactly the no-read caller cases; the reader, administrator and insert controls pass.Build, plugin suite, typecheck, pins
pnpm --filter @objectstack/plugin-security build→ exit 0pnpm --filter @objectstack/plugin-security test→ exit 0 ·Test Files 241 passed (241)·Tests 5220 passed | 45 skipped (5265)pnpm --filter @objectstack/plugin-security typecheck→ exit 0pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/update-by-id-refusal-order.dogfood.test.ts→ exit 0 ·Tests 19 passed (19)Ablation (one shot, dist-mediated)
node scripts/ablation-replace.mjsonsecurity-plugin-authz-middleware.ts, anchorif (addressedId === undefined) return refusal;(hits 1 → 0, blob5b65837cde22→20c73d8e1e31) replaced by a comparison against the planted literalABLATION_SECF_MARK, so the helper always keeps the gate's refusal:ablation-dist-preflight.mjs @objectstack/plugin-security 'ABLATION_SECF_MARK'→ exit 0 (marker in dist)Tests 8 failed | 15 passed (23)(the 8 no-read cases)Tests 1 failed | 18 passed (19)(the stale-token test)5b65837cde22== HEAD,git diff HEADempty (0 bytes); rebuild → exit 0;--absentpreflight → exit 0Showcase dogfood battery (final head)
pnpm --filter @objectstack/dogfood test(no shard, no path), under the lock:os-verify-lock: VERDICT command-exit 0·Test Files 404 passed | 3 skipped (407)·Tests 3327 passed | 28 skipped (3355)· no failures.Derived gates (`dispatch-gates.mjs --commands`, 90 families)
All 90 run at
7e516c3dcd, each exit captured before any pipe: 90 × exit 0. Two (pnpm --filter @objectstack/spec run check:skill-examples,pnpm check:dual-build-cjs-loads) first refused withPREREQUISITE NOT MET(exit 3: seven packages outside the dogfood closure had nodist/); after building those seven under the lock (turbo,44 cached, 44 total) both re-ran → exit 0 (283 prose examples type-check across 3 surface(s);115 published require entry point(s) across 70 package(s) load).pnpm check:type-check-debt→ exit 0 (0 ledger entr(ies) re-measured … none above its recorded number).Reconciliation:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD→90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN.Not run locally, owned by CI:
test:repo(the vitestrepoproject; no file of it is touched) and the repo-wide lint scan;eslint --no-inline-configover the three changed TypeScript files → exit 0.Generated by Claude Code