Skip to content

Commit eee0974

Browse files
fix(lint)!: refuse a sharing-rule condition that compares a field with a json or multiple field when it is authored (#20375)
Fixes #19886 Clause-②: no Stage 2g of #19886: the sharing-rule twin of stage 2f, and the only remainder item of release `5861981062`. Seat answer A in the 2f ACCEPT (`5861343773`): "Stage 2g (the `validate-sharing-rule-enforceability.ts` arm) measures the sharing runtime (`rule-criteria.ts` → `engine.find` → `driver-sql`) FIRST. It reuses this PR's classification". Claim `5862004488`. Base `67047171`, head `79a2ad1e`. The changeset declares `Clause-②: no (narrowing)`, BREAKING, `@objectstack/lint` minor, following the 2d / 2e / 2f precedent. ## What changes `validateSharingRuleEnforceability` (`packages/lint`) gets one arm. When a sharing rule's `condition` lowers, the arm reads the lowered filter against the declared field map of the rule's anchor object. It reports `sharing-rule-unlowerable-condition` for every field-to-field comparison (`==`, `!=` and the four ordering operators, either side, under `!` too) in which either column is DECLARED to hold a list or an object. - **One classification, not a copy.** The arm calls `listHoldingComparisons` from `validate-rls-predicate-enforceability.ts`, the 2f helper. That helper reads the spec's `STRUCTURED_JSON_TYPES` and `isMultiValueField`, the two sets driver-sql refuses such a comparison by. The share is the smallest one that works: `export` on `listHoldingComparisons` and on its `ListHoldingComparison` result type, plus one comment sentence. No RLS line changes behaviour, and the 2f pins run unchanged (see Tests). - **The anchor only, lazily.** The graph is indexed per anchor, once, and only for a condition that lowered. A lowered sharing criterion can only address its anchor's own columns, because a cross-object path does not lower. Indexing the whole stack up front changed which refusal an unreadable reference carrier raises and turned the existing `#18550` pin red in the first run. The lazy anchor-scoped graph keeps that pin byte-identical. - **The id.** It keeps `sharing-rule-unlowerable-condition`. The fix is the same rewrite of the condition, and the same class's literal spelling (`record.status == ['a', 'b']`) is already reported under that id, refused by the compiler rather than by the driver. The id's one-line doc now names both halves. A separate id was weighed and not taken (see Decisions). - **Doors.** `os validate` / `os build` / `os lint` run this rule. The metadata save door for a `sharing_rule` does not run it, as before (see Acceptance notes). No gate is added and no runtime seam changes. Files: - the rule (+105 / −8); - the RLS rule (+5 / −2: two `export` keywords, one comment sentence); - a new table-driven pin beside the existing sharing tests; - the changeset. The cli admission parity test (`rls-policy-authoring-admission.test.ts`) has no sharing-rule family, because the sharing rule never crosses the save door, so it is untouched. ## The refusal text (new) Message, from the real `os validate` at head: > Sharing-rule condition `record.status != record.tags` lowers, but compares a field with a field that holds a list or an object: `record.status != record.tags`, where `tags` is declared `type: 'json'`. A column that holds a list or an object is not one comparable value, on either side of a field-to-field comparison, so the platform refuses the comparison instead of evaluating it: the rule is seeded into `sys_sharing_rule`, but every criteria query it runs is refused on the SQL drivers (`INVALID_FILTER` / 400: driver-sql refuses a cross-field comparison against such a column by its declared type), and `SharingRuleService` reads a refused query as matching no record. No `sys_record_share` grant is ever materialised, at boot or on any later write, and the only signal is a WARN line in the server log. The rule is declared and grants nothing. Hint: > A field compared with a `json` or `multiple` field has no row-filter form: a row filter compares one value with one value, and cannot test membership in a list another column holds. Compare with a single-valued column, or with a literal — "one of these values" is `record.status in ['open', 'pending']` — or keep the value the rule keys on in a single-valued field and compare with that. The class sentence is the 2f RLS arm's sentence, word for word; a pin holds the two equal. The hint drops the RLS hint's `current_user` alternative, because a sharing condition that reads `current_user` is refused (`sharing-rule-runtime-variable-condition`). ## Zone 1 P1, measured FIRST (before any edit, at `67047171`) A probe drove the real `SharingServicePlugin` over a real `ObjectQL`: - `init`, then `start`, then the `kernel:ready` handlers: seeding through `bootstrapDeclaredSharingRules` from `registerApp`'s `sharingRules` collection, and hook binding; - then the `kernel:bootstrapped` backfill; - then a post-boot insert and update, to exercise the per-record hook path. Setup: - 10 leak rules and 4 controls, each sharing READ with its own user, on a `private` object; - 3 seeded rows, including one whose `status` is a member of `tags`, so a rule that "should match" exists; - each recipient's read went through the sharing middleware. | driver | leak rules (10: `json` list and object, `multiple` lookup, `multiselect`, `multiple` user; `==`, `!=`, `!(==)`, `>`, less-or-equal; both orders) | controls (text `!=` / `==` text, literal, `json != null`) | |---|---|---| | driver-sql (better-sqlite3) | all 10 **seeded** with `criteria_json`; every criteria query `INVALID_FILTER` / 400; one WARN `[sharing-rule] criteria query failed` per rule at the backfill; **0 grants; the recipient reads 0 rows**; after the insert and update, still 0 grants, with only engine-level `Find operation failed` warns that name no rule | granted and enforced exactly the matching rows (for example text `!=` text: r1, r2, then r4 after the insert) | | driver-sqlite-wasm | identical, cell for cell | identical | | driver-memory (test/demo; #15104, frozen) | reads a `{ $field }` comparand as a literal: `!=` grants every row and `==` / ordering grant nothing | **equally wrong** (text `==` text grants nothing) — this is #15104, not the list class | - driver-mongodb: not live-measured. Its source (`mongodb-filter.ts`) refuses every `{ $field }` comparand, scalar or list. - driver-turso: local and replica mode extend `SqlDriver`. The remote transport was not measured. **Verdict: P1 holds.** On the SQL drivers the runtime refuses the comparison, and the sharing path turns the refusal into a silent zero-share: the rule is declared, seeded, and grants nothing. The prohibition does not apply. ## Zone 2, measured **1. The authoring doors.** - The cells: 85 sharing rules in one stack. - 72 leak cells: 7 operators × 5 list/object columns (`json`, `address`, `multiple` lookup, `multiselect`, `multiple` user) × 2 orders, plus a list-against-list cell and a compound one; - 10 scalar controls; - 3 firing controls. - The doors: the real CLI (`node packages/cli/bin/run.js validate --json`) and the save door (`saveMetaItem({ type: 'sharing_rule' })` over a real `ObjectQL`, sqlite-wasm). | tree | leak (72) | controls (10) | firing (3) | save door | |---|---|---|---|---| | `67047171` (base) | 72 clean | 10 clean | 3 reported (list literal and function call → unlowerable; `current_user` → runtime-variable) | 85 / 85 accepted, firing controls included | | `79a2ad1e` (head) | **72 refused, one finding each, one consequence sentence and one hint across all 72** | 10 clean | 3 reported, rule and message identical to base | 85 / 85 accepted (unchanged; this rule is CLI-only) | **2. The runtime.** P1 above. The door that answers is the driver, through `engine.find`: driver-sql's `crossFieldComparisonClass`, by declared type. `plugin-sharing` catches the refusal (`findMatchingRows` / `matchRecord`) and reads it as "no match". **3. Where the arm lives.** `compileCelToFilter(condition, { variables: {} })`, the seeder's exact call, returns the same lowered `FilterCondition` shape the RLS rule walks. The probe's seeded `criteria_json` shows it: `{"status":{"$ne":{"$field":"tags"}}}` and `{"$not":{"status":{"$eq":{"$field":"tags"}}}}`. So the smallest arm is one call to the 2f helper on `result.filter` when `result.ok`. **4. Census (narrowing).** - objectstack at `67047171`, `packages/**` + `examples/**`, non-test: 3 declared sharing-rule conditions (`app-showcase` ×2, a `qa/downstream-contract` fixture), all field against literal. 0 field-to-field, 0 against a `json` / `multiple` column. - cloud `main` `96eb092` (the `ls-remote` tip): 0 declared sharing-rule conditions. - The real `os validate` at head over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo`: 0 `sharing-rule-*` findings. Error and warning counts are unchanged from base for the three apps that ran there. `app-showcase` could not load at base before its dependency closure was built, and at head it is valid with 0 errors. ## Tests All at head `79a2ad1e`, in one run through `os-verify-lock.sh` (VERDICT command-exit 0, held 603s on a shared box): - `@objectstack/lint` build exit 0 (DTS emitted, 4/4 declaration files). - `@objectstack/lint`, the targeted five files (the new pin, the existing sharing tests, the 2f RLS list-holding pin, the rule-id barrel and the wiring guard): 5 files / 480 tests pass. - `@objectstack/lint`, full package: 113 files / 4704 tests pass. The existing sharing-rule pins and the 2f RLS pins run unchanged, including the `#18550` unreadable-carrier pin that the first draft of this arm turned red. - `@objectstack/lint` `typecheck`: exit 0 (source and test layer). - `@objectstack/cli` `unit` project: 230 files / 3296 pass. `integration` project, the admission parity pin: 30 / 30 (named by the dispatch as a consumer suite; not touched). - `@objectstack/plugin-sharing`, full package (read-only consumer; no pin flips): 37 files / 913 pass. The new pin has 64 tests: - 28 cells: 7 operators × 2 classes × 2 orders, each asserting the envelope (severity, rule id, path, where) and the whole message verbatim, plus the hint's first clause; - 16 declared classes read from the spec; - 11 controls; - the parsed tier, the compound and two-comparison cases, an inactive rule, the RLS class-sentence parity, the not-judged cases, one-defect-one-finding against an unlowerable condition, the anchor arm reported beside it, and the `os validate` rule table. The first run was at `53275286`, before the lazy-graph fix: `lint` full 1 failure out of 4704, the `#18550` pin; `cli unit` 230 / 3296; `plugin-sharing` 37 / 913. That failure is why the graph is anchor-scoped and lazy. **Ablation** From committed `79a2ad1e`, run by `ablation.sh` with an `EXIT INT TERM` trap: - **Mutation.** `scripts/ablation-replace.mjs` replaced `if (listHolding) findings.push(listHolding);` with a guard that never pushes, carrying the marker `ABLATION_19886G`. Anchor 1 to 0, marker 0 to 1, blob `a95f00af` to `f507e6a3`. - **Build.** `pnpm --filter @objectstack/lint build` exit 0. `ablation-dist-preflight.mjs @objectstack/lint` found the marker in all 4 built entries. - **The new pin plus the existing sharing tests: 50 red / 64 green.** Red: every refusal cell, class and property. Green: the 14 controls and not-judged cases, and all 50 pre-existing sharing tests. - **The real `os validate` over the 85-cell stack, with the ablated dist:** the 72 leak cells are clean again, the 10 controls clean, and the 3 firing controls unchanged. The arm alone carries the refusal, through the built CLI. - **Restore.** `git checkout HEAD -- ABS_PATH`: blob equal to HEAD (`a95f00af`), `git diff HEAD` empty. Rebuilt with exit 0; the preflight `--absent` reads the marker absent from all 14 built files and the tree clean. ## Gates Re-derived at head with `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`. The change set is these 4 paths against merge base `67047171`, and it is the same 60 commands as at `53275286`. Every one was run at `79a2ad1e`, with exit codes recorded to disk before they were read. The `--ran` reconciliation reads **60 derived, 59 run, 1 NOT MEASURED, 0 UNRUN**: - 54 light gates: exit 0. - 5 build-reading gates exit 0: `check:dts-closure`, `check:lean-entry-closure`, `check:published-files`, `check:sourcemap-no-sources-content` and `check:type-check-debt`. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: exit 3, PREREQUISITE NOT MET. Six packages outside the built closures have no `dist/` (studio, client-react, embedder-openai, knowledge-ragflow, organizations, service-cluster-redis), and the gate needs a whole-tree `pnpm build`. As a proxy, lint's `dist/index.cjs` and `dist/runtime.cjs` both `require()` cleanly. - **ADR-0087:** `not-required (already-registered cel-predicate-one-value-comparand-refused)`. - The entry's `surface` names `sharingRules[].condition` and this exact class: "a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does". - Its `replacement` carries the prescription. - `check-adr-0087-registration` exits 0 and reads the marker as `[BREAKING+clause-②-narrowing] not-required (already-registered)`. ## Decisions settled in this PR (open to the reviewer) **The id: reuse `sharing-rule-unlowerable-condition` (chosen) or add `sharing-rule-unenforceable-condition`.** - Actual business need: 0 producers write this shape (census), so neither choice moves an author. - Long-term soundness: this file assigns ids by fix, and the fix here is the unlowerable one, a rewrite of the condition. The class's literal spelling already sits under that id. The cost is a constant whose name says "unlowerable" for a condition that lowers, so its doc line now names both halves. 2f made the same trade in RLS (`rls-predicate-unenforceable`). - Preventing AI authoring mistakes: the message states "lowers, but compares…" and the real consequence, so a reader of `--json` is not sent to "rewrite into the pushdown subset". - Startup-stage scope: a new id is a new permanent public constant and barrel line, for no author. ## Acceptance notes - **The save door does not run this rule.** `validateSharingRuleEnforceability` is `CLI_ONLY`. Its wiring entry in `authoring-rules.ts` records the runtime crossing as pending: the snapshot does not carry `sharingRules`. Measured at base and at head: `saveMetaItem({ type: 'sharing_rule' })` accepts all 85 cells, including the 3 firing controls this rule has always reported at the CLI. This is pre-existing and not specific to this class, so it is not widened here. - **driver-memory** mis-reads every `{ $field }` comparison as a literal, scalar ones included. Measured on the sharing path: `!=` over-shares every row. That is #15104 (frozen; test and demo only), unchanged here. - **The per-record path is quieter than the backfill.** After a write, a refused criteria query is caught in `matchRecord` with no sharing-level WARN for a non-`field` rule. Only the engine logs `Find operation failed`, naming no rule. That is runtime diagnostics, read only here (`plugin-sharing` is fenced). The new finding says "a WARN line in the server log", which holds for the boot backfill. - **The ADR-0087 entry's `reason`** describes the stage 2d outcome for this class: the write-check evaluator refusing it. That fits RLS, not a sharing condition, where the driver refuses the criteria query. The `surface` and `replacement` fit, so no registration is owed. The reason text is not edited here. - The cross-class family (text vs number, text vs image, formula) is #20347's and is not widened into this arm. ## What remains on #19886 The release's remainder list (`5861981062`) held stage 2g only, and this PR closes it, hence `Fixes`. Carried elsewhere: #20347 (cross-class comparisons, a different family) and #15104 (frozen, driver-memory). --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 67c98f6 commit eee0974

4 files changed

Lines changed: 386 additions & 10 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
A sharing rule whose `condition` compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint`, instead of being seeded and then granting nothing (#19886).
6+
7+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, whose surface names `sharingRules[].condition` and this class.
8+
9+
Clause-②: no (narrowing)
10+
11+
`record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the condition's text and not the object's field types, so the seeder seeds the rule. Measured before this change: 72 conditions (`==`, `!=`, `!(==)`, `>`, `>=`, `<`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; plus a list-against-list and a compound spelling) were all accepted by the real `os validate`. The runtime refused every one of them, measured through the real plugin-sharing on driver-sql and driver-sqlite-wasm: the rule was seeded into `sys_sharing_rule`, every criteria query it ran answered `INVALID_FILTER` / 400, `SharingRuleService` read that as matching no record, and no `sys_record_share` grant was written, at boot or on a later insert or update. The recipient read nothing. The only signal was one WARN line per rule in the server log.
12+
13+
What changes:
14+
15+
- `@objectstack/lint`: `validateSharingRuleEnforceability` reports `sharing-rule-unlowerable-condition` for a condition that lowers but compares two fields (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) where either column is DECLARED to hold a list or an object. It uses the same classification as the row-level-security rule's arm for this class (`listHoldingComparisons`, now exported from `validate-rls-predicate-enforceability.ts`), which reads the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). The finding names each comparison and the declaration behind it, and states the run-time consequence. It keeps the unlowerable id because the fix is the same rewrite of the condition, and the literal spelling of the same class (`record.status == ['a', 'b']`) is already reported under that id.
16+
- Inactive rules are judged too, as the rule already does for every condition: the seeder seeds them regardless of `active`.
17+
18+
Not changed: a field compared with a single-valued field (`record.status != record.owner_name`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an anchor object from another package, an object with no field map, an undeclared name), which the rule does not judge. No row-level-security verdict changes. The rule still runs only at the CLI doors; the metadata save door for a `sharing_rule` does not run it, as before.
19+
20+
No shipped condition moves: the 3 declared sharing-rule conditions in this repository's packages and examples compare a field with a literal, the cloud repository declares none, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no new `sharing-rule-*` finding.
21+
22+
**What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal ("one of these values" is `record.status in ['open', 'pending']`), or keep the value the rule keys on in a single-valued field and compare with that.
23+
24+
<!-- adr-0087: not-required (already-registered cel-predicate-one-value-comparand-refused) The entry's surface already names sharingRules[].condition and this exact class ("a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does"), and its replacement carries the prescription ("A field compared with a json or multiple field has no pushdown form: compare with a single-valued column"). This change moves where that registered class is refused on a sharing rule, from a silent zero-share at run time to authoring time; it adds no class and no prescription the entry does not already hold, and it rewrites no stored metadata. -->

‎packages/lint/src/validate-rls-predicate-enforceability.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,7 +1004,7 @@ function listHoldingDeclaration(meta: GraphField | undefined): string | null {
10041004
}
10051005

10061006
/** One lowered comparison between two columns, at least one of which holds a list or an object. */
1007-
interface ListHoldingComparison {
1007+
export interface ListHoldingComparison {
10081008
/** The comparison as the author wrote it, back in CEL. */
10091009
written: string;
10101010
/** Each list-holding column, with what it is declared as. */
@@ -1017,8 +1017,11 @@ interface ListHoldingComparison {
10171017
* the object graph. A column the graph cannot answer for (an object outside the
10181018
* stack, no field map, a name it does not declare) is not judged here; the
10191019
* reference pass above owns an unknown name.
1020+
*
1021+
* Exported for `validate-sharing-rule-enforceability.ts`, which judges the same
1022+
* class on a sharing rule's lowered `condition`: one classification, two rules.
10201023
*/
1021-
function listHoldingComparisons(
1024+
export function listHoldingComparisons(
10221025
graph: ObjectGraph,
10231026
object: string,
10241027
filter: Record<string, unknown>,
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19886] A sharing rule whose `condition` compares a field with a field that
5+
* holds a LIST or an OBJECT is refused at authoring time, judged by the
6+
* DECLARED type the stack's object graph carries — the sharing-rule twin of the
7+
* RLS arm pinned in `validate-rls-predicate-enforceability.list-holding-field.test.ts`,
8+
* through the same classification (`listHoldingComparisons`).
9+
*
10+
* `record.status != record.tags` (`tags` a `json` field or a `multiple` lookup)
11+
* lowers to a legal `{ status: { $ne: { $field: 'tags' } } }`, so the seeder
12+
* seeds the rule — and every criteria query it runs is then refused by
13+
* driver-sql by declared type (`INVALID_FILTER` / 400), which
14+
* `SharingRuleService` reads as "matches no record". Measured through the real
15+
* plugin-sharing: the rule is seeded and grants nothing, at boot and on every
16+
* later write. Before this arm every cell of the table below was clean at
17+
* `os validate`.
18+
*
19+
* One table: operator × column class × operand order, then the scalar
20+
* field-to-field controls, which stay clean.
21+
*/
22+
23+
import { describe, expect, it } from 'vitest';
24+
25+
import {
26+
validateSharingRuleEnforceability,
27+
SHARING_RULE_UNLOWERABLE_CONDITION,
28+
} from './validate-sharing-rule-enforceability.js';
29+
import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js';
30+
import { runAuthoringRules } from './authoring-rules.js';
31+
32+
const deal = {
33+
name: 'deal',
34+
label: 'Deal',
35+
sharingModel: 'private',
36+
fields: {
37+
status: { type: 'text', label: 'Status' },
38+
owner_name: { type: 'text', label: 'Owner name' },
39+
amount: { type: 'number', label: 'Amount' },
40+
budget: { type: 'number', label: 'Budget' },
41+
close_date: { type: 'date', label: 'Close date' },
42+
signed_date: { type: 'date', label: 'Signed date' },
43+
account: { type: 'lookup', label: 'Account', reference: 'account' },
44+
stage: { type: 'select', label: 'Stage', options: [{ label: 'Open', value: 'open' }] },
45+
tags: { type: 'json', label: 'Tags' },
46+
reviewers: { type: 'lookup', label: 'Reviewers', reference: 'account', multiple: true },
47+
},
48+
};
49+
const account = { name: 'account', label: 'Account', sharingModel: 'private', fields: { region: { type: 'text', label: 'Region' } } };
50+
51+
/** One criteria sharing rule on `deal`, the condition swapped in. */
52+
const stackWith = (condition: unknown, objects: unknown[] = [deal, account], extra: Record<string, unknown> = {}) => ({
53+
objects,
54+
sharingRules: [
55+
{
56+
name: 'deal_desk_share',
57+
type: 'criteria',
58+
object: 'deal',
59+
accessLevel: 'read',
60+
sharedWith: { type: 'team', value: 'deal_desk' },
61+
condition,
62+
...extra,
63+
},
64+
],
65+
});
66+
67+
/** Each operator an author can write between two fields, and what the finding quotes back. */
68+
const OPERATORS: ReadonlyArray<{ op: string; spell: (a: string, b: string) => string; quoted: string }> = [
69+
{ op: '!=', spell: (a, b) => `record.${a} != record.${b}`, quoted: '!=' },
70+
{ op: '!(==)', spell: (a, b) => `!(record.${a} == record.${b})`, quoted: '==' },
71+
{ op: '==', spell: (a, b) => `record.${a} == record.${b}`, quoted: '==' },
72+
{ op: '>', spell: (a, b) => `record.${a} > record.${b}`, quoted: '>' },
73+
{ op: '>=', spell: (a, b) => `record.${a} >= record.${b}`, quoted: '>=' },
74+
{ op: '<', spell: (a, b) => `record.${a} < record.${b}`, quoted: '<' },
75+
{ op: '<=', spell: (a, b) => `record.${a} <= record.${b}`, quoted: '<=' },
76+
];
77+
78+
const COLUMNS = [
79+
{ column: 'a json field', field: 'tags', declared: "`tags` is declared `type: 'json'`" },
80+
{ column: 'a multiple lookup', field: 'reviewers', declared: "`reviewers` is declared `type: 'lookup'`, `multiple: true`" },
81+
] as const;
82+
83+
/** The family's class sentence — the RLS arm's words, kept identical. */
84+
const CLASS_SENTENCE =
85+
'A column that holds a list or an object is not one comparable value, on either side of a field-to-field ' +
86+
'comparison, so the platform refuses the comparison instead of evaluating it';
87+
88+
/** The measured runtime consequence on the sharing path. */
89+
const CONSEQUENCE =
90+
'the rule is seeded into `sys_sharing_rule`, but every criteria query it runs is refused on the SQL drivers ' +
91+
'(`INVALID_FILTER` / 400: driver-sql refuses a cross-field comparison against such a column by its declared ' +
92+
'type), and `SharingRuleService` reads a refused query as matching no record. No `sys_record_share` grant is ' +
93+
'ever materialised, at boot or on any later write, and the only signal is a WARN line in the server log. The ' +
94+
'rule is declared and grants nothing.';
95+
96+
describe('validateSharingRuleEnforceability — a condition comparing a field with a json / multiple field is REFUSED (#19886)', () => {
97+
for (const { op, spell, quoted } of OPERATORS) {
98+
for (const { column, field, declared } of COLUMNS) {
99+
for (const order of ['scalar first', 'list first'] as const) {
100+
const [left, right] = order === 'scalar first' ? ['status', field] : [field, 'status'];
101+
const condition = spell(left, right);
102+
it(`${op} · ${column} · ${order}: \`${condition}\``, () => {
103+
const findings = validateSharingRuleEnforceability(stackWith(condition));
104+
expect(findings.map((f) => ({ severity: f.severity, rule: f.rule, path: f.path, where: f.where }))).toEqual([
105+
{
106+
severity: 'error',
107+
rule: SHARING_RULE_UNLOWERABLE_CONDITION,
108+
path: 'sharingRules[0].condition',
109+
where: 'sharing rule "deal_desk_share" on object "deal"',
110+
},
111+
]);
112+
expect(findings[0].message).toBe(
113+
`Sharing-rule condition \`${condition}\` lowers, but compares a field with a field that holds a list or ` +
114+
`an object: \`record.${left} ${quoted} record.${right}\`, where ${declared}. ${CLASS_SENTENCE}: ${CONSEQUENCE}`,
115+
);
116+
expect(findings[0].hint).toMatch(/^A field compared with a `json` or `multiple` field has no row-filter form/);
117+
});
118+
}
119+
}
120+
}
121+
122+
it('the table covers every field-to-field operator, both classes, both orders', () => {
123+
expect(OPERATORS.length * COLUMNS.length * 2).toBe(28);
124+
});
125+
126+
it('the parsed tier (`{ dialect, source }`) is refused identically', () => {
127+
const findings = validateSharingRuleEnforceability(stackWith({ dialect: 'cel', source: 'record.status != record.tags' }));
128+
expect(findings.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
129+
expect(findings[0].message).toContain('Sharing-rule condition `record.status != record.tags` lowers');
130+
});
131+
132+
it('an offending comparison inside a compound condition is refused, and a second one is named in the same finding', () => {
133+
const one = validateSharingRuleEnforceability(stackWith("record.stage == 'open' && record.status != record.tags"));
134+
expect(one.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
135+
136+
const two = validateSharingRuleEnforceability(stackWith('record.status != record.tags || record.reviewers == record.tags'));
137+
expect(two).toHaveLength(1);
138+
expect(two[0].message).toContain(
139+
"object: `record.status != record.tags`, where `tags` is declared `type: 'json'`; " +
140+
"`record.reviewers == record.tags`, where `reviewers` is declared `type: 'lookup'`, `multiple: true` and " +
141+
"`tags` is declared `type: 'json'`. ",
142+
);
143+
});
144+
145+
it('an inactive rule is judged too — the seeder compiles and seeds it regardless of `active`', () => {
146+
const findings = validateSharingRuleEnforceability(stackWith('record.status != record.tags', [deal, account], { active: false }));
147+
expect(findings.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
148+
});
149+
150+
it('speaks the RLS arm\'s class sentence, word for word — one class, one sentence, two surfaces', () => {
151+
const rls = validateRlsPredicateEnforceability({
152+
objects: [deal, account],
153+
permissions: [{ name: 'sales', label: 'Sales', rowLevelSecurity: [{ name: 'p', object: 'deal', operation: 'select', using: 'record.status != record.tags' }] }],
154+
});
155+
const sharing = validateSharingRuleEnforceability(stackWith('record.status != record.tags'));
156+
expect(rls[0].message).toContain(`${CLASS_SENTENCE}: `);
157+
expect(sharing[0].message).toContain(`${CLASS_SENTENCE}: `);
158+
});
159+
});
160+
161+
describe('validateSharingRuleEnforceability — every declared list-or-object class, read from the spec (#19886)', () => {
162+
// The spec's two value-shape classes: `STRUCTURED_JSON_TYPES` and
163+
// `isMultiValueField` (an inherently-multi option type, or a multi-capable
164+
// type flagged `multiple: true`) — the same table the RLS arm pins.
165+
const LIST_HOLDING: ReadonlyArray<[string, Record<string, unknown>, string]> = [
166+
['json', { type: 'json' }, "`type: 'json'`"],
167+
['composite', { type: 'composite' }, "`type: 'composite'`"],
168+
['repeater', { type: 'repeater' }, "`type: 'repeater'`"],
169+
['record', { type: 'record' }, "`type: 'record'`"],
170+
['location', { type: 'location' }, "`type: 'location'`"],
171+
['address', { type: 'address' }, "`type: 'address'`"],
172+
['vector', { type: 'vector' }, "`type: 'vector'`"],
173+
['multiselect', { type: 'multiselect' }, "`type: 'multiselect'`"],
174+
['checkboxes', { type: 'checkboxes' }, "`type: 'checkboxes'`"],
175+
['tags', { type: 'tags' }, "`type: 'tags'`"],
176+
['multiple select', { type: 'select', multiple: true }, "`type: 'select'`, `multiple: true`"],
177+
['multiple radio', { type: 'radio', multiple: true }, "`type: 'radio'`, `multiple: true`"],
178+
['multiple lookup', { type: 'lookup', reference: 'account', multiple: true }, "`type: 'lookup'`, `multiple: true`"],
179+
['multiple user', { type: 'user', multiple: true }, "`type: 'user'`, `multiple: true`"],
180+
['multiple file', { type: 'file', multiple: true }, "`type: 'file'`, `multiple: true`"],
181+
['multiple image', { type: 'image', multiple: true }, "`type: 'image'`, `multiple: true`"],
182+
];
183+
for (const [label, def, declared] of LIST_HOLDING) {
184+
it(`${label}: refused, naming the declaration`, () => {
185+
const objects = [{ ...deal, fields: { ...deal.fields, subject: { label: 'Subject', ...def } } }, account];
186+
const findings = validateSharingRuleEnforceability(stackWith('record.status != record.subject', objects));
187+
expect(findings.map((f) => f.rule)).toEqual([SHARING_RULE_UNLOWERABLE_CONDITION]);
188+
expect(findings[0].message).toContain(`\`record.status != record.subject\`, where \`subject\` is declared ${declared}.`);
189+
});
190+
}
191+
});
192+
193+
describe('validateSharingRuleEnforceability — the one-value spellings stay CLEAN (#19886 controls)', () => {
194+
const CONTROLS: ReadonlyArray<[string, string]> = [
195+
['text != text', 'record.status != record.owner_name'],
196+
['text == text', 'record.status == record.owner_name'],
197+
['!(text == text)', '!(record.status == record.owner_name)'],
198+
['number > number', 'record.amount > record.budget'],
199+
['date <= date', 'record.close_date <= record.signed_date'],
200+
['a single lookup == text', 'record.account == record.owner_name'],
201+
['a single select != text', 'record.stage != record.status'],
202+
['a json field against a literal (not a field-to-field comparison)', "record.tags == 'a'"],
203+
['a json field null test', 'record.tags != null'],
204+
['a flat literal list', "record.status in ['open', 'pending']"],
205+
];
206+
for (const [label, condition] of CONTROLS) {
207+
it(`${label}: \`${condition}\``, () => {
208+
expect(validateSharingRuleEnforceability(stackWith(condition))).toEqual([]);
209+
});
210+
}
211+
212+
it('a single-valued field of a multi-capable type is one value: `select`, `lookup`, `user`, `file`', () => {
213+
for (const def of [{ type: 'select' }, { type: 'lookup', reference: 'account' }, { type: 'user' }, { type: 'file' }]) {
214+
const objects = [{ ...deal, fields: { ...deal.fields, subject: { label: 'Subject', ...def } } }, account];
215+
expect(validateSharingRuleEnforceability(stackWith('record.status != record.subject', objects)), def.type).toEqual([]);
216+
}
217+
});
218+
});
219+
220+
describe('validateSharingRuleEnforceability — the arm is the graph\'s, and reports once (#19886)', () => {
221+
it('judges nothing the graph cannot answer: an anchor outside the stack, a field map it cannot read, an undeclared name', () => {
222+
expect(validateSharingRuleEnforceability(stackWith('record.status != record.tags', [account]))).toEqual([]);
223+
expect(validateSharingRuleEnforceability(stackWith('record.status != record.tags', [{ name: 'deal', sharingModel: 'private' }]))).toEqual([]);
224+
expect(validateSharingRuleEnforceability(stackWith('record.status != record.nope'))).toEqual([]);
225+
});
226+
227+
it('one defect, one finding: a condition the compiler already refuses keeps its own finding alone', () => {
228+
const findings = validateSharingRuleEnforceability(stackWith('size(record.tags) > 0 && record.status != record.tags'));
229+
expect(findings).toHaveLength(1);
230+
expect(findings[0].rule).toBe(SHARING_RULE_UNLOWERABLE_CONDITION);
231+
expect(findings[0].message).toMatch(/is outside the pushdown subset/);
232+
expect(findings[0].message).not.toMatch(/lowers, but compares/);
233+
});
234+
235+
it('reports beside the anchor arm, independently — two fields, two findings', () => {
236+
const publicDeal = { ...deal, sharingModel: 'public_read_write' };
237+
const findings = validateSharingRuleEnforceability(stackWith('record.status != record.tags', [publicDeal, account]));
238+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
239+
['sharing-rule-object-not-shareable', 'sharingRules[0].object'],
240+
[SHARING_RULE_UNLOWERABLE_CONDITION, 'sharingRules[0].condition'],
241+
]);
242+
});
243+
244+
it('reaches the author through `os validate`\'s rule table', () => {
245+
const stack = stackWith('record.reviewers == record.status');
246+
const cli = runAuthoringRules('validate', { normalized: stack, parsed: stack }).filter((f) => f.rule.startsWith('sharing-rule-'));
247+
expect(cli.map((f) => ({ rule: f.rule, path: f.path }))).toEqual([
248+
{ rule: SHARING_RULE_UNLOWERABLE_CONDITION, path: 'sharingRules[0].condition' },
249+
]);
250+
expect(cli[0].message).toContain('`record.reviewers == record.status`, where `reviewers` is declared');
251+
});
252+
});

0 commit comments

Comments
 (0)