Repository navigation
fix(plugin-security)!: a row-level policy whose compiled filter carries a null list member or null ordering bound fails closed on both clauses, so its read and its write check agree (#20212) - #20243
Conversation
…d RLS policy filter RLSCompiler.compileFilter now judges each compiled policy filter with @objectstack/spec/data's assertListComparandShapes and normalizeFilterComparandTypes. A policy the faces refuse (a null list member, a null ordering bound) joins the per-request fail-closed route, so the using clause and the check clause refuse together. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
… RLS denial detail Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…sed on both clauses, SqlDriver and InMemoryDriver The five shapes of the card, as `using` and as `check`, through the real SecurityPlugin + ObjectQL stack on SqlDriver and InMemoryDriver, with the type family (a membership set under == / !=) as the control. A unit pin on RLSCompiler.compileFilter holds the drop, the WARN reason and the pass-through of every filter the faces accept. driver-memory joins plugin-security's devDependencies, aliased to source. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…d as the drop the runtime now makes The finding's message said the RLS layer does not run the shared comparand faces on its own filter, so the policy survives and analytics refuses every query. RLSCompiler now runs them, so the message gives the per-request drop and the clause's own fail-closed consequence, as it does for a type fault. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…rect two pending entries the change makes false The 19951 and 20018 entries said the RLS layer does not run the shared comparand faces on its own filter and that such a policy gets the withheld 500 on every analytics face. Both now read false: one clause each. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
…s-composed-filter-faces
…Driver; no new driver-memory test consumer A new test consumer of @objectstack/driver-memory is a ledgered, ruled decision (check:driver-memory-census), not one a test file makes. The refusal happens in the compile step, before any driver is asked, and the unit pin holds that step; the InMemoryDriver readings are recorded on the PR. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 18 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 9fd2d2ab03b163ca23946f9de3732a9901578d09 && git checkout 9fd2d2ab03b163ca23946f9de3732a9901578d09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c02fa12767eeee8ffde3ff7e48830c182df2118f 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1 && git checkout -B drift-repro c02fa12767eeee8ffde3ff7e48830c182df2118f && git merge --no-ff 2a0d7da0b1f5db3feb5d587fb73f7bc2297582f1
node scripts/docs-audit/affected-docs.mjs --json c02fa12767eeee8ffde3ff7e48830c182df2118f |
Contract reviewServed-tier: Scope: PR #20243 (card #20212, p2, ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20212
Clause-②: no (narrowing)
What changes
RLSCompiler.compileFilter(packages/plugins/plugin-security/src/rls-compiler.ts) now runs the two shared comparand faces,assertListComparandShapesandnormalizeFilterComparandTypesfrom@objectstack/spec/data, on every compiled policy filter, forusingandcheckalike, with or without a field guard. A policy the faces refuse joinsdeniedBywith the new drop reasonrefused-comparand. That is the per-request route a list under==/!=(the type family) already takes. Alone, the clause answersRLS_DENY_FILTERwith one[RLS] DENY (fail closed)WARN. Beside a policy that compiles, the refused policy drops out of the OR silently. The faces are called and their verdict is routed; no null rule is written inplugin-securityor in a driver. A thrown value without the ADR-0112 envelope is re-thrown, not treated as a verdict.@objectstack/lint: therls-predicate-unenforceablemessage for a null-family comparand said the RLS layer does not run the faces, so the policy survives and analytics refuses every query. This change made that false, so the message now gives the drop and the clause's own consequence, as it already does for a type fault.Seam: where the RLS filter is composed and where
checkis evaluated (H1, H2)SecurityPlugin's engine middleware, step 3 "RLS filter injection" (computeLayeredRlsFilter, thenRLSCompiler.compileFilter(…, 'using'), thenandComposeLayers), writesopCtx.ast.where = { $and: [callerWhere, …] }.check: the write gate, step 3.6:computeWriteCheckFilter, thenRLSCompiler.compileFilter(…, 'check'), thenmatchesFilterCondition(image, filter)(@objectstack/formula), in-process.lowerWhereFilterArray, on the caller'swhere, beforeexecuteWithMiddleware. Nothing judgesopCtx.ast.whereafter the middleware composes it. Measured: at base the null-family filter reached SqlDriver and was executed (P1 read returnedr_none), where any face would have thrownINVALID_FILTER.judgeFilterruns the same stage 1 (assertListComparandShapes,assertFilterIsMaterializable,assertTextOperatorTargetsAreStringCapable,assertTemporalComparandsInterpretable,normalizeFilterComparandTypes, or the array branch'sparseFilterAST) and stage 2 (resolveWhereFilterTokens), on the caller'swhereonly.RLSCompiler.compileFilter, for both halves. Both clauses compile there (read middleware, write gate and analytics'getReadFilter), and the type family'sDENY (fail closed)route already lives there. See Deviations.whereseam gets no call. The other producers that compose ontoast.whereare the Layer 0 tenant wall, controlled-by-parent (id lists), plugin-sharing (ids, owner, self scope), the attachment and comment access hooks, and the engine's own search filter. All are platform-produced; none carries an authored comparand. A call there could only answer a refusal as a 400 the caller cannot act on (the note in objectql'sfilter-comparand-shape.tssays why injected filters are not judged there), and it would never fire for any producer in this repository.Measured: base
805af4f290against head (compiler blob2438357788)The real
SecurityPlugin+ObjectQLstack, callerusr_member, rowsr_open/r_closed/r_none(status NULL), on InMemoryDriver, SqlDriver (better-sqlite3) and SqlDriver on a private PostgreSQL 16 server. Each predicate was run once asusing(find / findOne / count) and once ascheck(insertclosed, insertopen, by-id update toclosed).usingread at base (memory / sqlite / pg)checkat base (closed / open / update)!(record.status in ['open', null])r_closed/r_none/r_none, no WARNrecord.status in ['open', null]r_none r_open/r_open/r_openrecord.status > null($gt: null)null($lte: null)r_none/ none / nonerecord.status in [null]r_none/ none / nonerecord.status == current_user.positionsrecord.status != current_user.positionsAt base, P2 and P5 also admitted a NULL-status insert. At head that insert is refused too.
reason: 'refused-comparand'against the control'sunsupported).record.status == 'open'readsr_openwith no WARN and admitsopen/ refusesclosed. That is exactly the control beside the same sibling. At base the P1 pair readr_closed r_open(memory) orr_none r_open(SQL) and admittedclosed.where(H3), measured with$in: ['open', null],$notof that,$gt: null,$lte: nulland the array form:INVALID_FILTER/ 400, same message, byte-identical base to head.record.status == nulland!= nullare byte-identical base to head.getReadFilterandexplain. The spread:==,!=, lists without null and their negation, ordering (>,at-most, a two-sided range),== null/!= null,startsWith/endsWith/contains,in current_user.org_user_idsand its negation,current_user.id/.email, an||and an&&composite, an emptied membership, an undeclared column, an unresolvedcurrent_userkey, and tenant-scoped objects on SQLite and PostgreSQL (InMemoryDriver refuses multi-tenant objects withMEMORY_MULTI_TENANT_UNSUPPORTEDat base and head alike). The only differing cells are the null-family ones: the five, the three multi-policy rows carrying one, and one tenant-scoped null-family row.Tests (at
2a0d7da0b1unless stated)packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts. On the real stack, SqlDriver: the five shapes and the two controls, asusingand ascheck; the sibling position for each family; null-check and list controls; the caller's ownwhere. Also new:rls-compiled-comparand-faces.test.ts, a unit test oncompileFilter: seven shapes x two clauses x with and without a guard, the sibling, two refused policies, and 18 accepted predicates that compile to exactlycompileCelToFilter's output with no WARN. 84 passed.pnpm --filter @objectstack/plugin-security test: 139 files, 2839 tests passed.run typecheck: exit 0,check:test-typecheckOK.pnpm --filter @objectstack/lint test: 109 files, 4232 passed (atf3eb1d06c4).run typecheck: exit 0 (at4d66d4d572; its lint source is unchanged since).pnpm --filter @objectstack/driver-memory test: 56 files, 1362 passed (atf3eb1d06c4).pnpm --filter @objectstack/objectql test: 318 files, 5762 passed (at4d66d4d572, before mergingorigin/main; this diff does not touch objectql).pnpm --filter @objectstack/driver-sql testwithOS_TEST_POSTGRES_URLset to a private PostgreSQL 16 server (zoneAsia/Shanghai, processTZ=America/New_York): 196 files passed, 3 skipped, 3806 passed, 86 skipped (MySQL cells, not provisioned); live postgres RAN (at4d66d4d572).2a0d7da0b1:scripts/ablation-replace.mjsreplaced the onejudgeCompiledComparands(outcome.filter)call with a pass-through (anchor 1 to 0, blob2438357788toe6a410b0b5). The two new files went 42 failed, 42 passed out of 84: every null-family cell was red, and every control and accepted-predicate cell stayed green. The restore was verified by blob equal to HEAD and an emptygit diff HEAD. The subject is imported fromsrc/by relative path, so nodist/is on the resolution path.eslint --no-inline-config --format jsonon the 5 changed TypeScript files: 5 files, 0 errors, 0 warnings. All 5 are inside eslint's population (--print-configresolves each). The config enables no type-aware linting (parserOptions.project/projectServicenull), so this diff cannot move any untouched file's verdict.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat2a0d7da0b1derived 63 commands.--ranreconciliation: 63 accounted, 60 run, 3 NOT MEASURED, 0 unrun. Every run command exits 0 exceptcheck-empty-changeset --base origin/main(exit 1, by design; see Deliberate corrections). The six families the dispatch named that this diff no longer derives also exit 0:check-engine-split-ratio(both),check:dispatcher-error-vocabulary,check:durability-log-level,check:stack-collection-mapsandcheck:swallow-census-controls. So docheck:authz-resolver,check:error-code-casingandcheck:filter-alias-parity, andnode scripts/check-issue-citations.mjs --base 615c468746(6 citations resolve).check:dual-build-cjs-loads,check:i18n,check:type-check-debt. Reason: PREREQUISITE NOT MET (exit 3), each needs the whole-workspace build; CI builds it.Deliberate corrections of two pending changesets
This change made one sentence false in each of two pending release notes, and each gets a one-clause correction.
check-empty-changesetstays red on both by design, which puts the decision in front of a person. Please confirm..changeset/19951-rls-lint-per-request-refusals.mdsaid "The RLS layer does not run that check on its own filter, so the backend answers with its own semantics … Every analytics query over the object is refused." It now says the RLS compiler runs that check too and drops the policy: reads return no rows, andcheckwrites are refused 403..changeset/20018-native-read-scope-faces.mdsaid such a policy "now gets the withheld 500 on every analytics face". It now says the compiler drops the policy, so the analytics faces receive the deny sentinel and answer zero rows.Deviations
RLSCompiler.compileFilter, not "the one engine seam" in triage's words. The write check never passes the engine's composedwhere, so a call only there would have flipped the disagreement: the read would deny while the check admits. The compile step is the one place both clauses (and analytics' read scope) pass, and the type family's fail-closed route lives there. The dispatch's ruling section anticipated this choice.@objectstack/driver-memoryis a ledgered, maintainer-ruled decision (pnpm check:driver-memory-census: "Is this consumer legitimate? is a maintainer ruling"). A first version of the pin added the devDependency and the census went red, so it was reverted. The open question is in the report.packages/lintis off the claim's file surface. Its finding text read false after this change, so it was corrected in the same PR.Acceptance notes
whereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010's door is untouched): measured with the three analytics faces (ObjectQL execute, the/analytics/sqlecho run on the database, NativeSQL execute). With the base read scope of each null-family policy, all three answerREAD_SCOPE_COMPILE_FAILED/ 500. With the head read scope (getReadFilternow answers the deny sentinel), all three answer zero rows. So a null-family policy now answers the same on the data path and the analytics path. Beside a granting sibling, analytics moves from refusing the whole query to serving the sibling's rows, as it already did for the type family.explainread verdict:rls: narrowsat base,rls: deniesat head, the same as the type-family control.memory-matcher.tsis now true on the RLS path too. The head column shows no null-family filter reaches any driver. That file is not edited.packages/lint/src/validate-rls-predicate-enforceability.tsstill says the analytics read-scope compiler runs the faces on every RLS read scope. That stays true, but a null-family policy no longer produces such a scope. Noted, not edited.Generated by Claude Code