fix(plugin-security,service-analytics): a boolean comparand is judged by the spec verdict at the RLS compile seam and in the NativeSQL strategy - #21424
Conversation
…mparand verdict beside the number arm A compiled policy comparand against a declared boolean column is judged by `booleanComparandDoorVerdict` (`@objectstack/spec/data`), in the same walk as the number arm: an accepted spelling narrows copy-on-write, any other string is refused through the existing `refused-comparand` route. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…n-comparand verdict on every filter it compiles The caller's `where` (the dataset door's `runtimeFilter` arrives merged into it), each measure's own `filter` and the dataset's own scope are judged by `booleanComparandDoorVerdict` before they compile: an accepted spelling narrows copy-on-write, any other string is refused `INVALID_FILTER` / 400 in the `where` door's envelope. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… the RLS seam and the NativeSQL strategy Each measured cell answers the engine door's column, on SQLite and (where OS_TEST_POSTGRES_URL is set) PostgreSQL; the negation cell hides the excluded row; the compiled policy filter and the registered dataset's filters are deep-frozen, so the narrowing is held to copy-on-write. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ed-type comparand refusal; changeset for both packages Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Takes the spec's widened boolean-comparand verdict (non-string comparands refused), which both compilers here consume. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…her than 1 / 0) at both compilers Both compilers read the spec's verdict and carry no table of their own, so the widened refusal set arrives with the merge; the pins gain a cell for it, and the comments, the docs bullet and the changeset now describe the set by the verdict rather than as strings only. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…, as compileCelToFilter types its argument Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 21 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 7c67d2051cc1a54f1f42ba03078b22e22847f517 && git checkout 7c67d2051cc1a54f1f42ba03078b22e22847f517
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 2fd5e16a9738dafebd3c4768f0480c578313b159 && git checkout -B drift-repro 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 && git merge --no-ff 2fd5e16a9738dafebd3c4768f0480c578313b159
node scripts/docs-audit/affected-docs.mjs --json 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5
|
|
Red check triaged: not this PR's ·
Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ |
…et narrowing (minor, BREAKING), as the number twin at the same seam Clause-②: no (narrowing), with the ADR-0087 not-required (no-migration-prescription) disposition and the launch-window banner. No code change. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21376
Clause-②: no (narrowing)
Both compilers that build filters outside the engine's field-aware door now run the spec's boolean-comparand verdict (
booleanComparandDoorVerdict,@objectstack/spec/data). They answer what the engine door answers. Neither copies the verdict's table or its words.What changes
plugin-security,rls-compiler.ts).judgeCompiledComparands, in one walk, as the engine's walk does.narrowPolicyNumberComparandsbecamenarrowPolicyComparands. At a field key the number arm judges first; where it does not judge, the boolean arm may. The two classes are disjoint.narrowedFieldSpec), so they judge the same positions by construction. The boolean positions are the number door's by identity, as in the spec.booleanComparandFieldVerdict/booleanComparandDoorVerdictand words its refusal withbooleanComparandRefusalMessage.numbermap. That map records every declared column with itstype/returnType, which is the same slice the boolean verdict reads. Sosecurity-plugin.tsis untouched.refused-comparandroute. That is the envelope the number arm answers: the policy joinsdeniedBy, the read getsRLS_DENY_FILTER(zero rows), the write check answersPERMISSION_DENIED/ 403, and the WARN detail is rooted at the clause (using.flag.$ne).'true'/'false','1'/'0'and1/0.service-analytics,native-sql-strategy.ts).compileClausesruns the same verdict on every filter it compiles: the query'swhere, each measure's ownfilterand the dataset's own scope.runtimeFilterarrives merged into thewhere(DatasetExecutor.combineFilters).lowerAnalyticsWhere(the shared faces first, both spellings) and then walked. A member is judged at the columnresolveStorageTargetresolves it to, through the host'sdeclaredFieldTypehook. That is the same target the datetime lowering, the text-operator constant and$emptyalready ask.invalidFilterError, the analyticswheredoor's own envelope (INVALID_FILTER/ 400), before any statement runs.declaredFieldTypehook judges nothing, the tiering every such hook here takes.content/docs/permissions/rls.mdxsaid "Four ways a policy denies rather than leaks". It now names the declared-type comparand refusal as the fourth, for the boolean and the number classes.patchfor both packages,Clause-②: no.Measured before and after (the engine door is the target column)
Base
6d67ad5ec, headef3ea8700. Two servers: SQLite (SqlDriver, better-sqlite3) and a private PostgreSQL 16.14 started for this run.Position 1. The real
SecurityPluginmiddleware over a realObjectQL, as a member whose permission set has one policy withusingandcheckthe same predicate. Two rows:tstorestrue,fstoresfalse. "write" is an insert of atrue/falserow as the member.where, both)record.flag == truerecord.flag == 'true'record.flag != 'true'record.flag == 'yes'refused-comparandINVALID_FILTER/ 400record.flag == 1record.flag == '1'The negation was fail-open on both faces before: the read kept the excluded row on SQLite, and the write check admitted it on both dialects. On PostgreSQL
'yes'was read astrue.Position 2. Three faces were measured:
RestServer's ownPOST /api/v1/analytics/dataset/queryroute handler;AnalyticsService.query, which the runtime'sPOST /analytics/queryrelays verbatim;AnalyticsServicePlugin's composition, narrowed to the ObjectQL strategy, as the engine-door column.The base cells below use two rows (one
true, onefalse).runtimeFilter/where{ flag: true }{ flag: "true" }{ flag: { $ne: "true" } }{ flag: "yes" }INVALID_FILTER, no statement ranINVALID_FILTER{ flag: 1 }{ flag: "1" }The cube read showed the same cells, plus
"false",$inand$ninof strings, all aligned after the change.The raise-rule measurement (first)
git grepat6d67ad5ecoverexamples,packages(create-objectstacktemplates included) andskillsfound 0 RLS predicates comparing with'true'/'false'/'1'/'0', and 0 analyticswhere/filter/runtimeFilter/FilterArraycomparands spelling a boolean as text.using/checkpredicate compares an id, an email, an org or anull.== "yes"hit is a showcase action-visibility predicate on aradiofield. It is not a boolean, not RLS and not analytics..objectui-sha89cad75d5: NOT MEASURED. The objectui sibling is not checked out in this container.Copy-on-write
mainthe compiled policy filter is not shared across requests.compileCelToFilterkeeps no cache, andcompileFilteris called per read and per write check (security-plugin.ts, thelayer1compile and the write-check compile).@objectstack/formulamock deep-freezes every filtercompileCelToFilterreturns, so every cell would throw on an edit in place. One test also reads the frozen filters back and finds the string still there.filterand its measure'sfilter).Pins
packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts, 26 cases.OS_TEST_POSTGRES_URLis set and is a named skip otherwise.wheretwin's rows or envelope, the member's read, both writes, and the drop reasons.packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts, 68 cases.main,45efcfa3dhad widened the verdict to refuse a number other than 1 / 0, aDateand an array. Both compilers followed with no code change, because they hold no table of their own. The pins gained a2cell at each position.Ablations (each mutation landed and was restored on disk by
scripts/ablation-replace.mjs; blob equal toHEADandgit diff HEADempty after each)Run at
95bf8c4d0, before the merge and before the2cells were added. Both pins import their subject by relative path (./security-plugin.js,../plugin.js), so the ablated code issrc/, never adist/. No build leg or dist preflight applies.false &&before the RLS boolean field verdict)== truecontrol); the negation cell showsf, tagainnarrowsreturns the comparand)'1'/$eq '0', which SQLite affinity already answered)judgedBooleanComparandsreturns its input)'1'cells)Verification (at the head named)
dispatch-gates --commandsat78e4f3eb2derived 96 commands. All 96 exit 0.PREREQUISITE NOT MET(exit 3) and were rerun after building what they read:check:skill-examples(client and client-react),check:i18n(its turbo closure),check:dual-build-cjs-loads(the fullturbo run build), andcheck:type-check-debt, which overran a 9-minute timeout on the shared box and then finished.--ranreconciliation: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.ef3ea8700. The only later commit is a 2-line type fix in the RLS pin.check:type-check-coverage,check:type-check-debt,check:test-source-alias,check:cross-package-test-inputsandcheck:nul-byteswere rerun: all exit 0. The gate list is unchanged.78e4f3eb2.pnpm --filter @objectstack/plugin-security test: 161 files, 3513 passed, 45 skipped.pnpm --filter @objectstack/service-analytics test: 169 files, 3828 passed, 123 skipped.ef3ea8700.pnpm --filter @objectstack/plugin-security typecheck(test layer included) andpnpm --filter @objectstack/service-analytics typecheckboth pass. The first run found 2 TS2345 in the new pin, whichef3ea8700fixes.ef3ea8700.eslint --no-inline-config --format jsonread 4 files and found 0 errors and 0 warnings.--print-configshows noparserOptions.projectorprojectServicefor any of them.eslint.config.mjsstates it never enables type-aware linting, so this diff cannot move an untouched file's verdict..md/.mdxfiles are outside eslint'sfilesglobs. The repo-widepnpm lintis CI's.Acceptance notes
== 1write cell moves. A policyrecord.flag == 1used to refuse writing atruerow while its read showed that row. The write check compared the storedtruewith1. Narrowing1totrueis the spec's verdict and the engine door's answer, so the write check now agrees with the read. The control== truedoes not move on any face.RlsFieldGuard.numbernow feeds both arms. It always recorded every declared column. Renaming it to a class-neutral name would touchsecurity-plugin.ts, which is outside this card's surface. Noted, not filed.account.active) is judged at the column it resolves to in NativeSQL. The engine-door column for that case is NOT MEASURED here.formulareturning boolean isdeferredat NativeSQL. The host'sdeclaredFieldTypehook relays noreturnType, because the plugin retired that relay. The engine refuses a formula filter one door earlier anyway.AnalyticsService.query:{ amount: "abc" }answers 200 / 0,{ amount: { $lte: "9999-12-31" } }answers 200 / 2, and{ amount: true }answers 200 / 0;INVALID_FILTER/ 400 for each.Generated by Claude Code