Skip to content

Commit ee75aae

Browse files
fix(plugin-security): security/explain answers the read's refusal for an operator the read refuses on a declared JSON-stored column (#21319) (#21371)
Fixes #21319 Clause-②: no `security/explain` now answers the read's refusal, `INVALID_FILTER` / 400 with no verdict, for a row-level policy that aims an operator the read refuses at a column the object declares JSON-stored (a multi-valued field, or a structured-JSON type). Before, it answered `visible: true`, decided by `rls`, for a record whose find was refused. ## Premise, re-measured on `main` at `6c5bef5f4` Harness: the registered `security` service's `explain()` and the real `engine.find` / `insert` / `update` / `delete` through `SecurityPlugin` + `ObjectQL`, as a member resolving a permission set whose `using` is the predicate. Both SQLite families (driver-sql on better-sqlite3, driver-sqlite-wasm) answered every cell alike. | `using` (column class) | find | by-id update / delete | explain, every operation | |---|---|---|---| | multi-valued `!=` (`tags`) | 400 `INVALID_FILTER` | 403 | `visible: true`, `decidedBy: rls` | | `json` `==` | 400 | 403 | `visible: true`, `decidedBy: rls` | | negated `in` on `tags` | 400 | 403 | `visible: true`, `decidedBy: rls` | | `select` flagged `multiple`, `!=` | 400 | 403 | `visible: true`, `decidedBy: rls` | | `lookup` flagged `multiple`, `!=` | 400 | 403 | `visible: true`, `decidedBy: rls` | The object-level report (no record id) said `allowed: true` with `rls` `narrows`, and a record id no row carries was reported `visible: false`, while the find refused both. The card left the HTTP route NOT MEASURED: measured here once through `RestServer` on the real stack, `POST /api/v1/security/explain` answered 200 with `visible: true` while `GET /api/v1/data/:object` answered 400 `INVALID_FILTER`. After this change the same probe answers 400 `INVALID_FILTER` at the route; the wire message is cut at the REST door's bound with the diagnostic, its remedy and the policy name intact. Both probes were temporary and are not committed. ## The change - `explain-engine.ts`: `matchUnderDeclaredColumns`, the one seam the record attribution judges every row filter through, now asks the JSON-column rule the read and the write check apply (`findJsonColumnCheckRefusal`, imported from `rls-check-stored-form.ts`) before the matcher reads the record. The rule reads the declaration, never the record, so it refuses for every record or for none. The object-level pass (`refuseWhatTheMatcherRefuses`) asks the same seam when either classification finds a refusal, so the report without a record id, and a record id no row carries, refuse too. ⛔ No copy of `JSON_COLUMN_INCOMPATIBLE_OPERATORS` and no copy of core's words; no `packages/core` edit. - The answer shape is the one explain already gives a cross-class comparison: a thrown error with no decision, taking `code` / `status` from its `cause`. The `cause` is the error the write check throws for the same refusal (core's message, the read's envelope), so the envelope has one constructor in the package, and `cause.message` is byte-equal to the find's message. The message leads with core's diagnostic, which names the field and the operator and carries the remedy, then names the policy, then the reason explain reports no verdict. Naming the field and operator follows the cross-class precedent: explain publishes the same predicate to the same caller. Core's own message says the diagnostic is in the server log, which would be false for explain, which logs nothing. The diagnostic leads because its length grows only with the field name, while the policy subject is unbounded. - The subject and the trailing sentence are now shared by both refusal builders; the cross-class message is byte-identical to before. - `rls-check-stored-form.ts` (the small change step 2 allowed, same package): `jsonColumnCheckRefusalError` is exported, and `findJsonColumnCheckRefusal` takes an optional `root` (default `check`) so the refusal's `path` names explain's row filters as `rowFilter[…]` rather than as a write check. The module is not re-exported from `src/index.ts`, so the published surface is unchanged (0 hits for the new export in `dist/index.d.ts`). ## Pins — `explain-json-column-refusal.test.ts` On both SQLite families (PostgreSQL when `OS_TEST_POSTGRES_URL` is set, skipped otherwise, as #20431's file does): - The card's three rows plus a `select` and a `lookup` flagged `multiple`: for read, create, update and delete, the real request answers its enforcement envelope (400 / 400 / 403 / 403) and explain answers `INVALID_FILTER` / 400 with no decision; the object-level report and an absent record id do too. Each refusal asserts the envelope, a message that starts with core's diagnostic for that field and operator and names the policy, a head that keeps the diagnostic under the REST bound (`truncateClientMessage`), and a `cause` whose envelope is the read's and whose message equals the find's. - Two policies, one refusing: the message names only the policy carrying the refused operator. - Controls, unchanged on both sides (explain's row verdict equals the find's rows, for read and update): `contains`, `!contains`, presence (`!= null`), and a `!=` on a column declared neither way. - #20431's pins (`explain-cross-class-refusal.test.ts`) stay green, as do `rls-check-stored-form.test.ts` and `rls-stored-list-ordering-fails-closed.test.ts`. ## Ablations (one-shot; each committed first, mutated through `scripts/ablation-replace.mjs` with its trap, restore proven by blob == HEAD and an empty `git diff HEAD`) The subject resolves through source (the pins import `./security-plugin.js` relatively), so no `dist/` leg applies. | mutation | expected | observed | |---|---|---| | A: the refusal removed (`declaredJsonStoredColumns(declaredColumns)` → empty set, 2 sites, anchor 2 → 0) | the refused rows go red | 12 red: J1–J5 and the two-policy pin, both drivers; 8 controls and 14 cross-class pins green | | B: the rule extended to `$contains` / `$notContains` (anchor 1 → 0) | the membership controls go red | 4 red: `contains` and `!contains`, both drivers | | C: the rule applied to every column, not only JSON-stored ones (anchor 1 → 0) | the scalar control goes red | 4 red: the scalar control and the two-policy pin, both drivers. Wider than the one control: the two-policy pin's second policy is a scalar `==`, which the mutated rule also refuses and names | ## Verification (head `cf6f85cf9`, after merging `origin/main` at `23365eaed`) - `pnpm --filter @objectstack/plugin-security test`: 159 files / 3483 passed / 33 skipped, exit 0 (159 of the package's 159 test files). - `pnpm --filter @objectstack/plugin-security typecheck` (tsc, scripts project, test layer): exit 0. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 65 commands; all 65 ran on `cf6f85cf9`, all exit 0. `--ran` reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt` answered PREREQUISITE NOT MET (no `dist/`); their closures were built and all three measured green on the final pass. - Not run locally, CI's: the CI jobs and type-check lanes the derivation names outside its list (Test Core, Dogfood, Build Core, Temporal Conformance, the workspace typecheck lanes). ## Docs No sentence this change makes false was found in `content/docs/**` (outside `releases/`) or `skills/**`. Two it makes true for this class, unedited: `skills/objectstack-data/rules/security.md:61-62` (explain "answers from the enforcing code path") and `content/docs/permissions/explain.mdx:10-13` ("walks the same code paths the enforcement middleware runs"). ## Acceptance notes - `packages/spec/src/security/explain.zod.ts:13-14` says the report "IS enforcement, minus the throw". Since the cross-class refusal landed, explain throws `INVALID_FILTER` for a filter enforcement cannot run, and this change adds a second class of such filter. Not made false here and outside this claim's surface; noted, not filed. - No REST-door wire pin for this class: the door's bound was measured once (above) and the thrown-message head is pinned through `truncateClientMessage`; a `packages/rest` pin like the cross-class one is outside this claim's surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent db0cf22 commit ee75aae

4 files changed

Lines changed: 497 additions & 24 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): `security/explain` answers the read's `INVALID_FILTER` / 400 for a row-level policy that aims an operator the read refuses at a field declared JSON-stored, instead of a "visible" verdict for a request enforcement refuses (#21319)
6+
7+
Clause-②: no
8+
9+
The read a row-level policy scopes refuses a scalar comparison, an ordering or a text operator (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit equality) on a field the object declares JSON-stored: a structured-JSON type (`json`, `address`, …), or a multi-valued field (`tags`, `multiselect`, `checkboxes`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` flagged `multiple: true`). The row-level write `check` refuses them too, by the same rule. `security/explain` (the `security` service's `explain()` and `POST /api/v1/security/explain`) evaluated them in JS instead. Measured with `SecurityPlugin` on two SQLite driver families, as a member resolving a permission set whose `using` is the predicate:
10+
11+
| `using` | find | explain, before |
12+
|---|---|---|
13+
| `record.tags != 'x'` (`tags` is `tags`, multi-valued) | 400 | `visible: true`, decided by `rls` |
14+
| `record.meta == 'x'` (`meta` is `json`) | 400 | `visible: true`, decided by `rls` |
15+
| `!(record.tags in ['x'])` | 400 | `visible: true`, decided by `rls` |
16+
| `record.owners != 'x'` (a `select` or `lookup` flagged `multiple`) | 400 | `visible: true`, decided by `rls` |
17+
18+
The report without a record id said `allowed: true`, and a record id no row carries was reported `visible: false`. Now explain answers every one of these with the read's refusal, `INVALID_FILTER` / 400 and no verdict, for every operation, the answer it already gives a policy comparing two fields of different classes; a by-id update or delete is itself refused 403, at the row-level gate whose pre-image re-read is the refused read. The message leads with the full diagnostic, which names the field and the operator and says how to repair the policy, then the policy that carries it; the error's `cause` carries the read's refusal, with the find's code, status and message. The rule is the one the write check applies, and it reads the object's declaration, never the record.
19+
20+
Unchanged: `contains` and its negation (`$contains` / `$notContains`), and the presence checks (`== null`, `!= null`), answer on such a field as before; a field declared neither way keeps every operator; an object whose schema cannot be loaded is judged as before. To repair a refused policy, test membership with `contains` (for example `!record.tags.contains('x')`).

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 127 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ import { superuserBypassBitForOperation } from './permission-evaluator.js';
4949
import { ExplainObjectNotFoundError } from './errors.js';
5050
import { RLS_DENY_FILTER, compiledPolicyNameOf } from './rls-compiler.js';
5151
import { declaredComparisonColumns } from './declared-comparison-columns.js';
52+
import {
53+
declaredJsonStoredColumns,
54+
findJsonColumnCheckRefusal,
55+
jsonColumnCheckRefusalError,
56+
type DeclaredJsonStoredColumns,
57+
type JsonColumnCheckRefusal,
58+
} from './rls-check-stored-form.js';
5259
import {
5360
unresolvedPostureExplainDetail,
5461
type UnresolvedPostureCause,
@@ -892,24 +899,54 @@ function policyMembersOf(node: unknown): unknown[] {
892899
}
893900

894901
/**
895-
* [#20431] The names of the policies in a compiled business-RLS filter that
896-
* carry a refused field-to-field comparison — the attribution the RLS write
897-
* check logs for the same refusal. The composed filter is one policy's filter,
898-
* `{ $or: [...] }` of them, or (the object-level pass, #20604) either one
899-
* `$and`-composed beside the tenant wall or a delegator's filter;
900-
* `compiledPolicyNameOf` recognises each policy by identity
901-
* ({@link policyMembersOf}).
902+
* [#20431] The names of the policies in a compiled row filter whose own filter
903+
* `refuses` — the attribution the RLS write check logs for the same refusal.
904+
* The composed filter is one policy's filter, `{ $or: [...] }` of them, or (the
905+
* object-level pass, #20604) either one `$and`-composed beside the tenant wall
906+
* or a delegator's filter; `compiledPolicyNameOf` recognises each policy by
907+
* identity ({@link policyMembersOf}).
908+
*
909+
* [#21319] `refuses` is the rule of the refusal being answered: the spec's
910+
* comparison-class rule for a field-to-field comparison, the JSON-column rule
911+
* for an operator the read refuses on a declared JSON-stored column.
902912
*/
903913
function refusedPolicyNamesOf(
904914
filter: Record<string, unknown>,
905-
fields: NonNullable<MatchesFilterOptions['fields']>,
915+
refuses: (member: Record<string, unknown>) => boolean,
906916
): string[] {
907917
const names = policyMembersOf(filter)
908-
.filter((m) => findCrossFieldClassRefusal(m as Record<string, unknown>, fields) !== null)
918+
.filter((m) => refuses(m as Record<string, unknown>))
909919
.map((m) => compiledPolicyNameOf(m) ?? '(unnamed)');
910920
return [...new Set(names)];
911921
}
912922

923+
/**
924+
* [#20431] Who carries a refused filter, as explain's refusal names it: the
925+
* policies {@link refusedPolicyNamesOf} found, or the filter itself when it
926+
* found none.
927+
*/
928+
function refusalSubjectOf(object: string, policies: readonly string[]): string {
929+
return policies.length === 0
930+
? `A row-level filter on '${object}'`
931+
: `The row-level security ${policies.length === 1 ? 'policy' : 'policies'} ` +
932+
`${policies.map((p) => `'${p}'`).join(', ')} on '${object}'`;
933+
}
934+
935+
/**
936+
* [#20431] Why explain answers with a refusal and no verdict. The last sentence
937+
* of every refusal explain gives, so it is the part a long subject may cut.
938+
*/
939+
const EXPLAIN_ANSWERS_THE_REFUSAL =
940+
'Enforcement refuses every request this filter scopes (the find answers INVALID_FILTER / 400), so explain answers ' +
941+
'with the same refusal and reports no verdict.';
942+
943+
/**
944+
* [#21319] How explain's JSON-column refusal names the filters it judged in
945+
* the refusal's `path` ({@link findJsonColumnCheckRefusal}): the row filters a
946+
* read runs under, which the report calls `rowFilter`.
947+
*/
948+
const ROW_FILTER_ROOT = 'rowFilter';
949+
913950
/**
914951
* [#20431] What explain answers when the record matcher refuses a
915952
* field-to-field comparison: enforcement's own refusal. The code and status
@@ -940,17 +977,59 @@ function crossFieldRefusalForExplain(
940977
fields: NonNullable<MatchesFilterOptions['fields']>,
941978
): Error {
942979
const policies = filter !== null && typeof filter === 'object'
943-
? refusedPolicyNamesOf(filter as Record<string, unknown>, fields)
980+
? refusedPolicyNamesOf(filter as Record<string, unknown>, (m) => findCrossFieldClassRefusal(m, fields) !== null)
944981
: [];
945-
const subject = policies.length === 0
946-
? `A row-level filter on '${object}'`
947-
: `The row-level security ${policies.length === 1 ? 'policy' : 'policies'} ` +
948-
`${policies.map((p) => `'${p}'`).join(', ')} on '${object}'`;
949982
const err = new Error(
950983
'Compare a field only with a field of the same class, or fix the declaration of the one that is ' +
951-
`declared with the wrong type. ${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement ` +
952-
'refuses every request this filter scopes (the find answers INVALID_FILTER / 400), so explain answers ' +
953-
'with the same refusal and reports no verdict.',
984+
`declared with the wrong type. ${refusalSubjectOf(object, policies)} cannot be evaluated: ` +
985+
`${refusal.diagnostic}. ${EXPLAIN_ANSWERS_THE_REFUSAL}`,
986+
);
987+
const { code, status } = cause as { code?: string; status?: number };
988+
return Object.assign(err, { code, status, cause });
989+
}
990+
991+
/**
992+
* [#21319] What explain answers when a row filter aims an operator the read
993+
* refuses at a column the object declares JSON-stored: the read's refusal.
994+
*
995+
* The read a policy scopes is compiled by the driver, which refuses
996+
* `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit
997+
* equality, on such a column with `INVALID_FILTER` / 400 whatever the rows; the
998+
* RLS write check refuses the same operators since #21254. The record matcher
999+
* would evaluate them instead, so explain answered `visible: true`, decided by
1000+
* the policy, for a record whose find was refused. The rule is the write
1001+
* check's, imported ({@link findJsonColumnCheckRefusal}): one operator set and
1002+
* one traversal for the three judges of one policy, and ⛔ no copy of either.
1003+
*
1004+
* The shape is {@link crossFieldRefusalForExplain}'s, so explain gives one
1005+
* answer shape for every refusal: a thrown error with no decision, carrying
1006+
* the refusal as its `cause` and taking its code and status from it. The
1007+
* cause is the error the write check throws for the same refusal (core's
1008+
* message, the read's envelope), so the envelope has one constructor here.
1009+
*
1010+
* The message is core's diagnostic (`jsonColumnOperatorRefusalText`), which
1011+
* names the field and the operator, then the policy that carries it, for the
1012+
* reason the cross-class refusal names its columns: explain publishes the
1013+
* same predicate to the same caller. Core's own message withholds both and
1014+
* points to the server log; explain logs nothing, so that sentence would be
1015+
* false here. The diagnostic leads because it holds the remedy: its length
1016+
* grows only with the field's name, while the subject lists every refusing
1017+
* policy, so under the REST door's bound a subject-first order would cut the
1018+
* remedy for long policy names. The subject and the reason come last.
1019+
*/
1020+
function jsonColumnRefusalForExplain(
1021+
refusal: JsonColumnCheckRefusal,
1022+
object: string,
1023+
filter: Record<string, unknown>,
1024+
jsonStored: DeclaredJsonStoredColumns,
1025+
): Error {
1026+
const cause = jsonColumnCheckRefusalError(refusal);
1027+
const policies = refusedPolicyNamesOf(
1028+
filter,
1029+
(m) => findJsonColumnCheckRefusal([m], jsonStored, ROW_FILTER_ROOT) !== null,
1030+
);
1031+
const err = new Error(
1032+
`${refusal.diagnostic} ${refusalSubjectOf(object, policies)} cannot be evaluated. ${EXPLAIN_ANSWERS_THE_REFUSAL}`,
9541033
);
9551034
const { code, status } = cause as { code?: string; status?: number };
9561035
return Object.assign(err, { code, status, cause });
@@ -961,13 +1040,28 @@ function crossFieldRefusalForExplain(
9611040
* refusal answered the way explain answers it: a field-to-field comparison of
9621041
* no shared comparison class becomes {@link crossFieldRefusalForExplain}, and
9631042
* any other refusal of the matcher's propagates as the matcher raised it.
1043+
*
1044+
* [#21319] Before the matcher reads the record, the filter is judged by the
1045+
* JSON-column rule the read and the write check apply: an operator in
1046+
* `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit equality, aimed at a column
1047+
* the object declares JSON-stored becomes {@link jsonColumnRefusalForExplain}.
1048+
* It reads the declaration, never the record, so it refuses for every record
1049+
* or for none. What still answers on such a column is unchanged: the
1050+
* membership pair (`$contains` / `$notContains`) and the presence predicates.
1051+
* No declared columns → no JSON-stored column → no judgement, as before.
9641052
*/
9651053
function matchUnderDeclaredColumns(
9661054
record: Record<string, unknown>,
9671055
filter: unknown,
9681056
object: string,
9691057
declaredColumns: MatchesFilterOptions | undefined,
9701058
): boolean {
1059+
if (filter !== null && typeof filter === 'object') {
1060+
const node = filter as Record<string, unknown>;
1061+
const jsonStored = declaredJsonStoredColumns(declaredColumns);
1062+
const jsonRefusal = findJsonColumnCheckRefusal([node], jsonStored, ROW_FILTER_ROOT);
1063+
if (jsonRefusal) throw jsonColumnRefusalForExplain(jsonRefusal, object, node, jsonStored);
1064+
}
9711065
try {
9721066
return matchesFilterCondition(record, filter as any, declaredColumns);
9731067
} catch (e) {
@@ -1002,6 +1096,13 @@ function matchUnderDeclaredColumns(
10021096
* the `cause` are the record-grained pass's. It is asked only when the spec's
10031097
* classification finds a refused comparison, so no filter the find runs is
10041098
* evaluated here. No declared columns → no judgement, as before.
1099+
*
1100+
* [#21319] The same holds for an operator the read refuses on a declared
1101+
* JSON-stored column, measured the same way: the object-level report said
1102+
* `allowed: true` with `rls` `narrows` for every operation, and a record id no
1103+
* row carries was reported `visible: false`, while the find answered 400. So
1104+
* the JSON-column rule ({@link findJsonColumnCheckRefusal}) is the second
1105+
* classification that asks {@link matchUnderDeclaredColumns}, which answers it.
10051106
*/
10061107
function refuseWhatTheMatcherRefuses(
10071108
filter: unknown,
@@ -1010,7 +1111,11 @@ function refuseWhatTheMatcherRefuses(
10101111
): void {
10111112
const fields = declaredColumns?.fields;
10121113
if (!fields || filter === null || typeof filter !== 'object') return;
1013-
if (findCrossFieldClassRefusal(filter as Record<string, unknown>, fields) === null) return;
1114+
const node = filter as Record<string, unknown>;
1115+
if (
1116+
findCrossFieldClassRefusal(node, fields) === null &&
1117+
findJsonColumnCheckRefusal([node], declaredJsonStoredColumns(declaredColumns), ROW_FILTER_ROOT) === null
1118+
) return;
10141119
matchUnderDeclaredColumns({}, filter, object, declaredColumns);
10151120
}
10161121

@@ -1082,6 +1187,10 @@ async function applyRecordAttribution(
10821187
// it already answers the matcher's other `INVALID_FILTER` refusals: the
10831188
// explanation fails with the envelope the find fails with, and no record
10841189
// verdict is reported.
1190+
// [#21319] The same answer, by the same seam, for an operator the read
1191+
// refuses on a column the object declares JSON-stored (a multi-valued field,
1192+
// or a structured-JSON type): `matchUnderDeclaredColumns` judges it before
1193+
// the matcher reads the record.
10851194
const matches = (filter: unknown): boolean | undefined => {
10861195
if (!recordExists) return undefined;
10871196
if (filter == null) return true;

0 commit comments

Comments
 (0)