Commit ee75aae
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
- .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 | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
52 | 59 | | |
53 | 60 | | |
54 | 61 | | |
| |||
892 | 899 | | |
893 | 900 | | |
894 | 901 | | |
895 | | - | |
896 | | - | |
897 | | - | |
898 | | - | |
899 | | - | |
900 | | - | |
901 | | - | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
902 | 912 | | |
903 | 913 | | |
904 | 914 | | |
905 | | - | |
| 915 | + | |
906 | 916 | | |
907 | 917 | | |
908 | | - | |
| 918 | + | |
909 | 919 | | |
910 | 920 | | |
911 | 921 | | |
912 | 922 | | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
913 | 950 | | |
914 | 951 | | |
915 | 952 | | |
| |||
940 | 977 | | |
941 | 978 | | |
942 | 979 | | |
943 | | - | |
| 980 | + | |
944 | 981 | | |
945 | | - | |
946 | | - | |
947 | | - | |
948 | | - | |
949 | 982 | | |
950 | 983 | | |
951 | | - | |
952 | | - | |
953 | | - | |
| 984 | + | |
| 985 | + | |
| 986 | + | |
| 987 | + | |
| 988 | + | |
| 989 | + | |
| 990 | + | |
| 991 | + | |
| 992 | + | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + | |
| 997 | + | |
| 998 | + | |
| 999 | + | |
| 1000 | + | |
| 1001 | + | |
| 1002 | + | |
| 1003 | + | |
| 1004 | + | |
| 1005 | + | |
| 1006 | + | |
| 1007 | + | |
| 1008 | + | |
| 1009 | + | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
| 1024 | + | |
| 1025 | + | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
954 | 1033 | | |
955 | 1034 | | |
956 | 1035 | | |
| |||
961 | 1040 | | |
962 | 1041 | | |
963 | 1042 | | |
| 1043 | + | |
| 1044 | + | |
| 1045 | + | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
964 | 1052 | | |
965 | 1053 | | |
966 | 1054 | | |
967 | 1055 | | |
968 | 1056 | | |
969 | 1057 | | |
970 | 1058 | | |
| 1059 | + | |
| 1060 | + | |
| 1061 | + | |
| 1062 | + | |
| 1063 | + | |
| 1064 | + | |
971 | 1065 | | |
972 | 1066 | | |
973 | 1067 | | |
| |||
1002 | 1096 | | |
1003 | 1097 | | |
1004 | 1098 | | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
| 1104 | + | |
| 1105 | + | |
1005 | 1106 | | |
1006 | 1107 | | |
1007 | 1108 | | |
| |||
1010 | 1111 | | |
1011 | 1112 | | |
1012 | 1113 | | |
1013 | | - | |
| 1114 | + | |
| 1115 | + | |
| 1116 | + | |
| 1117 | + | |
| 1118 | + | |
1014 | 1119 | | |
1015 | 1120 | | |
1016 | 1121 | | |
| |||
1082 | 1187 | | |
1083 | 1188 | | |
1084 | 1189 | | |
| 1190 | + | |
| 1191 | + | |
| 1192 | + | |
| 1193 | + | |
1085 | 1194 | | |
1086 | 1195 | | |
1087 | 1196 | | |
| |||
0 commit comments