Repository navigation
fix(plugin-security)!: the Layer 0 tenant write wall holds for the row a write stores (#20013) - #20043
Merged
objectstack-fleet[bot] merged 7 commits intoSep 24, 2026
Conversation
…(failing first) Measurement on the base, isolated posture, driver-sql and driver-sqlite-wasm: a beforeInsert/beforeUpdate hook that writes organization_id outside the caller's organization scope is admitted on a by-id update, an insert, an array insert and a predicate update, and the row is stored there. A supplied out-of-scope value is refused before next(), as the controls show. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
… stores Step 3.7 judged only an organization_id present in the payload as sent, before next() runs the engine's beforeInsert/beforeUpdate chain, so a value a hook wrote was never judged by the wall. The wall now also judges the stored image through OperationContext.postHookWriteImageCheck, installed whenever Layer 0 applies to the write (not only when a business check does), composed after step 3.6's check when both apply. The payload judgement stays, so the change only refuses more. The post-next() fail-closed guard covers the new installation, with one observed stand-down: a write the ADR-0094 permission-set data door executed itself, without the engine. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…ed writes The Layer 0 tenant wall now installs OperationContext.postHookWriteImageCheck on every walled insert and update, so the terminals that stand in for the engine run it as the engine does (an insert's rows as sent, the by-id row or the matched rows merged with the payload) instead of being refused by the fail-closed guard. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…seam under a walled posture Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…nant-wall-post-hook
… stored-row seam The merged-in explain-dependency-fault stack is walled, so the Layer 0 tenant wall installs the stored-row seam on its by-id update. Its terminal now runs it on the prior row merged with the payload, as the engine does, through the producer's dispatch predicate. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. 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): |
objectstack-fleet
Bot
deleted the
claude/issue-20013-tenant-wall-post-hook
branch
September 24, 2026 23:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #20013
Clause-②: no (narrowing)
What this fixes
Step 3.7 of the security middleware is the Layer 0 tenant write wall (ADR-0095 D1, ADR-0105 D5). Its contract,
packages/plugins/plugin-security/src/security-plugin.tslines 3239-3248 onorigin/main246314dffe:The wall judged
opCtx.data, the payload as the caller sent it, beforenext()runs the engine'sbeforeInsert/beforeUpdatechain. A value a hook wrote intoorganization_idwas never judged by the wall, so the row was stored in whatever organization the hook named. The engine records the insert twin of the same gap atpackages/objectql/src/engine.tslines 11846-11847 on246314dffe: 「The Layer 0 tenant wall still judges the PRE-hook image — filed separately, and the fix's host is this seam.」The wall now also judges the row the engine is about to store, through the seam the row-level
checkalready uses (OperationContext.postHookWriteImageCheck). The refusal is the wall's existing one:PERMISSION_DENIED/ 403, nothing stored. The judgement of the payload as sent stays, so this change only ever refuses more. No engine change.Mechanism hypotheses (dispatch Section 2), measured
Base:
origin/main26550c6603. RealSecurityPluginandObjectQL,isolatedposture, driver-sql (better-sqlite3) and driver-sqlite-wasm. The fixture is a tenant object whosebeforeInsertandbeforeUpdatehook copies another payload field intoorganization_id, and a caller whose active organization isorg_a.organization_idwas present inopCtx.data(thesuppliedRowsfilter), beforenext().organization_idorg_bPERMISSION_DENIED/ 403 ("the update would place 'qa_account' in another tenant")org_a, unchangedorg_bPERMISSION_DENIED/ 403org_borg_borg_borg_borg_bon the second roworg_a, secondorg_bmulti: true), hook writesorg_borg_borg_aorg_acheckapplies, measured: with no row-level policy on the object, an inner middleware sawpostHookWriteImageCheckabsent on every leg above. So the wall gets its own installation condition: a walled posture, a tenant object, a caller Layer 0 does not exempt (that is,computeWriteTenantCheckFilterreturns a filter). When step 3.6 also installed its judgement, the two are composed into the one handle the engine runs, in the order the middleware judges in (thecheck, then the wall).A second finding shaped the fail-closed guard. The post-
next()guard now covers the new installation. The ADR-0094 permission-set data door executes an insert or update ofsys_permission_setitself, through the metadata protocol, and never callsnext(). Measured on the base with a platform administrator (active organizationorg_a) underisolated: Layer 0 walls the object (organization_id = org_a), the insert is admitted, and the engine's write never runs. A guard-covered wall seam alone would turn that into a 403. No engine write runs there, so no hook chain runs, and the payload judgement has already cleared the onlyorganization_idsuch a write can carry. The guard therefore stands down for a seam that carries the wall alone on a write the door executed itself. The fact is observed by a wrapper around the door's registration, which records a write the door never passed tonext()in a plugin-privateWeakSet. No operation field is involved that another middleware could set. A seam carrying a row-levelcheckis not stood down: the data door keeps failing closed under one, exactly as before.ADR-0095 D1 "Not touched" records that D1 added no tenant post-image check to
computeWriteCheckFilter. Its reason is that such a check "would risk denying legitimate inserts before the auto-stamp runs". This change does not touchcomputeWriteCheckFilter, and it judges an image only when the image names an organization. An absent value (the auto-stamp's to fill) is never judged, and a control pins that. ADR-0105 D5 affirms the direction: an explicit value is validated against the membership set or equality.What changed
packages/plugins/plugin-security/src/security-plugin.tssingleposture pays for nothing new.denyTenantPlacement) serves both halves. The step installs the stored-row judgement whenever the wall applies (insert, or a non-array update, as step 3.6 scopes it), composed after step 3.6's judgement when one is installed.next()guard covers the new installation with the data-door stand-down above. Its developer message names what was not evaluated: the business-check sentence is unchanged byte for byte, and a wall-only seam gets its own sentence.packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts(new): 34 cells, 17 per driver..changeset/20013-tenant-wall-post-hook.md:@objectstack/plugin-securityminor, BREAKING, remedy,not-required (no-migration-prescription).Tests
New file, real
SecurityPluginandObjectQLon both SQL drivers,isolatedposture unless a cell says otherwise. Every refusal assertscodePERMISSION_DENIED,status403 and the wall's message ("the insert/update would place 'OBJECT' in another tenant"), then reads the table straight off the driver.checkinstalled and passing (one composed seam);check(an in-scope insert the check refuses is refused);groupposture: outside the membership set refused, inside admitted;org_a;privateobject is exempt;singleposture is unchanged;isolated: admitted, and the engine's write never ran.Failing first. The file was committed before the fix (
8afaec51a1). On the base it gave 14 red (the 7 negative cells that existed then, × 2) and 18 green. Every red read "expected a refusal, got a completed write".Ablations, each through
scripts/ablation-replace.mjsin WRAP mode, with the anchor hit 1 → 0 and a blob change reported by the tool. Each ran under an outertraprestoring fromHEAD, and was then proven restored (blob == HEAD278b25f19e, emptygit diff HEAD). They ran one43cda19b4, whose blobs forsecurity-plugin.tsand the pin file equal the final head's. The subject is imported relatively by the test file and@objectstack/objectqlis aliased tosrc/in this package'svitest.config.ts, so nodist/sits between a mutation and the run.group, × 2checkis (the old condition)group, fail-closed, × 2organization_idjudged toocheckA5 has an extra reading. Without the payload judgement, a supplied out-of-scope value is admitted but lands nowhere. The engine's static
readonlystrip drops a caller-sentorganization_id, which the registry injects asreadonly: true, while a hook-written value is exempt from that strip. That exemption is why the hook path reached the store, and why the payload refusal is the loud half of the supplied case.Suites (final head
cce969cf4d, after mergingorigin/main7766b62282, closure rebuilt):typecheck: exit 0; the test layer compiles all 131 test files (--listFiles), and its ledger holds 0 files / 0 errors.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsoncce969cf4dderived 62 families: the dispatch-time 49 plus 13 more. The 13 arecheck-adr-0087-registrationandcheck-empty-changeset(each with its self-test),release-rehearsal-clone --self-test,check:engine-double-contract,check:objectql-double-limit,check:objectui-changeset,check:pm-changeset-deadline-census,check:query-options-erasure,check:type-check-coverage,check:type-check-debtandcheck:where-matcher. All 62 ran, and--ranreconciled 62 derived / 62 run / 0 NOT-MEASURED, every line carrying its exit code, all 0.check:dual-build-cjs-loads,check:i18nandcheck:type-check-debtfirst answered PREREQUISITE NOT MET (exit 3) and were re-run green after a full workspace build. The board-probingGITHUB_TOKEN=… node scripts/check-issue-citations.mjspassed: 9 citations resolve.Narrowed lint:
eslint --no-inline-config --format jsonover the 13 touched.tsfiles reported 13 files, 0 errors, 0 warnings. It is a full measurement for these files for three reasons. The population is the**/*.{ts,…}andpackages/**blocks ofeslint.config.mjs. The count, 13, comes from the JSON output. The config never enables type-aware linting (noparserOptions.project, no typed rules), so this diff cannot move the verdict for any untouched file. The repo-widepnpm lintand the Dogfood Regression Gate are left to CI.Surface beyond the claim, with reasons
The new installation runs on every walled insert and update. So every plugin-security test harness that stands in for the engine with a terminal that skips the seam, under a walled posture, was refused by the fail-closed guard (48 red on the first full run). Each double now runs the seam the way the engine does: an insert's rows as sent (no hooks in a double), and on an update the by-id row or the matched rows merged with the payload, through the producer's dispatch predicate where the double dispatches updates.
authz-matrix-gate,can-write-object-admission,check-only-write-scope,controlled-by-parent-detail-write-authority,controlled-by-parent-master-widener,explain-write-verdict-inputs,no-active-organization-write-refusal,row-write-widener-composition,select-only-write-visibilityandtenant-layer0-verdict-on-operation.explain-dependency-fault, which arrived with the merge oforigin/main(PR fix(plugin-security): explain fails closed when a dependency it shares with enforcement throws #20030) and went red on the merged tree for the same reason.check:engine-double-contractis green, with no ledger change.grep -rln postHookWriteImageCheck packages --include=*.test.tsnames only plugin-auth'ssys-user-self-service-route.test.ts(PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012's). The hand-made SecurityPlugin hosts under a walled posture are runtime'sshare-links-enforcement-contextandstandalone-stack-seeder-declaration-copy. All pass unchanged (plugin-auth and runtime suites green), so none is touched.Behaviour that changes (all in the refusing direction)
isolatedorgroup, an insert, by-id update or predicate update whose hook chain leavesorganization_idoutside the caller's organization scope (or the delegator's, ADR-0090 D10) is refused, and nothing is stored.errorlog. The ADR-0094 data door is the stated exception, for a wall-only seam.Pending changesets
This change makes no sentence in a pending changeset false. None says the tenant wall is unchanged. The "admitted as before" and "judged exactly as before" sentences in
19950-rls-check-multi-row-writes.mdand19989-by-id-update-post-hook-check.mdare scoped to the row-levelcheck, whose judgement this change does not alter. So no deliberate correction was made, andcheck-empty-changesetis green.Acceptance notes
packages/objectql/src/engine.tslines 11846-11847 still read "The Layer 0 tenant wall still judges the PRE-hook image — filed separately". After this change that sentence is false: the wall judges the stored row through that same seam. The file was held by the engine lane at dispatch, and this PR does not touch it. Suggested replacement for the engine lane: "(The Layer 0 tenant wall judges this row too, through the same seam; plugin-security: the Layer 0 tenant write wall (step 3.7) judges only anorganization_idpresent in the payload as sent, so abeforeUpdatehook that writesorganization_iditself can store the row in another tenant #20013.)".driver.findover the composedwhere, with no row ceiling on that path. The row-levelcheckseam (security: acheck-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and nousingexists to scope it #19950) already imposes the same cost where acheckapplies. NOT MEASURED: bulk-update latency or memory.organization_idis not judged by either half. That mirrors step 3.7's "supplied (non-empty)" scope and ADR-0095 D1's reason. A hook that clears the column on an update therefore lands a row with no organization. It is not measured here, and no declared contract covers it.next(), and the stored row, in the engine. The first is what keeps this change refusing only more (A5).fe1579223acarries noClaude-Sessiontrailer; the other commits carry the model-free pair.Generated by Claude Code