fix(plugin-security): an RLS fail-closed denial logs its reason, not nothing - #13942
Conversation
…nothing `compileCelToFilter` computes a precise `detail` when it refuses — the `current_user.*` path that did not resolve, the index of the null member of a membership array — and `RLSCompiler.compileExpression` discarded it at `if (!result.ok) return null`, one line before the only site that could surface it. The warn beside that drop was gated on `!isSupportedRlsExpression`, a SHAPE-only test that answers "supported" for precisely those shapes, so the membership and no-active-organization refusals logged nothing at all. Carry the reason instead: `compileExpressionOutcome` returns the filter or the cause, `compileExpression` keeps its published `Record | null` signature, and the drop site emits one line per distinct cause when — and only when — the clause actually fails closed. The emptied-membership drop, which the compiler reports as a success and this file then refuses, joins the same vocabulary. Nothing about the decision moves: `RLS_DENY_FILTER` still lands, record attribution still excludes, zero rows still means zero rows. Part of #13639 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
📓 Docs Drift Check10 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 818a510e9524e8ec95f0bc812529ca44021da95f && git checkout 818a510e9524e8ec95f0bc812529ca44021da95f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd3ea164879ec20b472945bc78814e9d98aaa0b0 577e49e00266cf8b8f66082d6a38e9b763c79e64 && git checkout -B drift-repro dd3ea164879ec20b472945bc78814e9d98aaa0b0 && git merge --no-ff 577e49e00266cf8b8f66082d6a38e9b763c79e64
node scripts/docs-audit/affected-docs.mjs --json dd3ea164879ec20b472945bc78814e9d98aaa0b0 |
Part of #13639 — the non-forked half only. The sentinel question and any further
!okconsumers are measured and reported below, not delivered here.The defect
compileCelToFiltercomputes a precisedetailon every refusal — whichcurrent_user.*path did not resolve, which member of a membership array came back null and at what index, which parse bound was overrun.RLSCompiler.compileExpressionconsumed!okand threw the rest away atif (!result.ok) return null;, one line before the only site that could surface it. The drop-warn beside it is gated on!isSupportedRlsExpression(predicate)— a SHAPE-only test that answers "supported" for exactly the membership and equality shapes that refuse at runtime — so that branch logged nothing at all.The resulting failure shape is the worst-shaped one an operator can be handed: the user sees zero rows, no error is raised, nothing appears in the log. And the denial is deliberate — fail-closed working as designed — which is precisely why it needs a trace: a correct refusal is indistinguishable from "the data genuinely doesn't match", and the information that ends that search had already been computed.
The repair
compileExpressionOutcomereturns the filter or the cause;compileExpressiondelegates to it and keeps its publishedRecord | nullsignature unchanged. The drop site then splits by what actually happened:unsupported/parse-erroron a predicateisSupportedRlsExpressionrejects)DROPPED (no enforcement)warn, now also carrying the compiler's detailunresolved-variable)DENY (fail closed)line naming the variable path, the member index, the policy, the object, the clause, the predicate and the consequenceok;isEmptyMembershipFilterthen refuses)empty-membership— it names itself, since there is no compiler detail to carryTwo deliberate restrictions, both because this seam runs on read paths:
filters.length === 0fail-closed site, not per dropped policy.cel-to-filter.ts'swarnedOverLimitmemo — the immediately-upstream module in this same call chain — at the same bound (500) and with the same clear-on-overflow, held perRLSCompilerinstance (the plugin holds one long-lived compiler). No new mechanism was invented. Without it the "no active organization" denial, a persistent session state, would emit a line per query for as long as it lasts.Nothing about the decision moves
This is an observability change. Pinned in
rls-denial-observability.test.ts:RLS_DENY_FILTERstill lands with the same value, shape and singleidkey; the sentinel still excludes every record matched against it viamatchesFilterCondition(the same predicate testexplain's record-grained pass runs);compileExpressionstill returnsnullfor a refusal and the identical filter for a resolving predicate; a resolving membership still compiles to the same$in.Verification
All at
577e49e00, clean tree.pnpm --filter @objectstack/plugin-security test— 94 files, 1750 tests, 0 failedpnpm --filter @objectstack/plugin-security typecheck— exit 0.tsc --noEmit -p tsconfig.test.json --listFilesconfirms all three edited files are inside the program (src/rls-compiler.ts,src/rls-denial-observability.test.ts,src/security-plugin.test.ts) — the typecheck is not green over files nothing read.pnpm lint(eslint . --no-inline-config, whole repo) — exit 0.node scripts/pm/dispatch-gates.mjs(no path argument; it reads the change set itself). The model-tier path limb does not fire: "no path-derived mandate: the surface hits none of the 3 declared glob(s)". Green:check:cross-package-test-inputs,check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:test-source-alias,check:nul-bytes,check:logger-receiver-detach,check-keyed-text-bounds,check-undeclared-dep-imports,check:doc-authoring,check:i18n(after clearing its named build prerequisite),check:i18n-stale-fill,check:query-options-erasure,check:type-check-coverage,check:type-check-debt(incl.--re-measure: "29 ledger entries re-measured, 1531 raw tsc errors total, none above its recorded number"),check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:published-files,check:slot-lookup,check:page-declaration-shape,check:type-source-resolution,check:dual-build-cjs-loads(afterturbo run build),check:shard-attestation,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-plugin-teardown-shape,check-tenant-audit-census,check-cross-package-test-inputs,docs-audit/check-affected-docs,release-rehearsal-clone --self-test.check-test-completeness.mjsexits 3 with noturbo run testlog to parse ("the local reading for this gate is NOT MEASURED … it is not a red");scripts/pm/check-half-states.mjsexits 2 onHTTP 502from the GitHub API, an infrastructure fault unrelated to this diff.Ablation
Direction predicted before running: reverting the gate fix leaves the drop silent again, so exactly the pins that assert the new line should redden — 6 in
rls-denial-observability.test.tsand the 1 rewritten pin insecurity-plugin.test.ts— while every decision pin and every negative stays green.The repair was committed first, so the restore leg has a real reference point. The mutation replaced the one emission line with a marker; the mutation was confirmed on disk before anything was measured, never by an editor's exit code: blob hash
3c54ac68…(equal to theHEADblob) before,e0c6c524…after, removed-marker count 0, injected-marker count 1,git diff --statshowing 1 insertion / 1 deletion. The script carriedtrap … EXIT INT TERMwith an absoluteREPO_ROOTrestore, and the restore isgit checkout HEAD -- path— never the bare form, which restores from the index.No rebuild leg is claimed or needed: the suite imports the subject relatively (
./rls-compiler.js), which cannot resolve through a packageexportsmap todist/, so the mutated source is what ran. The measurement confirms it — the mutation reddened pins, which a stale-distablation cannot do.Result — 7 failed, 250 passed, exactly the predicted 7:
Restore proven by state, not by the trap firing: on-disk blob back to
3c54ac68…= theHEADblob,git diff HEADempty,git status --porcelainempty, marker counts back to 0 / 1. Re-run on the restored tree: 257 passed, 0 failed.Declared controls — green in BOTH directions, so they are controls and NOT ablation evidence: every pin in the DECISION is unchanged, plus a normal RESOLVING expression logs nothing, a shape that CORRECTLY logged before still logs — once, with its own message, a dropped policy that is NOT a denial stays silent, no applicable policy for the clause is NOT a denial, and a compiler with NO logger bound never throws. They hold the boundary of the change; they do not demonstrate it.
Out of scope — measured, not touched
1. The sentinel fork: are
__deny_all__and__rls_deny__deliberately distinct?explain'sisDenyAllwas NOT changed and the sentinels were NOT merged. One finding materially re-shapes the fork: the reportednarrowsverdict is not produced byisDenyAllat all.isDenyAll(explain-engine.ts:122) has exactly two call sites, both inside the record-grained augmentation (lines 728 and 897). The object-levelrlslayer verdict comes from a separate inline comparison at line 1312,filterParts.some((f) => f.id === '__deny_all__'), and that samedenyAllalso drivesdecision.allowed(line 1326) and replacesreadFilterwith{ id: '__deny_all__' }(line 1313). Teaching onlyisDenyAllabout__rls_deny__would therefore not fix the symptom the card names. The full measurement, the two production/consumption censuses and the recommendation are in the report comment on #13639.2. The other
!okconsumers — census, with a reverse controlThe filer marked this unmeasured. Every non-test call site of the compile-result family (
compileCelToFilter,isPushdownableCel,lowerCelAst), excludingdist/, comments and the defining module:reasondetailpackages/plugins/plugin-security/src/rls-compiler.tspackages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts:132(celToFilter)packages/formula/src/rls-predicate.ts:59(isSupportedRlsExpression)packages/lint/scripts/check-doc-formula-expressions.mjs:962packages/lint/src/validate-rls-predicate-enforceability.ts:283packages/lint/src/validate-sharing-rule-enforceability.ts:438Reverse control: the last two rows are the control that makes the zeroes readable — the same detector that reports
celToFilteras discarding finds two consumers that carrydetailinto user-facing text, so "discards" is a reading and not a blind spot.One consumer has the same shape as the repaired one and is NOT repaired here:
celToFilterin the sharing seeder collapses{ reason, detail }tonull, and its caller logs the fact without the reason. It is materially less severe (boot-time, not per-request; the seeder does log a line naming the rule and its condition, so the operator is not left with nothing). It sizes a follow-up card rather than widening this one.isSupportedRlsExpressionreturnsbooleanby contract and is not a defect.Generated by Claude Code