Repository navigation
Commit ef96c9e
fix(plugin-security): the RLS write check judges a date / datetime / time column in its stored form (#21235)
Fixes #21109
Clause-②: no
Executes ruling A (comment 5933322270 on #21109): **the RLS write check
judges the row as it will be stored.** Dispatched by the
`domain:services` seat 2 PM under claim 5938708535, session
`session_01DiCSbmJrkzNhuEAier4VoJ`.
## What changed
- New `packages/plugins/plugin-security/src/rls-check-stored-form.ts`.
Its `storedFormCheckJudge` is the one step every write-check path passes
through before `matchesFilterCondition` judges a `check` clause. That
covers the insert seam, the by-id update image and the update seams,
since all of them call `satisfiesCheck` in `security-plugin.ts`. On each
column the object **declares** `date`, `datetime` or `time` (the spec's
`CALENDAR_DATE_TYPES`, `INSTANT_TYPES`, `CLOCK_TIME_TYPES`), it puts two
things through `@objectstack/core`'s `temporalStorageForm`, which it
consumes and does not change:
- the post-image's value;
- the check's value comparands on that column (`$eq`, `$ne`, the four
orderings, `$in`, `$nin`, `$between`, implicit equality).
- The declared types come from the declaration the write check already
reads (`writeCheckFieldOptions` → `declaredComparisonColumns`), never
from the values. A column the object does not declare temporal is judged
as written. An object whose schema cannot load is judged as before.
- Refusals are still attributed to the compiled policies as they are
(`checkParts`). The rewritten parts are used only for evaluation, and
they are copy-on-write: a filter with nothing temporal comes back as the
same object.
- `security/explain` does not judge a `check` clause. It judges fetched,
stored rows under the read filters (`explain-engine.ts:1088`), so it is
not on this step.
## Measured (ObjectQL.insert + SecurityPlugin + SqlDriver on
better-sqlite3, as a member resolving a permission set; the same
predicate as `using` and `check`)
Compiled filters are shown instead of the CEL text.
| compiled check | written | `main` 62b90d7 | this PR | stored | read |
|---|---|---|---|---|---|
| `{ due_on: { $lte: '2026-01-05' } }` | `'2026-01-05T15:00:00Z'` /
`Date` / `'2026-01-05'` | admitted ×3 | admitted ×3 | `2026-01-05` |
shown |
| `{ due_on: '2026-01-05' }` | `'2026-01-05T15:00:00Z'` | **403** |
admitted | `2026-01-05` | shown |
| `{ due_on: '2026-01-05' }` | `Date(2026-01-05T15:00Z)` | **403** |
admitted | `2026-01-05` | shown |
| `{ due_on: { $gt: '2026-01-05' } }` | `'2026-01-05T15:00:00Z'` |
**admitted** | 403 | `2026-01-05` | hidden |
| `{ due_on: { $lt: '2026-01-05' } }` | `'2026-01-04T23:00:00-05:00'` |
admitted | admitted | `2026-01-04` | shown |
| `{ start_time: '09:00' }` | `'09:00'` | admitted | admitted |
`09:00:00` | shown |
| `{ start_time: '09:00' }` | `'09:00:00'` | **403** | admitted |
`09:00:00` | shown |
| `{ due_at: { $gte: '2026-01-05T10:00:00Z' } }` |
`'2026-01-05T10:00:00Z'` | admitted | admitted |
`2026-01-05T10:00:00.000Z` | shown |
| `{ due_at: '2026-01-05T10:00:00Z' }` | `'2026-01-05T18:00:00+08:00'` |
**403** | admitted | `2026-01-05T10:00:00.000Z` | shown |
| `{ tags: { $contains: 'x' } }` | `'x'` | 403 | 403 (not folded, below)
| `["x"]` | shown |
| `{ tags: { $contains: 'x' } }` | `'xy'` | 403 | 403 | `["xy"]` |
hidden |
On every temporal row, this PR admits a write exactly when the read
shows the stored row. The one write that is admitted on `main` and
refused here is the `$gt` boundary-day cell, whose stored row the read
hides. The changeset names it as the read/write agreement the ruling
buys.
## The comparand half (beyond the ruling's literal words)
The ruling names the post-image. Putting only the image into the form
would **refuse writes `main` admits and the read shows**, whenever a
policy spells its comparand unlike the stored form. Ablation A2 below
measured two such writes: `{ due_at: { $gte: '2026-01-05T10:00:00Z' } }`
written with that same instant, and `{ start_time: '09:00' }` written as
`'09:00'`. The read pairs both sides (`driver-sql`'s
`coerceFilterValue`, the memory driver, objectql's `having` walker), so
the step pairs both sides too. This is called out for the seat's
confirmation in the report.
## Not folded: the multi-valued scalar wrap (claim 5938708535's
condition)
The condition: the wrap must come from the same function the write door
uses. That function is `normalizeMultiValueFields`, at
`packages/objectql/src/validation/record-validator.ts:599`. It cannot be
reached without a new export in another lane's package:
- neither entry of `@objectstack/objectql` exports it (`index.ts:404`
exports `ValidationError`, `validateRecord`, `VALIDATION_FAILED_CODE`;
`core.ts:90` exports `ValidationError`, `validateRecord`);
- `plugin-security` lists `@objectstack/objectql` under
`devDependencies` only.
So the fold is not built, and nothing is copied. Still measured (table
above): `tags: 'x'` under `contains('x')` is 403 while the read shows
`["x"]`. The insert seam runs at `engine.ts:13253`, before
`normalizeMultiValueFields` at `:13410`. The export a fold needs, and
the options, are in the report.
## Fork clause: driver-mongodb
The SQL family (`driver-sql`, `driver-sqlite-wasm`, `driver-turso`) and
`driver-memory` call `temporalStorageForm`. `driver-mongodb` stores
through its own copy (`mongodb-temporal.ts`). Shape by shape, over 15
shapes, the two give the same logical value. A `datetime` is the same
instant held as a BSON `Date`, a physical spelling of the kind ADR-0053
keeps for MySQL. The one exception is a `Date` in a UTC year from 0001
to 0999 on a `date` column: core gives `0999-06-15` and the copy gives
`999-06-15`. On `main`, the raw check already admits that write under a
bare-day `$lte` (the `Date` is lifted to an instant). Mongo's own read
of `999-06-15` against any padded day sorts it above that day whatever
RLS does. This PR treats that as a mongo defect, not a fork in the
stored form, and reports it to the seat to rule. A live mongod is NOT
MEASURED: no binary in the container.
## F7 stays
`lteBound` (`packages/formula/src/matches-filter.ts:902`) stays live and
untouched: `git diff 62b90d7..HEAD -- packages/formula` is empty. Its
deletion is the follow-up card the seat files after this lands. The pins
here already pass with it out of reach (below). The RLS CEL lowering
emits no `{ $field, addDays }` comparand: `cel-to-filter.ts` has 0
`addDays` hits, against 5 `$field` hits as the control. That half of F7
is therefore the deletion card's to measure on its other callers.
## Tests
- `rls-check-stored-form.test.ts`, 38 cells, on two driver families
(driver-sql better-sqlite3 and driver-sqlite-wasm):
- 16 write-and-read cells: each write is admitted exactly when the read
under the same predicate shows the stored row (`PERMISSION_DENIED` / 403
asserted as code + status), and a refused write stores nothing. They
cover `date` (the boundary day as an ISO instant, a `Date` and a bare
day), `datetime`, `time`, and a `text` control.
- A by-id update cell.
- 3 unit cells: the declaration-only column reading, comparand
rewriting, and copy-on-write.
- **F7 is out of reach in this file.** `@objectstack/formula`'s matcher
is wrapped so a `$lte` arrives as `$lt` or `$eq` (and a `$between` as
`$gte` plus that), and the first cell pins that the wrapper is live.
- At head 00cb551: `vitest run` for plugin-security gives **157 files,
3383 passed, 23 skipped** (the new file is +1 file / +38 tests). `pnpm
--filter @objectstack/plugin-security typecheck` exits 0: `tsc
--noEmit`, the scripts project, and `check:test-typecheck` OK with 0
debt.
## Ablations
All on committed head ec6db40, through `scripts/ablation-replace.mjs`
in WRAP mode. Every restore is proven: blob == HEAD and `git diff HEAD`
is empty. Tests import `src` directly, so no `dist/` was involved.
| | mutation | red of 38 |
|---|---|---|
| A0 | the judge swapped back to `main`'s raw evaluation | 16: per
driver, `$lte` ISO + `Date` (F7 out of reach), `$eq` ISO + `Date`, `$gt`
boundary, `datetime` +08:00, `time` `'09:00:00'`, by-id update |
| A1 | image half off | 16: as A0, except `time` `'09:00'` written
replaces `'09:00:00'` |
| A2 | comparand half off (image only) | 8: `datetime` `==` +08:00,
`datetime` `$gte` same spelling, `time` `'09:00'`, `time` `'09:00:00'`.
Two of these are admitted on `main` and shown by the read |
| A3 | a `text` column read as `date` | first attempt a **no-op** (the
tool refused: the replacement contained its anchor, count 1 → 1); rerun
with the anchor `return undefined;`: 7, both control cells on both
drivers plus the 3 unit cells |
| A4 | the judge admits everything | 14: exactly the 7 negative pins × 2
drivers |
## Gates
- Derivation: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at 00cb551 gives 63 families
from 4 paths vs merge base 62b90d7. All 63 were run with exit codes
recorded, and all exited 0. `--ran`: 63 derived, 63 run, 0 NOT-MEASURED,
0 UNRUN.
- The first sweep, at 1d9ccec, had three non-zero exits:
- `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE
NOT MET). Both passed after a full turbo build (72/72).
- `check:test-source-alias` exited 1: a dynamic
`import('@objectstack/formula')` inside a test body. Fixed in bfa3609
(a module-top import).
- Narrowed lint at 00cb551: `eslint --no-inline-config --format json`
over the 3 changed `.ts` files gives 3 files, 0 errors, 0 warnings, none
ignored. The population comes from `eslint.config.mjs:971` (`files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`). `eslint.config.mjs:327-329`
enables no type-aware linting, so no untouched file's verdict can move.
The full `pnpm lint` is CI's.
- Control-byte self-scan over the changed files: none.
## Docs
I grepped `content/docs/**` (outside `releases/`) and `skills/**` for
sentences on how a `check` judges a write (raw versus stored form, date
columns), and found none this PR makes false. Positive control: the grep
reaches `content/docs/permissions/rls.mdx:64` (the `check` row: each row
as its hooks leave it) and
`content/docs/permissions/authorization.mdx:56` (`check` judges the
write post-image). Both are still true. No docs edit.
## Acceptance notes
- `security/explain` compares a stored row against a `using` comparand
as written. A temporal comparand spelled unlike the stored form (for
example `'09:00'` on a `time` column) would be compared unlike the read.
This is unmeasured, so it is noted and not filed.
- The multi-valued wrap and driver-mongodb's unpadded year go to the
seat as findings in the report.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1a4c7f8 commit ef96c9e
4 files changed
Lines changed: 583 additions & 1 deletion
File tree
- .changeset
- packages/plugins/plugin-security/src
| 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 | + | |
| 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 | + | |
0 commit comments