Repository navigation
Commit 7001918
fix(plugin-security): security/explain answers enforcement's refusal for a row-level policy comparing two fields of no shared comparison class (#20598)
Fixes #20431
Clause-②: no
## What was wrong
A row-level policy can compare two fields that share no comparison
class, for example a text field against a number field. Enforcement
refuses every request such a policy scopes. The find answers
`INVALID_FILTER` / 400. A by-id update or delete fails closed at its
row-level gate (403), because that gate's pre-image read is the same
refused read.
The record-grained explanation judged the same predicate in-process
without the object's declared columns. It compared the two raw values
and reported a record verdict: `visible: true` for one ordering of a
pair, and `visible: false` (rls `excluded`) for the other. Both answers
covered a request that enforcement refuses.
## What changed
The landing point is
`packages/plugins/plugin-security/src/explain-engine.ts`, as the
dispatch expected; the other files are the pin file and the changeset.
There are no changes to `security-plugin.ts`, `packages/formula`,
`packages/spec`, the REST layer, or enforcement.
- The record matcher (`matchesFilterCondition`) now receives the
object's declared columns (`options.fields`), as the RLS write check
does. They are read from `ql.getSchema(object)`: the schema the engine
already reads for the OWD, and the ObjectQL registry that the find's
driver compiles against. A schema that cannot be read hands over no
columns, and the matcher judges values only, as before.
- With the columns, the matcher refuses the comparison. Explain answers
with that refusal: the explanation fails with `INVALID_FILTER` / 400
(the matcher's code and status, the envelope the find answers with), and
no record verdict is reported. The message names the policy and both
fields with their declared types. The matcher's own error rides as
`cause`.
- Naming the fields discloses nothing new. The report explain gives the
same caller for the same object already publishes that predicate
(`readFilter`, or the `rls` layer's `rowFilter`).
## Why a refusal and not a fail-closed report: dispatch assumption A3
did not hold
A3 said to reuse PR #20030's shape (layer `not_evaluated`,
`record.visible: false`) for "enforcement refuses this read".
Measurement on `main` says otherwise:
- Explain already answers the matcher's other `INVALID_FILTER` refusals
as a refusal.
- `rls-stored-list-ordering-fails-closed.test.ts` (landed in `de091b50`,
PR #20310) pins it: "explain read 400 = find 400; explain update 400,
the by-id update 403". One of its cells is a field-to-field comparison
against a list-holding field.
- PR #20030's shape covers a dependency call that fails, not a predicate
the matcher refuses.
My first commit used the report shape. The full `plugin-security` suite
then turned 2 cells of that landed pin red, because its field-to-field
cell is now caught first by the comparison-class rule. Keeping the
report shape would have added the second refusal dialect the dispatch
forbids. So this PR follows the ruling's intent: "the read is refused …
both orderings answer the same refusal as find".
## Measurement: before and after (better-sqlite3, the same stack as the
pins)
| policy class | find | by-id update / delete | explain read / update /
delete, before | after |
|---|---|---|---|---|
| text vs number | 400 `INVALID_FILTER` | 403 `PERMISSION_DENIED` |
`visible: true`, `decidedBy: 'rls'`, rls `admitted` | refused, 400
`INVALID_FILTER` |
| number vs text (the other ordering) | 400 `INVALID_FILTER` | 403
`PERMISSION_DENIED` | `visible: false`, `decidedBy: 'rls'`, rls
`excluded` | refused, 400 `INVALID_FILTER` |
| text vs text (control) | the row | admitted | `visible: true`,
`decidedBy: 'rls'` | unchanged |
## Tests
New file:
`packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts`.
It uses the real `SecurityPlugin`, `ObjectQL` and SQL drivers
(better-sqlite3 and sqlite-wasm; PostgreSQL when `OS_TEST_POSTGRES_URL`
is set), on PR #20427's harness. Every refused cell asserts both halves
with their envelope `code` and `status`: explain's answer, and the
caller's real request.
- Five cells: text vs number, text vs image, text vs formula, text vs
json, and number vs text. Each checks read, update and delete. Explain
answers `{ code: 'INVALID_FILTER', status: 400 }` and its message names
the policy and both fields. Find answers `INVALID_FILTER` / 400, update
and delete answer `PERMISSION_DENIED` / 403, and nothing is stored.
- Both orderings of one pair get `{ find: INVALID, explain: INVALID }`.
- Control, same class: find returns only the matching row. Explain
reports `visible: true` / `admitted` for it and `visible: false` /
`excluded` for the other row. The update is admitted and matches
explain.
Pre-fix run: `main`'s `explain-engine.ts` restored from the base blob
`92716c91`, under a trap whose restore is proven by the HEAD blob and an
empty `git diff HEAD`. Result: `Tests 12 failed | 2 passed | 7 skipped
(21)`. The 2 passes are the controls.
**Ablation:** only the declared-columns argument was removed, through
`scripts/ablation-replace.mjs`. The anchor hit 1 → 0 and the blob went
`a46456db` → `5a314958`. Result: `Tests 12 failed | 2 passed | 7 skipped
(21)`. Every refused cell on both drivers failed:
```text
AssertionError: expected 'answered' not to be 'answered' // Object.is equality
AssertionError: expected { find: { …(2) }, explain: 'admitted' } to deeply equal { find: { …(2) }, explain: { …(2) } }
```
Restore: `ok restored: blob == HEAD (a46456d) and git diff HEAD is
empty`.
All figures below were measured at `5e48f52c`, the head after merging
`origin/main` `c876a742`:
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: `Test Files 144 passed (144)`, `Tests 3066 passed | 23
skipped (3089)`.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, with
the test layer OK. `tsc -p tsconfig.test.json --listFiles` counts the
new file once.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 64
commands, and all 64 ran with exit 0. Three first answered exit 3
`PREREQUISITE NOT MET` (`check:dual-build-cjs-loads`, `check:i18n`,
`check:type-check-debt`). I rebuilt with `turbo run build
--filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks) and
re-ran them; all three answered exit 0. `dispatch-gates --ran`: `64
derived, 64 run, 0 NOT-MEASURED, 0 UNRUN`.
- Lint, narrowed: `eslint --no-inline-config --format json` over the two
touched `.ts` files gives 2 files, 0 errors, 0 warnings. `eslint
--print-config` shows no `parserOptions.project` / `projectService`.
Linting is not type-aware, so this diff cannot move any untouched file's
verdict.
## Acceptance notes
- **The REST door answers 500 for this refusal.** The explain route's
catch maps only `PERMISSION_DENIED` → 403 and `OBJECT_NOT_FOUND` → 404;
every other throw becomes `500 EXPLAIN_FAILED`. I measured it through
the real handler (`security-explain-envelope.test.ts` harness): a
service refusal carrying `INVALID_FILTER` / 400 comes back as `{ status:
500, error: { code: 'EXPLAIN_FAILED', message: … } }`. The refusal's
message survives. PR #20310's refusals were already answered this way.
It lives in `packages/rest/src/rest-server.ts`, outside this card's
surface, so it is reported, not fixed here.
- **The object-level answer is unchanged.** An explanation without a
`recordId` runs no record matcher. For a read under such a policy, it
still reports `allowed: true` and rls `narrows`, where the find answers
400. This PR does not change that; it is reported separately.
- **Missing record, not measured.** When the record does not exist, the
matcher never runs, so explain keeps its missing-record answer
(`visible: false`, no `decidedBy`) for a policy the find would refuse.
- **Duplicated attribution.** The policy-name attribution
(`refusedPolicyNamesOf`) copies the RLS write check's attribution in
`security-plugin.ts`. That file is held by #20555, so one shared helper
is left to whoever next touches both files.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3a89d45 commit 7001918
3 files changed
Lines changed: 396 additions & 6 deletions
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 | + | |
Lines changed: 263 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 | + | |
0 commit comments