Repository navigation
Commit 97239c3
Fixes #21254
Clause-②: no
The row-level write `check` now refuses an operator the read refuses on
a column the object declares JSON-stored, with the read's
`INVALID_FILTER` / 400 and the read's words. The operator set is
`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` plus implicit
equality. The core module's "two faces, one rule" gains a third face.
`storedFormCheckJudge` imports the set and
`jsonColumnOperatorRefusalText` and copies neither. Its error
constructor is its own, as each face keeps its own, and it carries the
read's envelope. There is no `packages/core` edit and no authoring-door
refusal (triage ruling `5942971732`).
### Premise, re-measured on `main` at `5a9292e6f`
Harness: `ObjectQL.insert` + `SecurityPlugin`, a member resolving a
permission set, `using` and `check` the same predicate. `tags` is
declared `tags` and `meta` is declared `json`. Both driver families
(driver-sql on better-sqlite3, and driver-sqlite-wasm) gave the same
answer in every row. The read is a system-stored row of the same value,
read by the member under the same policy.
| `check` | member writes | write on `main` | read, same policy | write
on this branch |
|---|---|---|---|---|
| `record.tags != 'x'` | `['x']`, and `'x'` | **admitted**, stored
`["x"]` | 400 `INVALID_FILTER` | 400 `INVALID_FILTER` |
| `!(record.tags in ['x'])` | `['x']` | **admitted**, stored `["x"]` |
400 | 400 |
| `record.tags == 'x'` | `['x']` | 403 | 400 | 400 |
| `record.tags in ['x']` | `['x']` | 403 | 400 | 400 |
| `record.tags > 'a'` | `['x']` | 400 (the evaluator's
list-under-ordering refusal) | 400 | 400 (this refusal's words) |
| `record.meta != 'x'` / `record.meta == 'x'` | a scalar | **admitted**,
stored | 400 | 400 |
| `record.tags.contains('x')` / `!record.tags.contains('x')` | `['x']` /
`['y']` | admitted or 403, by membership | shown or hidden, alike |
unchanged |
| `record.tags != null` | `['x']` | admitted | shown | unchanged |
| `record.title != 'x'` (`text`) | `'y'` / `'x'` | admitted / 403 |
shown / hidden | unchanged |
On this branch the refused write's message is byte-identical to the
read's (pinned against `jsonColumnOperatorRefusalText(...).message`,
imported). The full diagnostic names the field, the operator and the
policy, and it goes to the server log, which the message points to.
### ⚠️ Two deviations the PM should read
1. **Rows 3 to 5 change their answer.** They are not admitted before or
after, and nothing is stored. The pin says "the two rows that already
refuse are unchanged". The ruling's rule refuses "an operator in the
core set, on a declared JSON-stored / multi-valued column, with the read
side's code and status", and `$eq`, `$in` and `$gt` are in that set. So
`record.tags == 'x'` and `record.tags in ['x']` move from 403 to the
read's 400. `record.tags > 'a'` keeps 400 `INVALID_FILTER` with core's
words. Keeping 403 there would need either a subset of the set with
`$eq` / `$in` exempted (a second copy of the rule), or a verdict that
depends on the record. I took the ruling's rule as written and read
"unchanged" as "still refused, nothing stored". If the 403 must stay,
that is a ruling question.
2. **File surface widened by one file: `security-plugin.ts` (+21/-1).**
It is the judge's only caller. The core message says "the full
diagnostic is in the server log". Without a log line on this face that
sentence would be false. The judge carries the diagnostic on the error
under a symbol, which keeps it off the wire, the way
`@objectstack/formula`'s comparison-class refusal does. The gate's
existing `satisfiesCheck` catch logs it beside the policy name. Conflict
check: none of the 13 open PRs touches
`packages/plugins/plugin-security/src/security-plugin.ts`, read at this
write.
### Changes
- `packages/plugins/plugin-security/src/rls-check-stored-form.ts`:
- `declaredJsonStoredColumns`: the declared multi-valued columns plus
the spec's `STRUCTURED_JSON_TYPES`. This is the same population the
evaluator already asks `$contains` membership of on the same
declaration, so no new classification.
- `findJsonColumnCheckRefusal`: a pure walk over the parts as compiled,
using the traversal of objectql's per-aggregation gate. It takes the
policy name from the compiler's marks.
- The refusal error, with `code` `INVALID_FILTER`, `status` 400 and
`httpStatus` 400.
- `jsonColumnCheckRefusalCarriedBy`.
- `storedFormCheckJudge` finds the refusal once and throws it for every
image before any is evaluated, so the verdict comes from the declaration
and never from a record.
- `packages/plugins/plugin-security/src/security-plugin.ts`: logs the
carried diagnostic (deviation 2).
- `packages/plugins/plugin-security/src/rls-check-stored-form.test.ts`:
- The card's table at the engine door on both drivers: the write, the
stored row, the read's envelope and message, and the log line.
- A by-id update and a predicate update under a check-only policy.
- Controls: `contains` and its negation, presence, and a scalar column.
- Unit pins beside the module: every operator in the imported set,
implicit equality for any comparand, depth and part paths, the
membership and presence pair left alone, no declaration meaning no
refusal, and the diagnostic kept off the wire.
-
`packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts`:
fixture triage of the stage-2e pins this rule supersedes.
- The nine scalar cells on declared JSON columns (`tags` / `meta` are
`json`, `watchers` is a `multiple` lookup) were compared before. They
are now refused 400, as the read is.
- The `null` control and the stage-2a equality control (`C3`) move to
the `text` column `status`, where the evaluator still decides, so their
subject stays covered.
- `.changeset/21254-rls-write-check-json-column-operator-refusal.md`:
`@objectstack/plugin-security` `patch`, `Clause-②: no`. No export of any
package changes; `rls-check-stored-form` is not on the plugin's
`exports`.
### Verification (at `f9d5020aa`, after merging `origin/main`
`393ae878d`)
`origin/main` now carries the core module's field-class parameter. This
face calls the text builder with three arguments, so it gets the default
class, whose words are unchanged. The workspace was rebuilt after the
merge.
- `pnpm --filter @objectstack/plugin-security test`: 157 files passed,
3450 passed, 23 skipped. Before the stage-2e triage the same run had 22
red cells in that one file, listed above.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0,
including `check:test-typecheck` (0 files / 0 errors in the test-layer
ledger).
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 63 commands. On the rebuilt workspace all 63 ran at
this head with exit 0, and `--ran` reconciled "63 derived, 63 run, 0
NOT-MEASURED, 0 UNRUN" (a derived zero: every line carries its exit
code). On the first pass, before the full build,
`check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT
MET). That was a missing `dist/`, not a finding.
- ESLint, narrowed to the 4 touched source files with
`--no-inline-config --format json`: 4 files, 0 errors, 0 warnings. The
config never enables type-aware linting (its own header says so), so
this diff cannot move the verdict on an untouched file.
- Not run locally, declared to CI: the full lint farm, `Test Core`,
`Dogfood`, `Temporal Conformance` and `Build Core`.
### Ablations (run at the committed head `9e6b96b95`;
`scripts/ablation-replace.mjs` proved each mutation on disk and each
restore: blob equals `HEAD`, and `git diff HEAD` is empty)
The subject is imported relatively (`./rls-check-stored-form.js`) inside
the package's own `src`, so no `dist/` is involved. All three ran
`vitest run src/rls-check-stored-form.test.ts`.
| leg | mutation | result | what went red |
|---|---|---|---|
| refusal removed | the judge's `if (refusal) throw …` line deleted | 23
failed / 81 passed | every refused cell on both drivers (rows 1 and 2
included), the update pin, and the 3 unit refusal pins |
| refusal applied to the membership pair | `$contains` / `$notContains`
added to the walk's refusal test | 26 failed / 78 passed | every
`contains` / negated-`contains` control, this card's and the existing
multi-value ones |
| refusal applied to non-JSON columns | the walk's
`!jsonStored.has(key)` skip removed | 44 failed / 60 passed | every
scalar-column control (`title != 'x'`, `title in ['x']`, `title == …`),
and the unit pins on depth and leave-alone |
### Docs
`content/docs/**` (outside `releases/`) and `skills/**` were searched
for sentences on how a row-level `check` evaluates operators on
multi-valued or JSON columns. That covers `permissions/rls.mdx`,
`skills/objectstack-data/rules/security.md`, and every "JSON-stored",
"multi-value field" and `$contains` page. No page states it, so no
sentence becomes false and no doc is edited.
## Acceptance notes
- **A fourth in-process face still answers the old way:
`security.explain`'s record attribution.** It evaluates the same
policies with the evaluator and the declared columns, but without this
refusal. It answers `visible: true, decidedBy: rls` for a record under
`record.tags != 'x'`, `record.meta == 'x'` or `!(record.tags in ['x'])`,
while the find under the same policy answers 400 `INVALID_FILTER`. This
was measured in-process through the registered `security` service on
driver-sql at `5a56607ab`, a merge of this branch that predates the
latest `main`; the HTTP route was not driven. It is reported to the seat
as a finding and is not touched here.
- **Refusal order.** On the write face this refusal is judged before the
evaluator's own shape refusals. A policy that is malformed and also aims
a refused operator at a JSON column gets this message. Both answers are
`INVALID_FILTER` / 400. The read faces judge the comparand shape first.
- **`$notContains` reaches the check only as a filter.** The CEL
lowering spells a negated `contains` as `$not` over `$contains`. The
engine-door control uses that spelling, and `$notContains` itself is
pinned at unit level.
- **No `driver-memory` or `driver-mongodb` measurement.** The pins are
on the two SQL families the card measured.
- **`record.tags == null` / `!= null` lower to the presence spelling**
and are unchanged.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 222ecc2 commit 97239c3
5 files changed
Lines changed: 521 additions & 25 deletions
File tree
- .changeset
- packages/plugins/plugin-security/src
Lines changed: 21 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 | + | |
Lines changed: 224 additions & 4 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
32 | 49 | | |
33 | 50 | | |
34 | 51 | | |
| |||
44 | 61 | | |
45 | 62 | | |
46 | 63 | | |
| 64 | + | |
47 | 65 | | |
48 | 66 | | |
49 | 67 | | |
50 | 68 | | |
51 | 69 | | |
52 | 70 | | |
53 | 71 | | |
| 72 | + | |
54 | 73 | | |
55 | 74 | | |
| 75 | + | |
| 76 | + | |
56 | 77 | | |
57 | 78 | | |
58 | 79 | | |
| |||
117 | 138 | | |
118 | 139 | | |
119 | 140 | | |
120 | | - | |
121 | | - | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
122 | 146 | | |
123 | 147 | | |
124 | 148 | | |
| |||
142 | 166 | | |
143 | 167 | | |
144 | 168 | | |
| 169 | + | |
145 | 170 | | |
146 | 171 | | |
147 | 172 | | |
| |||
152 | 177 | | |
153 | 178 | | |
154 | 179 | | |
155 | | - | |
| 180 | + | |
156 | 181 | | |
157 | 182 | | |
158 | 183 | | |
| |||
180 | 205 | | |
181 | 206 | | |
182 | 207 | | |
183 | | - | |
| 208 | + | |
184 | 209 | | |
185 | 210 | | |
186 | 211 | | |
| |||
290 | 315 | | |
291 | 316 | | |
292 | 317 | | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
293 | 513 | | |
294 | 514 | | |
295 | 515 | | |
| |||
0 commit comments