fix(plugin-security): the RLS write check refuses an operator the read refuses on a declared JSON-stored column (#21254) - #21317
Conversation
…d refuses on a declared JSON-stored column The write check now refuses, with the read's INVALID_FILTER / 400 and @objectstack/core's words, an operator in JSON_COLUMN_INCOMPATIBLE_OPERATORS (or implicit equality) aimed at a column the object declares JSON-stored, so a policy whose read is refused no longer admits a write. The gate logs the withheld diagnostic beside the policy's name. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…nst the read, on two driver families The card's table at the engine write door beside the read the same policy scopes, the membership-pair, presence and scalar-column controls, a unit pin of the declaration-only verdict, and the stage-2e fixtures re-judged: a scalar on a declared JSON-stored column is now refused by the declaration, and the null / equality controls move to a text column where the evaluator still decides. Plus the changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…s-write-json-operator-refusal Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…s-write-json-operator-refusal Brings in the core JSON-column refusal's field-class parameter (default unchanged), which this face imports. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e9476ad4d351e31c90c1d7a07d37fe1e33789f04 && git checkout e9476ad4d351e31c90c1d7a07d37fe1e33789f04
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1371dc980cdf0d3128bee4a5441f2c6bec18f008 f9d5020aadac4786209ea83410b7a6e263775dfc && git checkout -B drift-repro 1371dc980cdf0d3128bee4a5441f2c6bec18f008 && git merge --no-ff f9d5020aadac4786209ea83410b7a6e263775dfc
node scripts/docs-audit/affected-docs.mjs --json 1371dc980cdf0d3128bee4a5441f2c6bec18f008 |
Fixes #21254
Clause-②: no
The row-level write
checknow refuses an operator the read refuses on a column the object declares JSON-stored, with the read'sINVALID_FILTER/ 400 and the read's words. The operator set is@objectstack/core'sJSON_COLUMN_INCOMPATIBLE_OPERATORSplus implicit equality. The core module's "two faces, one rule" gains a third face.storedFormCheckJudgeimports the set andjsonColumnOperatorRefusalTextand copies neither. Its error constructor is its own, as each face keeps its own, and it carries the read's envelope. There is nopackages/coreedit and no authoring-door refusal (triage ruling5942971732).Premise, re-measured on
mainat5a9292e6fHarness:
ObjectQL.insert+SecurityPlugin, a member resolving a permission set,usingandcheckthe same predicate.tagsis declaredtagsandmetais declaredjson. 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.checkmainrecord.tags != 'x'['x'], and'x'["x"]INVALID_FILTERINVALID_FILTER!(record.tags in ['x'])['x']["x"]record.tags == 'x'['x']record.tags in ['x']['x']record.tags > 'a'['x']record.meta != 'x'/record.meta == 'x'record.tags.contains('x')/!record.tags.contains('x')['x']/['y']record.tags != null['x']record.title != 'x'(text)'y'/'x'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.$eq,$inand$gtare in that set. Sorecord.tags == 'x'andrecord.tags in ['x']move from 403 to the read's 400.record.tags > 'a'keeps 400INVALID_FILTERwith core's words. Keeping 403 there would need either a subset of the set with$eq/$inexempted (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.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 existingsatisfiesCheckcatch logs it beside the policy name. Conflict check: none of the 13 open PRs touchespackages/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'sSTRUCTURED_JSON_TYPES. This is the same population the evaluator already asks$containsmembership 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.codeINVALID_FILTER,status400 andhttpStatus400.jsonColumnCheckRefusalCarriedBy.storedFormCheckJudgefinds 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:containsand its negation, presence, and a scalar column.packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts: fixture triage of the stage-2e pins this rule supersedes.tags/metaarejson,watchersis amultiplelookup) were compared before. They are now refused 400, as the read is.nullcontrol and the stage-2a equality control (C3) move to thetextcolumnstatus, where the evaluator still decides, so their subject stays covered..changeset/21254-rls-write-check-json-column-operator-refusal.md:@objectstack/plugin-securitypatch,Clause-②: no. No export of any package changes;rls-check-stored-formis not on the plugin'sexports.Verification (at
f9d5020aa, after mergingorigin/main393ae878d)origin/mainnow 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, includingcheck:test-typecheck(0 files / 0 errors in the test-layer ledger).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 63 commands. On the rebuilt workspace all 63 ran at this head with exit 0, and--ranreconciled "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-loadsandcheck:i18nexited 3 (PREREQUISITE NOT MET). That was a missingdist/, not a finding.--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.Test Core,Dogfood,Temporal ConformanceandBuild Core.Ablations (run at the committed head
9e6b96b95;scripts/ablation-replace.mjsproved each mutation on disk and each restore: blob equalsHEAD, andgit diff HEADis empty)The subject is imported relatively (
./rls-check-stored-form.js) inside the package's ownsrc, so nodist/is involved. All three ranvitest run src/rls-check-stored-form.test.ts.if (refusal) throw …line deleted$contains/$notContainsadded to the walk's refusal testcontains/ negated-containscontrol, this card's and the existing multi-value ones!jsonStored.has(key)skip removedtitle != 'x',title in ['x'],title == …), and the unit pins on depth and leave-aloneDocs
content/docs/**(outsidereleases/) andskills/**were searched for sentences on how a row-levelcheckevaluates operators on multi-valued or JSON columns. That coverspermissions/rls.mdx,skills/objectstack-data/rules/security.md, and every "JSON-stored", "multi-value field" and$containspage. No page states it, so no sentence becomes false and no doc is edited.Acceptance notes
security.explain's record attribution. It evaluates the same policies with the evaluator and the declared columns, but without this refusal. It answersvisible: true, decidedBy: rlsfor a record underrecord.tags != 'x',record.meta == 'x'or!(record.tags in ['x']), while the find under the same policy answers 400INVALID_FILTER. This was measured in-process through the registeredsecurityservice on driver-sql at5a56607ab, a merge of this branch that predates the latestmain; the HTTP route was not driven. It is reported to the seat as a finding and is not touched here.INVALID_FILTER/ 400. The read faces judge the comparand shape first.$notContainsreaches the check only as a filter. The CEL lowering spells a negatedcontainsas$notover$contains. The engine-door control uses that spelling, and$notContainsitself is pinned at unit level.driver-memoryordriver-mongodbmeasurement. The pins are on the two SQL families the card measured.record.tags == null/!= nulllower to the presence spelling and are unchanged.Generated by Claude Code