fix(plugin-security): stage RLS membership on the write check path so a membership-keyed check resolves on a bare insert (#16607) - #16722
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ouble ledger Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…s-check-membership-staging
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 29ed1a6156035486e50bfaf19fe091c7386e29d0 && git checkout 29ed1a6156035486e50bfaf19fe091c7386e29d0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin be92d468043c1b9e100e6e0f2c2fc9216a5aa850 f9adc5afa472622ed6593321d922d69526dd54b4 && git checkout -B drift-repro be92d468043c1b9e100e6e0f2c2fc9216a5aa850 && git merge --no-ff f9adc5afa472622ed6593321d922d69526dd54b4
node scripts/docs-audit/affected-docs.mjs --json be92d468043c1b9e100e6e0f2c2fc9216a5aa850
|
Fixes #16607
What
computeWriteCheckFilternow callsstageRlsMembership(context)immediately before it compiles thecheckclause — the same stagingcomputeLayeredRlsFilterperforms before Layer 1 compilesusing. One line of behaviour, plus the comment that says why it is load-bearing.The defect, reproduced before the fix
stageRlsMembershiphad exactly one call site, on the read-filter path. A bare insert performs no read, so an RLScheckclause that reads a membership-resolver key (record.employer_org in current_user.employer_org_ids, ADR-0105 D11) compiled against a context in which the key had never been staged: unresolved variable → policy dropped →RLS_DENY_FILTER→ refused with the check-gate envelope, on every bare insert, with or without the value the policy wanted. The two write shapes that did pass (by-id update, insert of acontrolled_by_parentchild) passed by accident of an earlier read on the same context object — the pre-image read at step 2.7 and the master read — having staged the key first.The new suite
packages/plugins/plugin-security/src/rls-check-membership-staging.test.tstranscribes the card's shape (a registered resolver publishingemployer_org_ids;using/checktwins withoperation: 'all') and drives the realSecurityPluginmiddleware over an in-memory engine that counts its reads. The repro is a bare insert on a fresh context: red onorigin/main(refused, check envelope), green with the fix (row lands, resolver consulted once, and the harness proves no governed object was read before the verdict — so the green cannot be the read-first accident).Why this is not a permissive degrade
The fix obtains the context the check should always have had; it relaxes nothing. Pinned on every leg the fix could have relaxed, all refused with the CHECK envelope (
PERMISSION_DENIED/ 403 /record_change_not_allowed/ "would violate a row-level CHECK") and the row never lands:The accidental paths, re-pinned without the accident
Per the triage boundary, the check is tested directly on a context nothing has read with:
computeWriteCheckFilteron a fresh context compiles to{ employer_org: { $in: [OWN_ORG] } }, equal to what theusingtwin compiles to on its own fresh context, with zero governed-object reads. The delegator leg (step 3.6 calls the same method withdelegatorContext) is pinned the same way on a distinct context — staged for that principal, once. The by-id update and thecontrolled_by_parentinsert are kept as middleware-level regression pins, each with the resolver consulted exactly once (staging is memoized per context, so the write-side call does not double-resolve).Reverse verification
Committed the fix first, then removed only the write-side
await this.stageRlsMembership(context);line (on-disk proof: injected marker count 1, remaining staging call sites 1 = the read-path site). Result: 6 of 13 red — the repro (expected { ok: false, … } to deeply equal { ok: true, message: 'written' }), the "write path consulted the resolver itself" pin (expected vi.fn() to be called 1 times, but got 0 times), the outside-the-set and throwing-resolver pins (refused for the wrong reason: the resolver was never called / the throw was never logged), the direct-method pin (RLS_DENY_FILTERinstead of the$infilter) and the delegator pin. The two read-first middleware shapes stayed green on the defect, exactly the accident the card describes. Restored withgit checkout HEAD -- path;git diff HEADempty, blob hash identical before and after (81b0e94…), marker count 0. The subject resolves fromsrc(./security-plugin.js), not through a dependencyexports, so no dist rebuild was part of either leg.Validation (all at
f9adc5afa, after mergingorigin/mainatbe92d4680— 8 incoming commits, 0 files in plugin-security source, 0 in spec, 0 in the lockfile)pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/rls-check-membership-staging.test.ts—Test Files 1 passed (1),Tests 13 passed (13)(VERDICT command-exit 0).pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2(whole package, pre-merge at706e6a5) —Test Files 102 passed (102),Tests 1902 passed (1902)(VERDICT command-exit 0).pnpm --filter @objectstack/plugin-security typecheck—VERDICT command-exit 0, 0error TSlines,check:test-typecheckself-test and package pass.pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-security^...' build—VERDICT command-exit 0.node scripts/pm/dispatch-gates.mjs --commandsfrom the real change set and reconciled with--ran:66 derived, 66 run, 0 UNRUN. Exit codes captured before any pipe. 63 exit 0 (includingcheck:nul-bytes,check:engine-double-contractafter the ledger learned the new file's three doubles via--write). Three are NOT MEASURED, not red:check:dual-build-cjs-loads,check:i18n,check:type-check-debteach exited 3 withPREREQUISITE NOT MET(the whole-repo built closure is absent in this worktree); CI builds that closure and owns those readings.pnpm lint(repo-wide eslint) is CI-owned and not run here.Clause-②: no — derived from the real export surface
Built
@objectstack/plugin-securitybefore and after and diffeddist/index.d.ts: 8 added lines, all 8 are JSDoc comment lines on the privatestageRlsMembershipmember, 0 removed, 0 declaration-shaped lines (positive control: the same declaration regex fires 8195 times on the built file).scripts/pm/check-widening-tells.mjs --declaration no --diffon the branch diff:3 changed file(s) read, no widening tell on any declared surface. No spec change, no new export, no schema key, no closed-set member; the accept set of the metadata contract is unchanged — an authoredcheckclause now evaluates as authored on the write path. Soneeds:contract-reviewis not hung on either carrier.Changeset
.changeset/rls-check-membership-staging.md—@objectstack/plugin-security: patch. Judged against AGENTS.md's "A bug fix in a released package takes apatchchangeset — never none, and neverskip-changeset": this is a bug fix in a released package (17.3.0), so it is a patch and the label is not applied.Tenancy pause (ADR-0131)
Nothing ADR-0131 ruled is touched: no change to organization ownership,
organization_idnullability, the Layer 0 wall, or any posture. The change is context assembly on the write path of Layer 1'scheckclause.Scope
Only #16607. #16608 (the insert-side
checkpost-image being the pre-hook payload) lives in the same file and is deliberately not addressed here; the harness has no hooks, so nothing in this suite pins that behaviour either way.验收备注
check-only-write-scope.test.ts(producer-assertingupdate/deletedoubles); the engine-double ledger gained three rows for the new file, which is the gate's own prescription (--write), not a debt.employer_org) stays refused after this fix by construction — the post-image lacks the field the policy tests — which is plugin-security: the insert-side RLScheckpost-image is the raw caller payload evaluated beforebeforeInsert, so a field a hook stamps can never satisfy an insert check — the caller must send the value the hook will overwrite anyway #16608's territory and untouched here.🤖 Generated with Claude Code
Generated by Claude Code