Repository navigation
fix(lint): RLS predicate enforceability reports the per-request refusals the shape check cannot see - #20210
Conversation
…d lowered comparands the shared faces refuse Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
… comparands Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 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 bd245620a6809a5a468dd693c3d761440d790173 && git checkout bd245620a6809a5a468dd693c3d761440d790173
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 77060e6d067b0b114ee17b07079f7fe388592900 && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 77060e6d067b0b114ee17b07079f7fe388592900
node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #19951
Clause-②: no
validateRlsPredicateEnforceabilityjudged an RLS predicate's shape with everycurrent_uservalue replaced by a placeholder, so a predicate the runtime refuses because of a value passedos validatecleanly and enforced nothing. The rule's reference pass now reports two such classes asrls-predicate-unenforceable(error), each with a pasteable rewrite that uses the predicate's own field and key:current_uservalue of the wrong type for its position. Examples: a membership set under==/!=or handed to a string method, a one-value key on the right ofin, the barecurrent_userobject underinor a string method. The compiler refuses these on every request andRLSCompilerdrops the policy.nulllist member or anullordering bound. These compile, and the RLS layer never runs the faces on its own filter, so the backend answers with semantics the platform left undefined.Only
packages/lint/src/validate-rls-predicate-enforceability.ts, its test and one changeset change. No runtime source is touched (formula lowering,RLSCompilerand the shared comparand faces are unchanged).Shape table (Zone 2, item 1)
Lint readings come from a stack that declares the fields. Runtime readings come from a throwaway probe (never committed) built on the
rls-list-literal-comparand-fails-closed.test.tsharness: realSecurityPlugin+ ObjectQL +SqlDriveron better-sqlite3, callerusr_member, rowsr_open(status open) andr_closed(status closed, reviewer usr_member), andcheckinserts of oneopenand oneclosedrow. "main" is560b724c; "this PR" is77060e6d.usingread (runtime)checkwrite (runtime)record.reviewer_id != current_user.org_user_idsDENY (fail closed)WARN!(record.reviewer_id == current_user.org_user_ids)DENY (fail closed)record.reviewer_id == current_user.positionsDENY (fail closed)record.reviewer_id in current_user.idDENY (fail closed)record.reviewer_id in current_userDENY (fail closed)record.reviewer_id.startsWith(current_user)DENY (fail closed)record.reviewer_id.contains(current_user)DENY (fail closed)record.reviewer_id.startsWith(current_user.org_user_ids)DENY (fail closed)record.status in ['open', null]r_open(acts asin ['open']), no WARNrecord.status in [null]!(record.status in ['open', null])r_closedhidden), no WARNrecord.status > nullrecord.status <= nullrecord.reviewer_id != current_useruncompilable predicateWARNrecord.status != ['closed', 'archived']uncompilable predicateWARNrecord.reviewer_id == current_user.idr_closed!(record.reviewer_id in current_user.org_user_ids)r_opencurrent_user.email == 'member@example.com'current_user.email == 'someone@else.com'DENY (fail closed)Reading of Zone 2's assumption 1:
usingread and thecheckevaluator disagree on!(record.status in ['open', null]). The shared faces refuse the same comparands wherever those faces run: the engine seam on every caller-supplied filter, andassertReadScopeComparandsRunnablein analytics (read from code,service-analytics/src/read-scope-sql.ts; not re-measured here). The data-path gap is in the Acceptance notes and the report, not addressed here.f != current_userwas already moved by fix(formula): refuse == / != against the bare current_user root at the CEL lowering #20189 and stays reported by the shape branch.The discriminator (Zone 2, item 2)
Type family: a one-reference type swap must make the same compile lower (
attributeTypeFault). When the reference pass's probe compile is refusedunsupported, the rule rebinds ONE reference to a value of the other runtime type and compiles again. The candidates are each kernel key (scalar to list, or list to scalar) and the bare root (as a list that still carries the keys, then as one string). If that single swap makes the predicate lower, and the swapped value lands in a lowered field position, the refusal came from that reference's type. The type is not the probe's to choose:ExecutionContextSchemadeclares it for the kernel keys (read viakernelKeyProbe), and the root is always the whole context object. So the runtime refuses the same position on every request.A refusal no single swap cures reports nothing. That is the probe-artefact class. A constant comparison with no field, such as
current_user.email == 'ops@acme.com', folds on the value: it lowers to "no restriction" for the caller it names and is refused for everyone else. The swap value begins with the probe string and never equals it, so ordering folds and equality folds stay refused under every swap. The pinned control assertscompileCelToFilterreally answers{ ok: true, filter: {} }for the named caller andunsupportedfor another, then asserts the lint stays silent on both clauses, alone and inside|| record.owner_id == current_user.id.Null family: the verdict of the same two faces analytics runs (
sharedFaceRefusal):assertListComparandShapes+normalizeFilterComparandTypesfrom@objectstack/spec/data, run on the probe-compiled filter and graded by the refusal'scode(INVALID_FILTER), never its prose. It is not a probe artefact, because the probe binds only strings and string lists, which both faces accept by construction; a probe carrying a nested record is not judged. The face-probe controlrecord.assigned_to_id in current_user.org_user_idsstays clean.Conservative by construction, and recorded in the docblock: a predicate with two type faults (no single swap cures it) stays silent.
New author-facing text (quoted verbatim;
KEYstands where the shipped string spells an angle-bracketed placeholder)Type-family message:
RLS CLAUSE `SOURCE` passes the shape check, but the compiler refuses it for the value `VARIABLE` holds on every request (COMPILER_DETAIL). HOLDING_SENTENCE+ the existing clause consequence with a new preamble:Holding sentences:
Hint leads, one per holding:
Each hint lead is followed by
In this predicate:and the rewrites, for example:replace `record.reviewer_id != current_user.org_user_ids` with `!(record.reviewer_id in current_user.org_user_ids)`replace `record.reviewer_id == current_user.org_user_ids` with `record.reviewer_id in current_user.org_user_ids`replace `record.reviewer_id in current_user.id` with `record.reviewer_id == current_user.id` (and `!(record.reviewer_id in current_user.id)` with `record.reviewer_id != current_user.id`); for a set, name a membership key: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions`replace `record.reviewer_id in current_user` with the key that holds the set: `record.reviewer_id in current_user.accessible_org_ids` / … `org_user_ids` / … `positions`, or an app-staged §7.3.1 set `record.reviewer_id in current_user.KEY`replace `record.reviewer_id.startsWith(current_user)` with the key that holds the string: `record.reviewer_id.startsWith(current_user.email)` / … `(current_user.id)` / … `(current_user.organization_id)`The key lists are derived from the declared types (
kernelKeysHolding) and are not transcribed.Null-family message:
RLS CLAUSE `SOURCE` lowers, but to a comparand the platform's shared filter check refuses (FACE_FIRST_SENTENCE).+ this consequence:Null-family hint:
Its other rewrites are
replace `record.status in [null]` with `record.status == null`andreplace `record.status > null` with `record.status != null` (has a value) or `record.status == null` (has none), or compare against a real bound. Every rewrite is a local replacement, so it stays correct under any enclosing!,&&or||. If the face refuses something the locator cannot place, the hint falls back to the existing lowerable-subset sentence.The existing
consequence(clause)text for uncompilable predicates is byte-identical; it only became parameterised.Pins
accepts every kernel-resolved key in BOTH positionsasserted[]forowner_id == current_user.KEYandowner_id in current_user.KEYfor every kernel key, which wrote the blind spot down as a pin. It is nownever reports a kernel-resolved key as UNKNOWN — in its own position it is clean, in the other it is a type fault. It asserts, per key and by its runtime type (the table hoisted toKERNEL_KEY_RUNTIME_TYPE),[]in the key's own position and exactly[rls-predicate-unenforceable]in the other, and neverrls-predicate-unknown-user-variable.packages/lint, no test asserts anrls-predicate-*id. The one hit is prose inpackages/qa/dogfood/test/authz-conformance.matrix.ts. The shape-branch pins (literal list, bare root,size(), SQLAND, over-budget), the verdict-parity corpus and the disjointness test are unchanged and green. A genuinely illegal shape keeps its refusal verbatim.the refusals the SHAPE check cannot see are reported (#19951), table-driven over both clauses:isSupportedRlsExpression(source) === true(why it was silent), then exactly[[rls-predicate-unenforceable, permissions[0].rowLevelSecurity[0].CLAUSE]], and that the hint contains the pasteable rewrite.RLS_DENY_FILTERonusingandPermissionDeniedErroroncheck, versus "NOT dropped".runAuthoringRules('validate', …).Verification (at
77060e6d, the merge oforigin/main0bd11261)pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 --reporter=verbose src/validate-rls-predicate-enforceability.test.ts:Tests 120 passed (120).pnpm --filter @objectstack/lint test:Test Files 108 passed (108),Tests 4204 passed (4204). This is the whole package, the rule's only home.pnpm --filter @objectstack/lint typecheck: exit 0.tsc --noEmit, thencheck:test-typecheckOKagainsttsconfig.test.json.--listFilesincludes the test file, and it carries 0 type errors; the 6 ledgered test-layer errors live in two other files.pnpm --filter @objectstack/lint build: exit 0.node scripts/ablation-replace.mjsin WRAP mode, with the anchor hit once (1 to 0), a new blob, and the restore proven (blob == HEAD8589ec8c,git diff HEADempty). The subject is imported fromsrc, so nodiststep applies.attributeTypeFault(...)replaced by?? FAKE_FAULT(every refusal reported). Result: 2 failed / 118. Red: the probe-artefact control, and§7.3.1 … a key used in BOTH positions takes the membership answer. That is the discriminator is load-bearing.sharedFaceRefusalmade to returnnull. Result: 8 failed / 112, the 7 null rows plus "NOT reported as a drop".sites.length > 0becomes< 0). Result: 12 failed / 108: the 8 type rows, the flipped kernel-key pin, the message test, the every-site test, and theos validatereach.git show 560b724c:…, blobcb850a12, as a throwaway module) and this PR's rule were run in one process over everyusing/checkliteral. Outside tests: 126 predicates, 77 of themcurrent_user(the note's control of 77 reproduced), 0 changed. Including every test fixture: 327 predicates, 205current_user, 0 changed. The 4 template-literal predicates the extraction skipped (${ownerField} == current_user.idand similar) and the one JSON fixture are all type-correct. No in-repo producer is newly flagged.assertListComparandShapes(packages/spec/src/data/filter-comparand-shape.ts) as it stands onmain. [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 stage 2b edits that file's$ne/ array arm. The lint only ever hands it filters the compiler already accepted, and the compiler refuses==/!=against a list, so a$nearray never reaches the face from here. Measured onmain; not waited on.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived at77060e6d(59 commands, a superset of the lead's 52 once the changeset exists), all run. Reconciled with--ran:59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN; all 57 exited 0. NOT MEASURED:pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt. Both exit 3, PREREQUISITE NOT MET: they need every package'sdist/(a whole-repo build), which is CI's.cli,mcp,metadata-protocol,platform-objects,cloud-connection): not run locally and declared to CI. The public surface is byte-identical: no export, no wire shape. The census above measures every in-repo predicate, fixtures included, at 0 flips, and no suite outsidepackages/lintasserts anrls-predicate-*id.owner == current_user.emailand similar) are inside the 0-flip census.Choices settled here (four axes)
!(record.status in ['open', null])hides everyclosedrow on read while its owncheckadmits the write, and an AI writing a blocklist "neither closed nor missing" lands exactly here.rls-predicate-unenforceable), not a new one. Zone 1's direction names the id for the compile-refusal family, and the null family's fix is the same kind of edit (rewrite inside the enforceable subset). An allowlist keyed on the id already means "this predicate enforces nothing", and a new public id would widen@objectstack/lint's exported surface for no new decision. The message says which class it is.userVariableIsScalarPositionedalready asks it, and never by grading prose.Acceptance notes
nulllist member ornullordering bound in a policy reaches the driver and thecheckevaluator, which disagree.!(record.status in ['open', null])asusingreads 0 rows (theclosedrow is hidden). Ascheck, it admits aclosedinsert and refuses anopenone.record.status in ['open', null]readsr_openonly.record.status > nullreads 0 rows and refuses both inserts.driver-memory/src/memory-matcher.ts), and they are reachable through RLS.packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.tssays "The authoring lint reports a list literal as rls-predicate-unenforceable; a membership set holds its value only per request, so that form is refused at request time." That is still true, but no longer complete: the lint now reports the membership-set form too. Carrier: none.record.f > current_user.positions) lowers to$gtover a list. Neither the compiler nor the faces refuse it, and its runtime answer was not exercised here. Carrier: none.userVariableIsScalarPositioned's existing behaviour.Generated by Claude Code