Repository navigation
Commit 0d3ec47
Fixes #20212
Clause-②: no (narrowing)
## What changes
`RLSCompiler.compileFilter`
(`packages/plugins/plugin-security/src/rls-compiler.ts`) now runs the
two shared comparand faces, `assertListComparandShapes` and
`normalizeFilterComparandTypes` from `@objectstack/spec/data`, on every
compiled policy filter, for `using` and `check` alike, with or without a
field guard. A policy the faces refuse joins `deniedBy` with the new
drop reason `refused-comparand`. That is the per-request route a list
under `==` / `!=` (the type family) already takes. Alone, the clause
answers `RLS_DENY_FILTER` with 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 in `plugin-security` or in a driver. A thrown value without
the ADR-0112 envelope is re-thrown, not treated as a verdict.
`@objectstack/lint`: the `rls-predicate-unenforceable` message 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 `check` is evaluated
(H1, H2)
- Composing layer: `SecurityPlugin`'s engine middleware, step 3 "RLS
filter injection" (`computeLayeredRlsFilter`, then
`RLSCompiler.compileFilter(…, 'using')`, then `andComposeLayers`),
writes `opCtx.ast.where = { $and: [callerWhere, …] }`.
- Evaluating layer for `check`: the write gate, step 3.6:
`computeWriteCheckFilter`, then `RLSCompiler.compileFilter(…, 'check')`,
then `matchesFilterCondition(image, filter)` (`@objectstack/formula`),
in-process.
- The engine runs the faces only in `lowerWhereFilterArray`, on the
caller's `where`, before `executeWithMiddleware`. Nothing judges
`opCtx.ast.where` after the middleware composes it. Measured: at base
the null-family filter reached SqlDriver and was executed (P1 read
returned `r_none`), where any face would have thrown `INVALID_FILTER`.
`judgeFilter` runs the same stage 1 (`assertListComparandShapes`,
`assertFilterIsMaterializable`,
`assertTextOperatorTargetsAreStringCapable`,
`assertTemporalComparandsInterpretable`,
`normalizeFilterComparandTypes`, or the array branch's `parseFilterAST`)
and stage 2 (`resolveWhereFilterTokens`), on the caller's `where` only.
- Chosen seam: `RLSCompiler.compileFilter`, for both halves. Both
clauses compile there (read middleware, write gate and analytics'
`getReadFilter`), and the type family's `DENY (fail closed)` route
already lives there. See Deviations.
- The engine's composed-`where` seam gets no call. The other producers
that compose onto `ast.where` are 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's `filter-comparand-shape.ts` says
why injected filters are not judged there), and it would never fire for
any producer in this repository.
## Measured: base `805af4f290` against head (compiler blob `2438357788`)
The real `SecurityPlugin` + `ObjectQL` stack, caller `usr_member`, rows
`r_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 as `using` (find / findOne / count)
and once as `check` (insert `closed`, insert `open`, by-id update to
`closed`).
| predicate | `using` read at base (memory / sqlite / pg) | `check` at
base (closed / open / update) | head, every driver |
| --- | --- | --- | --- |
| P1 `!(record.status in ['open', null])` | `r_closed` / `r_none` /
`r_none`, no WARN | ok / 403 / ok | 0 rows + WARN; 403 / 403 / 403 +
WARN |
| P2 `record.status in ['open', null]` | `r_none r_open` / `r_open` /
`r_open` | 403 / ok / 403 | 0 rows + WARN; 403 / 403 / 403 + WARN |
| P3 `record.status > null` (`$gt: null`) | none / none / none, no WARN
| 403 / 403 / 403, no WARN | 0 rows + WARN; 403 / 403 / 403 + WARN |
| P4 at-most `null` (`$lte: null`) | `r_none` / none / none | 403 / 403
/ 403 | 0 rows + WARN; 403 / 403 / 403 + WARN |
| P5 `record.status in [null]` | `r_none` / none / none | 403 / 403 /
403 | 0 rows + WARN; 403 / 403 / 403 + WARN |
| control `record.status == current_user.positions` | 0 rows + WARN, all
three | 403 x3 + WARN | byte-identical to base |
| control `record.status != current_user.positions` | 0 rows + WARN, all
three | 403 x3 + WARN | byte-identical to base |
At base, P2 and P5 also admitted a NULL-status insert. At head that
insert is refused too.
- Read and check now agree on every row, and each null-family row
answers exactly as the type-family control does, WARN included (`reason:
'refused-comparand'` against the control's `unsupported`).
- Several policies, OR-combined (H3): P1 beside `record.status ==
'open'` reads `r_open` with no WARN and admits `open` / refuses
`closed`. That is exactly the control beside the same sibling. At base
the P1 pair read `r_closed r_open` (memory) or `r_none r_open` (SQL) and
admitted `closed`.
- The caller's own `where` (H3), measured with `$in: ['open', null]`,
`$not` of that, `$gt: null`, `$lte: null` and the array form:
`INVALID_FILTER` / 400, same message, byte-identical base to head.
- Null checks (H3): `record.status == null` and `!= null` are
byte-identical base to head.
- No collateral (H4): 243 cells per side (the three drivers). Every cell
whose policy carries no null-family comparand is byte-identical base to
head. That covers read rows, write answers, the RLS / Security WARN
lines, `getReadFilter` and `explain`. 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_ids` and its negation, `current_user.id` /
`.email`, an `||` and an `&&` composite, an emptied membership, an
undeclared column, an unresolved `current_user` key, and tenant-scoped
objects on SQLite and PostgreSQL (InMemoryDriver refuses multi-tenant
objects with `MEMORY_MULTI_TENANT_UNSUPPORTED` at 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 `2a0d7da0b1` unless stated)
- New:
`packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts`.
On the real stack, SqlDriver: the five shapes and the two controls, as
`using` and as `check`; the sibling position for each family; null-check
and list controls; the caller's own `where`. Also new:
`rls-compiled-comparand-faces.test.ts`, a unit test on `compileFilter`:
seven shapes x two clauses x with and without a guard, the sibling, two
refused policies, and 18 accepted predicates that compile to exactly
`compileCelToFilter`'s output with no WARN. 84 passed.
- `pnpm --filter @objectstack/plugin-security test`: 139 files, 2839
tests passed. `run typecheck`: exit 0, `check:test-typecheck` OK.
- `pnpm --filter @objectstack/lint test`: 109 files, 4232 passed (at
`f3eb1d06c4`). `run typecheck`: exit 0 (at `4d66d4d572`; its lint source
is unchanged since).
- `pnpm --filter @objectstack/driver-memory test`: 56 files, 1362 passed
(at `f3eb1d06c4`).
- `pnpm --filter @objectstack/objectql test`: 318 files, 5762 passed (at
`4d66d4d572`, before merging `origin/main`; this diff does not touch
objectql).
- `pnpm --filter @objectstack/driver-sql test` with
`OS_TEST_POSTGRES_URL` set to a private PostgreSQL 16 server (zone
`Asia/Shanghai`, process `TZ=America/New_York`): 196 files passed, 3
skipped, 3806 passed, 86 skipped (MySQL cells, not provisioned); live
postgres RAN (at `4d66d4d572`).
- Reverse verification at `2a0d7da0b1`: `scripts/ablation-replace.mjs`
replaced the one `judgeCompiledComparands(outcome.filter)` call with a
pass-through (anchor 1 to 0, blob `2438357788` to `e6a410b0b5`). 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 empty `git diff HEAD`.
The subject is imported from `src/` by relative path, so no `dist/` is
on the resolution path.
- `eslint --no-inline-config --format json` on the 5 changed TypeScript
files: 5 files, 0 errors, 0 warnings. All 5 are inside eslint's
population (`--print-config` resolves each). The config enables no
type-aware linting (`parserOptions.project` / `projectService` null), so
this diff cannot move any untouched file's verdict.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `2a0d7da0b1` derived 63 commands. `--ran`
reconciliation: 63 accounted, 60 run, 3 NOT MEASURED, 0 unrun. Every run
command exits 0 except `check-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-maps` and
`check:swallow-census-controls`. So do `check:authz-resolver`,
`check:error-code-casing` and `check:filter-alias-parity`, and `node
scripts/check-issue-citations.mjs --base 615c468` (6 citations
resolve).
- NOT MEASURED: `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-changeset`
stays red on both by design, which puts the decision in front of a
person. Please confirm.
- `.changeset/19951-rls-lint-per-request-refusals.md` said "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, and `check` writes are refused 403.
- `.changeset/20018-native-read-scope-faces.md` said 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
- The seam is `RLSCompiler.compileFilter`, not "the one engine seam" in
triage's words. The write check never passes the engine's composed
`where`, 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.
- The real-stack pin runs on SqlDriver only. Triage's execution note 1
names InMemoryDriver too, and it was measured (table above, base and
head), but a new test consumer of `@objectstack/driver-memory` is 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/lint` is off the claim's file surface. Its finding text read
false after this change, so it was corrected in the same PR.
## Acceptance notes
- Analytics (execution note 2; #20010's door is untouched): measured
with the three analytics faces (ObjectQL execute, the `/analytics/sql`
echo run on the database, NativeSQL execute). With the base read scope
of each null-family policy, all three answer `READ_SCOPE_COMPILE_FAILED`
/ 500. With the head read scope (`getReadFilter` now 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.
- `explain` read verdict: `rls: narrows` at base, `rls: denies` at head,
the same as the type-family control.
- driver-memory's "constructively unreachable through the compile face"
note in `memory-matcher.ts` is 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.ts` still
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](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent fdb2669 commit 0d3ec47
8 files changed
Lines changed: 570 additions & 27 deletions
File tree
- .changeset
- packages
- lint/src
- plugins/plugin-security/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
17 | | - | |
| 17 | + | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
43 | | - | |
44 | | - | |
| 43 | + | |
| 44 | + | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
Lines changed: 12 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
837 | 837 | | |
838 | 838 | | |
839 | 839 | | |
840 | | - | |
841 | | - | |
842 | | - | |
843 | | - | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
844 | 844 | | |
845 | 845 | | |
846 | 846 | | |
| |||
904 | 904 | | |
905 | 905 | | |
906 | 906 | | |
907 | | - | |
908 | | - | |
909 | | - | |
910 | | - | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
911 | 915 | | |
912 | 916 | | |
913 | 917 | | |
| |||
Lines changed: 14 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
815 | 815 | | |
816 | 816 | | |
817 | 817 | | |
818 | | - | |
819 | | - | |
820 | | - | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
821 | 821 | | |
822 | 822 | | |
823 | 823 | | |
| |||
891 | 891 | | |
892 | 892 | | |
893 | 893 | | |
894 | | - | |
895 | | - | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
896 | 899 | | |
897 | | - | |
898 | | - | |
899 | | - | |
900 | | - | |
901 | | - | |
902 | | - | |
903 | | - | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
904 | 905 | | |
905 | 906 | | |
906 | 907 | | |
| |||
1075 | 1076 | | |
1076 | 1077 | | |
1077 | 1078 | | |
1078 | | - | |
| 1079 | + | |
1079 | 1080 | | |
1080 | 1081 | | |
1081 | 1082 | | |
| |||
Lines changed: 160 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
0 commit comments