Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): make the RLS emptied-membership deny guard polarity-aware - #13570
Conversation
…rity-aware An emptied pre-resolved membership set under a supported `not in` (`$not` wrapping `$in: []`) inverted to a constant-TRUE clause and compiled to allow-all on the read scope instead of the deny sentinel. The guard now fires on odd-polarity emptied memberships anywhere in the compiled filter tree (direct `$not`, `$not` arms inside `$or`/`$and`, `$not` over composites, multi-level `$not`, multi-key implicit AND) and keeps the legacy positive single-policy case, generalised through double negation. Empty `$nin` (intrinsically constant TRUE) is recognised defensively. Non-empty `not in` and inert positive composites are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
📓 Docs Drift Check6 anchor(s) derived from 1 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 — 14 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 10353670aaac809e0e383f394ef281acd726fde5 && git checkout 10353670aaac809e0e383f394ef281acd726fde5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9972720f843033d24ec657f3d17b75435fca74a f7347eb7675479afbdd01f5c39e883151a360e96 && git checkout -B drift-repro b9972720f843033d24ec657f3d17b75435fca74a && git merge --no-ff f7347eb7675479afbdd01f5c39e883151a360e96
node scripts/docs-audit/affected-docs.mjs --json b9972720f843033d24ec657f3d17b75435fca74a |
Uh oh!
There was an error while loading. Please reload this page.
…ng instead of folding it to constant TRUE (#13571) An emptied exclusion folded to '1 = 1' — constant TRUE — which vacates the whole read scope: every row admitted, no $not needed, on the lowering where a wrong answer is ADR-0021 scope over-reach. It now throws in the module's one refusal envelope (READ_SCOPE_COMPILE_FAILED / 500), like the arity check one line above. Deliberately asymmetric (domain:services ruling, 2026-08-31): $in: [] keeps its ruled #5322/#5243 constant-FALSE fold. That fold is narrowing at its own arm and load-bearing — the RLS compiler deliberately emits an emptied positive membership inside composites (PR #13570's 'own rows keep flowing' pin), and that filter reaches this compiler through security.getReadFilter. A uniform throw was measured and rejected: it would 500 every analytics query for any user whose membership set resolves empty beside an own-rows grant. $nin: [] has zero producers (the CEL lowering never emits $nin; the #13570 guard drops even-polarity empty-$nin policies), so this refusal costs no live traffic. Includes a non-RLS getReadScope provider control (the spec contract filled by hand) pinning refusal post-fix, and the over-denial control pinning that the #13570 composite still compiles and still admits exactly the own row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Fixes#13552
What
isEmptyMembershipFilterinpackages/plugins/plugin-security/src/rls-compiler.tsexisted so a pre-resolved membership set that resolves EMPTY drops the policy and the single-policy path fails closed viaRLS_DENY_FILTER. It shape-matched the bare positive form ({ f: { $in: [] } }) only, whilenot inis a first-class pushdown shape (!(x in y)lowers to$notwrapping$in). Under$not, an empty$in: []inverts from constant FALSE to constant TRUE —NOT (1 = 0)on the SQL read-scope lowering — so the policy the guard exists to turn into a DENY compiled to ALLOW-ALL on reads (p1 fail-open, triage grading in issuecomment-5472270131).The guard is now polarity-aware. It keeps the same convention the platform already uses (drop the policy, deny sentinel upstream — the shape
tenant-layer.tshand-encodes for its Layer 0 empty sets); no second convention is invented.The enumeration (triage mandatory item 1)
The guard fires — policy dropped,
RLS_DENY_FILTERon the single-policy path — for an emptied membership at ODD effective polarity ANYWHERE in the compiled tree, and for the legacy solely-empty positive case:{ f: { $in: [] } }— pre-existing behaviour, preserved;{ $not: { f: { $in: [] } } }— was allow-all, measured 5 of 5 fixture rows;$notarm nested inside$or— the constant-TRUE arm made the whole$orallow-all (measured 5 of 5);$notarm nested inside$and— the membership restriction silently evaporated (measured 3 of 5 where deny was intended);$notover a composite containing the emptied membership ($andcase is constant TRUE by De Morgan, measured 5 of 5;$orcase reduces to the negation of the other arm — degenerate restriction, fail closed);$not, odd depth (triple — measured 5 of 5);$not, even depth, solely — constant FALSE; returns the sentinel instead of an always-false filter (same zero rows, one recognisable shape);$not— constant TRUE by De Morgan;$nin: [](intrinsically constant TRUE — the read-scope SQL lowering renders it1 = 1). Not emitted by cel-to-filter today; recognised so a future lowering cannot fail open through the same blind spot.Deliberately NOT firing, matching pre-fix behaviour: a NON-empty membership under
$not(the workingnot infeature, row-level pinned); an emptied POSITIVE membership nested in a composite ($orarm is inert —owner in empty-set || owner == mekeeps granting own rows;$andarm is already constant FALSE); literaltrue(deliberate allow-all, compiles to{}); even-$notemptied membership nested inside a composite (inert constant-FALSE arm).Before/after control (triage mandatory item 2)
Reverse-verified from the committed state, with the mutation and both restore legs proven on disk:
rls-compiler.tsto baseff37576(worktree only), proved the mutation landed (new-guard marker grep-count 0, old-docblock marker count 1), then ran a scratch harness asserting the BUGGY behaviour. It PASSED on base — measured: direct$notcompiles{"$not":{"owner":{"$in":[]}}}and admits 5 of 5 rows viamatchesFilterCondition;$or-nested 5/5;$and-nested 3/5; composite 5/5; triple-$not5/5; bare-positive control already denied (0/5, the half that was green).9360869c63f7554b34afcf015addf84ac35c053e,git statusclean; the same harness against the fixed HEAD then FAILS 5 of 6 (every negated pin now gets the deny sentinel; only the unchanged positive control passes). The harness was deleted; the committed suite (rls-empty-membership-polarity.test.ts, 19 tests) asserts the AFTER state including row-level zero-admission for every enumerated shape.No rebuild was needed for either leg: the suite imports
./rls-compiler.jsrelative source under vitest transform (no dist resolution for the mutated subject);@objectstack/formula(unmutated) resolves to its freshly built workspace dist.Blast radius (triage mandatory item 3)
Declared in the changeset (
.changeset/rls-empty-membership-polarity-guard.md): callers relying on the allow-all stop seeing rows — if a negated-membership policy was the only applicable policy and its set resolves empty, reads go from every row to zero rows. That prior behaviour was a defect, not a contract. Own-rows access that must survive an emptied set belongs in a separate OR'd policy (per-policy grants compile independently — pinned in the suite); deliberate allow-all remains authorable as literaltrue.PM mechanism assumptions, measured
StrategyContext.getReadScope, wired tosecurity.getReadFilter, i.e. this compiler's output, so post-fix the emptied-negated shape no longer reachesread-scope-sql.tsthrough the RLS path. The lowering site itself still carries the same polarity-dependent inference ($in: []folds to FALSE_CLAUSE with a bare "safe" comment;$nin: []folds to1 = 1), and the contract type is fillable by non-RLS providers — reported as an out-of-scope finding rather than absorbed (see issue linked from the report).tenant-layer.tsis the model — confirmed; it returns the spread deny sentinel on empty access sets. This fix reaches the identical sentinel through the existing drop-the-policy channel; no second convention.security-plugin.test.ts: "should fail-closed for IN when org_user_ids is empty", "should fail-closed when a §7.3.1 membership set is empty"), and the formula-level pin ("empty membership array still compiles to $in:[] (caller decides)") explicitly delegates the decision to this caller. Full plugin-security suite: 91 files / 1684 tests green.Verification (all at
f7347eb)pnpm --filter @objectstack/plugin-security test— 91 files, 1684 passed (includes the new 19).pnpm --filter @objectstack/plugin-security typecheck— green;--listFilesconfirms bothrls-compiler.tsand the new test file are inside the tsc programs (1 hit each).scripts/pm/dispatch-gates.mjsfrom this diff: 33 green (incl.check:engine-double-contract,check:where-matcher,check:type-check-debtre-measure with zero surplus,check:i18n,check:cross-package-test-inputs); 2 NOT MEASURED by design per their own exit-3 text (check-test-completenessgrades a saved CI turbo log;check-half-statesneeds a GitHub credential this container lacks).check:nul-bytesgreen. Repo-widepnpm lint(eslint, no-inline-config) exit 0.isSystemreads inrls-compiler.ts, zero census anchors on it.The
isEmptyMembershipFilterexport is for direct shape tests only;index.tsdeliberately does not re-export it, so the package surface is unchanged.Generated by Claude Code
Generated by Claude Code