Commit 8b123c0
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)
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
- **Position 1: the RLS compile seam (`plugin-security`,
`rls-compiler.ts`).**
- The boolean arm sits beside the number arm in
`judgeCompiledComparands`, in one walk, as the engine's walk does.
- `narrowPolicyNumberComparands` became `narrowPolicyComparands`. At a
field key the number arm judges first; where it does not judge, the
boolean arm may. The two classes are disjoint.
- Both arms share one field-spec walker (`narrowedFieldSpec`), so they
judge the same positions by construction. The boolean positions are the
number door's by identity, as in the spec.
- The boolean arm reads `booleanComparandFieldVerdict` /
`booleanComparandDoorVerdict` and words its refusal with
`booleanComparandRefusalMessage`.
- It reads the field metas from the guard's existing `number` map. That
map records every declared column with its `type` / `returnType`, which
is the same slice the boolean verdict reads. So `security-plugin.ts` is
untouched.
- A refusal leaves through the existing `refused-comparand` route. That
is the envelope the number arm answers: the policy joins `deniedBy`, the
read gets `RLS_DENY_FILTER` (zero rows), the write check answers
`PERMISSION_DENIED` / 403, and the WARN detail is rooted at the clause
(`using.flag.$ne`).
- An accepted spelling narrows copy-on-write: `'true'` / `'false'`,
`'1'` / `'0'` and `1` / `0`.
- **Position 2: the NativeSQL strategy (`service-analytics`,
`native-sql-strategy.ts`).**
- `compileClauses` runs the same verdict on every filter it compiles:
the query's `where`, each measure's own `filter` and the dataset's own
scope.
- The dataset door's `runtimeFilter` arrives merged into the `where`
(`DatasetExecutor.combineFilters`).
- The condition is lowered by `lowerAnalyticsWhere` (the shared faces
first, both spellings) and then walked. A member is judged at the column
`resolveStorageTarget` resolves it to, through the host's
`declaredFieldType` hook. That is the same target the datetime lowering,
the text-operator constant and `$empty` already ask.
- An accepted spelling narrows copy-on-write. Anything else the verdict
refuses is refused through `invalidFilterError`, the analytics `where`
door's own envelope (`INVALID_FILTER` / 400), before any statement runs.
- A host with no `declaredFieldType` hook judges nothing, the tiering
every such hook here takes.
- **Docs.** `content/docs/permissions/rls.mdx` said "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.
- **Changeset.** `patch` for both packages, `Clause-②: no`.
## Measured before and after (the engine door is the target column)
Base `6d67ad5ec`, head `ef3ea8700`. Two servers: SQLite (`SqlDriver`,
better-sqlite3) and a private PostgreSQL 16.14 started for this run.
**Position 1.** The real `SecurityPlugin` middleware over a real
`ObjectQL`, as a member whose permission set has one policy with `using`
and `check` the same predicate. Two rows: `t` stores `true`, `f` stores
`false`. "write" is an insert of a `true` / `false` row as the member.
| predicate | before: read, SQLite | before: read, PG | before: write t
/ f | after: read (both) | after: write t / f | engine door (`where`,
both) |
|---|---|---|---|---|---|---|
| `record.flag == true` | t | t | admitted / 403 | t | admitted / 403 |
t |
| `record.flag == 'true'` | none | t | 403 / 403 | t | admitted / 403 |
t |
| `record.flag != 'true'` | **f, t** | f | **admitted** / admitted |
**f** | 403 / admitted | f |
| `record.flag == 'yes'` | none | **t** | 403 / 403 | none, both clauses
dropped `refused-comparand` | 403 / 403 | `INVALID_FILTER` / 400 |
| `record.flag == 1` | t | t | 403 / 403 | t | admitted / 403 | t |
| `record.flag == '1'` | t | t | 403 / 403 | t | admitted / 403 | t |
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 as `true`.
**Position 2.** Three faces were measured:
- the dataset door through `RestServer`'s own `POST
/api/v1/analytics/dataset/query` route handler;
- the cube read through `AnalyticsService.query`, which the runtime's
`POST /analytics/query` relays verbatim;
- the same service over `AnalyticsServicePlugin`'s composition, narrowed
to the ObjectQL strategy, as the engine-door column.
The base cells below use two rows (one `true`, one `false`).
| `runtimeFilter` / `where` | before: native, SQLite | before: native,
PG | after: native (both) | engine door |
|---|---|---|---|---|
| `{ flag: true }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 |
| `{ flag: "true" }` | **200, 0** | 200, 1 | 200, 1 | 200, 1 |
| `{ flag: { $ne: "true" } }` | **200, 2** | 200, 1 | 200, 1 | 200, 1 |
| `{ flag: "yes" }` | **200, 0** | **200, 1** | 400 `INVALID_FILTER`, no
statement ran | 400 `INVALID_FILTER` |
| `{ flag: 1 }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 |
| `{ flag: "1" }` | 200, 1 | 200, 1 | 200, 1 | 200, 1 |
The cube read showed the same cells, plus `"false"`, `$in` and `$nin` of
strings, all aligned after the change.
## The raise-rule measurement (first)
- `git grep` at `6d67ad5ec` over `examples`, `packages`
(`create-objectstack` templates included) and `skills` found 0 RLS
predicates comparing with `'true'` / `'false'` / `'1'` / `'0'`, and 0
analytics `where` / `filter` / `runtimeFilter` / `FilterArray`
comparands spelling a boolean as text.
- Every shipped `using` / `check` predicate compares an id, an email, an
org or a `null`.
- The one `== "yes"` hit is a showcase action-visibility predicate on a
`radio` field. It is not a boolean, not RLS and not analytics.
- **Studio's policy condition builder at `.objectui-sha` `89cad75d5`:
NOT MEASURED.** The objectui sibling is not checked out in this
container.
## Copy-on-write
- PM assumption 4 measured: on `main` the compiled policy filter is not
shared across requests. `compileCelToFilter` keeps no cache, and
`compileFilter` is called per read and per write check
(`security-plugin.ts`, the `layer1` compile and the write-check
compile).
- The narrowing is copy-on-write anyway. The RLS pin's
`@objectstack/formula` mock deep-freezes every filter
`compileCelToFilter` returns, so every cell would throw on an edit in
place. One test also reads the frozen filters back and finds the string
still there.
- The analytics pin deep-freezes every input filter and the registered
dataset (its own `filter` and its measure's `filter`).
## Pins
-
`packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts`,
26 cases.
- 7 narrowed cells and 5 refused cells per dialect. The PostgreSQL cell
runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise.
- Each cell asserts the `where` twin's rows or envelope, the member's
read, both writes, and the drop reasons.
- One test checks that the detail is rooted at its clause, and one is
the copy-on-write read-back.
-
`packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts`,
68 cases.
- 16 filters at the cube read and at the dataset door, per dialect.
- Each case asserts the engine face's answer and the native face's
equality with it. It also asserts which strategy answered, and that a
refusal ran no statement.
- Two more tests: the FilterArray spelling with the cube-qualified
member, and a registered dataset's own scope and measure filter read by
the cube door.
- After merging `main`, `45efcfa3d` had widened the verdict to refuse a
number other than 1 / 0, a `Date` and an array. Both compilers followed
with no code change, because they hold no table of their own. The pins
gained a `2` cell at each position.
## Ablations (each mutation landed and was restored on disk by
`scripts/ablation-replace.mjs`; blob equal to `HEAD` and `git diff HEAD`
empty after each)
Run at `95bf8c4d0`, before the merge and before the `2` cells were
added. Both pins import their subject by relative path
(`./security-plugin.js`, `../plugin.js`), so the ablated code is `src/`,
never a `dist/`. No build leg or dist preflight applies.
| mutation | pin | result |
|---|---|---|
| boolean arm removed (`false &&` before the RLS boolean field verdict)
| RLS, SQLite | 12 failed / 1 passed (the `== true` control); the
negation cell shows `f, t` again |
| RLS narrowing removed (`narrows` returns the comparand) | RLS, SQLite
| 7 failed (every narrowed cell and the read-back) / 6 passed (the
control, the 4 refusals, the detail) |
| NativeSQL narrowing removed | analytics, SQLite | 16 failed (the
canonical-string, negation, list and combinator cells, the array
spelling, the registered dataset) / 16 passed (controls, refusals, and
`'1'` / `$eq '0'`, which SQLite affinity already answered) |
| NativeSQL verdict call removed (`judgedBooleanComparands` returns its
input) | analytics, SQLite | 22 failed / 10 passed (the controls and the
`'1'` cells) |
## Verification (at the head named)
- **Gate derivation.** `dispatch-gates --commands` at `78e4f3eb2`
derived 96 commands. All 96 exit 0.
- Four first answered `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 full `turbo run build`), and
`check:type-check-debt`, which overran a 9-minute timeout on the shared
box and then finished.
- `--ran` reconciliation: 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.
- **At `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-inputs` and
`check:nul-bytes` were rerun: all exit 0. The gate list is unchanged.
- Both pins on SQLite and PostgreSQL: 26 and 68 passed.
- **Full test scripts at `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.
- **Typecheck at `ef3ea8700`.** `pnpm --filter
@objectstack/plugin-security typecheck` (test layer included) and `pnpm
--filter @objectstack/service-analytics typecheck` both pass. The first
run found 2 TS2345 in the new pin, which `ef3ea8700` fixes.
- **Lint, narrowed to the 4 changed TS files at `ef3ea8700`.**
- `eslint --no-inline-config --format json` read 4 files and found 0
errors and 0 warnings.
- `--print-config` shows no `parserOptions.project` or `projectService`
for any of them. `eslint.config.mjs` states it never enables type-aware
linting, so this diff cannot move an untouched file's verdict.
- The `.md` / `.mdx` files are outside eslint's `files` globs. The
repo-wide `pnpm lint` is CI's.
## Acceptance notes
- **The `== 1` write cell moves.** A policy `record.flag == 1` used to
refuse writing a `true` row while its read showed that row. The write
check compared the stored `true` with `1`. Narrowing `1` to `true` is
the spec's verdict and the engine door's answer, so the write check now
agrees with the read. The control `== true` does not move on any face.
- **The guard key `RlsFieldGuard.number` now feeds both arms.** It
always recorded every declared column. Renaming it to a class-neutral
name would touch `security-plugin.ts`, which is outside this card's
surface. Noted, not filed.
- **A relationship-path member** (`account.active`) is judged at the
column it resolves to in NativeSQL. The engine-door column for that case
is NOT MEASURED here.
- **A `formula` returning boolean is `deferred` at NativeSQL.** The
host's `declaredFieldType` hook relays no `returnType`, because the
plugin retired that relay. The engine refuses a formula filter one door
earlier anyway.
- **The NativeSQL face does not run the number-comparand door either.**
This is the twin of position 2, measured on SQLite through
`AnalyticsService.query`:
- `{ amount: "abc" }` answers 200 / 0, `{ amount: { $lte: "9999-12-31" }
}` answers 200 / 2, and `{ amount: true }` answers 200 / 0;
- the engine door answers `INVALID_FILTER` / 400 for each.
- It is out of this card's scope and left untouched, and the report
hands it to the seat.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 68c5ab7 commit 8b123c0
6 files changed
Lines changed: 939 additions & 60 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins/plugin-security/src
- services/service-analytics/src
- __tests__
- strategies
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | | - | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
208 | 217 | | |
209 | 218 | | |
210 | 219 | | |
| |||
Lines changed: 300 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 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
0 commit comments