Skip to content

plugin-security: the UPDATE-side RLS check post-image is pre-image + change set evaluated before beforeUpdate, so a hook-stamped scoping field can move a row into an organization the caller does not hold #16790

Description

@os-trump

Filed unassigned and unlabelled for triage, by the domain:services dev seat delivering #16608. Category (a), a reproducible defect, with the repro below. It is the SIBLING of #16608 one verb over, and it survives #16608's fix.

What #16608 fixed, and what it did not

#16608 ruled (maintainer, verbatim 「同意」, 2026-09-07, director seat, summon #17, decision batch #3) that the INSERT-side write check is evaluated on the hook-mutated payload — the row that will be stored — "so insert and update judge the same thing". The ruling's reference point was the update path, which "already merges its pre-image".

Delivering that revealed that the update path merges its pre-image with the caller's change set, and then the beforeUpdate hooks run and can rewrite exactly the field the check just judged. So after #16608 the two verbs still do not judge the same thing: insert judges the row that will be stored, update judges the row the caller asked for.

The repro

Measured on origin/main @ 941232040 plus #16608's branch, on both driver-sql (better-sqlite3 :memory:) and driver-sqlite-wasm, with a real ObjectQL engine, a real SecurityPlugin, a real IRlsMembershipResolver publishing current_user.employer_org_ids, and the #16608 policy shape:

check: 'record.employer_org in current_user.employer_org_ids'

The app stamps the denormalised scoping field on BOTH write events, the way ats does — a runAs: 'system' handler that reads the parent OUTSIDE RLS and overwrites employer_org from it, whatever the caller sent.

Caller holds employer_org_ids: ['org_a']. The row mem_u starts inside that scope (employer: 'emp_a', employer_org: 'org_a'), so the using pre-image gate lets the caller touch it. The caller then repoints the parent at emp_b, which belongs to org_b, while sending an in-scope value for the checked field:

PATCH qa_employer_member/mem_u  { employer: 'emp_b', employer_org: 'org_a' }
measured
verdict ADMITTED (no refusal)
stamp ran, and read emp_b — recorded, not assumed
stored row { id: 'mem_u', employer: 'emp_b', employer_org: 'org_b' }

The row has been moved into an organization the caller does not hold. The check was satisfied by org_a, a value that never landed.

Why

In the write gate (packages/plugins/plugin-security/src/security-plugin.ts, step 3.6) the update post-image is built as { ...preImage, ...opCtx.data } inside the middleware — which runs BEFORE the engine's operation, and therefore before beforeUpdate. The merge closes the "a check on an unchanged field still sees its value" hole (#1994's twin); it does not close the "a hook rewrites the checked field after the merge" hole, which is #16608's sentence exactly: the check judges a value that never lands and ignores the one that does.

What it is NOT

Not #16344 (a readonly field stripped from persistence but still reaching beforeUpdate) — that is about which values a hook may READ. This is about which image the check is evaluated against. Not #14099 (one hook-mutated payload applied to every matched row on a multi: true update) — this reproduces on the by-id path, which is the only path the check gate post-image-validates at all.

Scope note

#16608's PR does NOT fix this and deliberately does not try. Its ruling names the insert side; extending the evaluation point to the update verb is a second accept/refuse change on a verb the ruling treated as the correct reference, so it wants its own decision rather than riding in as a bounded in-place fix. The mechanism #16608 introduces is reusable for it: the engine seam OperationContext.postHookWriteImageCheck, which insert() calls once the beforeInsert chain has produced the row. The update path would need the same call site after beforeUpdate, with the middleware's already-read caller pre-image captured in the closure and merged against the post-hook change set. The check gate skips predicate (multi: true) updates today and would continue to; only the by-id path is in question.

Whoever picks this up should note the priority argument from #16608: a check that admits a row whose stored scope the caller does not hold is a cross-organization write, and #16608 was regraded to priority:p1 on exactly that measurement.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions