Skip to content

Commit bc400af

Browse files
os-warrenclaude
andauthored
fix(plugin-sharing): scope an org-stamped rule's criteria sweep to its own organization (#10422)
`SharingRuleService.findMatchingRecords` and `recordMatches` ran the rule's criteria query under a bare `SYSTEM_CTX` carrying no tenant, for every rule. The recipient half was already org-aware (`expandRecipient` threads `rule.organization_id` into the team / business-unit / position graphs), so an org-stamped rule expanded recipients inside its own organization and then swept every other organization's records for matches — and `reconcile` materialized the cross product as `sys_record_share` rows. Thread the rule's own `organization_id` as `tenantId` on the criteria read when it is non-null. The platform's existing chokepoint does the scoping: `buildDriverOptions` forwards it to `DriverOptions.tenantId` and `SqlDriver.applyTenantScope` emits `(organization_id = ? OR organization_id IS NULL)`, so a scoped rule still sees platform-owned null-org rows. The system elevation is retained — elevation and tenant are separate axes. A null-org (platform-global) rule keeps the full unscoped sweep, which is its declared behaviour. Both directions are pinned in `rule-criteria-org-scope.test.ts` against a real ObjectQL on a real SqlDriver, reading the materialized `sys_record_share` rows at rest (the cross-org rows are inert under the Layer-0 wall, so a read probe would show nothing). Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d2d112 commit bc400af

3 files changed

Lines changed: 396 additions & 2 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
**Behaviour change (narrowing):** an **org-stamped** sharing rule's criteria sweep is now scoped to that rule's own organization, where it previously swept **every** organization's records (#10119).
6+
7+
`SharingRuleService.findMatchingRecords` (the whole-rule evaluation pass) and `recordMatches` (the per-record write-hook pass) ran the rule's criteria query under a bare system context carrying no tenant, for every rule. The recipient half was already org-aware — `expandRecipient` threads `rule.organization_id` into the team / business-unit / position graph services — so a rule stamped with an `organization_id` expanded recipients inside its own organization and then matched records belonging to all the others. `reconcile` materialized the cross product: `sys_record_share` rows granting one organization's users access to another organization's records.
8+
9+
Measured on `main` before the change, through a real `ObjectQL` on a real `SqlDriver`: an `org_a`-stamped rule matched **the same four records as a platform-global rule** (`deal_a1`, `deal_b1`, `deal_b2`, `deal_p1`) and materialized a grant on each; the per-record hook pass minted a grant on `org_b`'s record with `grantsCreated: 1`.
10+
11+
What changes, and for whom:
12+
13+
- **Org-stamped rules** (`organization_id` non-null — what any org admin mints through `defineRule`) now run their criteria query with `tenantId` set to the rule's organization. The platform's existing chokepoint does the rest: `ObjectQLEngine.buildDriverOptions` threads it to `DriverOptions.tenantId` and `SqlDriver.applyTenantScope` emits `(organization_id = ? OR organization_id IS NULL)`. So such a rule matches its own organization's records **plus** platform-owned null-org records, and no other tenant's. `SharingRuleEvaluationResult.matchedRecords` falls accordingly, and the next reconcile pass **revokes** the cross-org `sys_record_share` rows it previously created, through the existing revoke-the-remainder branch — no migration is needed.
14+
- **Platform-global rules** (`organization_id = null`) are unchanged: they keep the full unscoped sweep, which is their declared behaviour (documented at the `deleteRule` platform-authority guard). Both directions are pinned.
15+
- **No public contract changes.** No schema, route, error code or accept/reject set moves; the system elevation on the criteria read is retained (the evaluator still sees rows no individual recipient could), only the tenant axis is added.
16+
17+
The cross-org rows this stops creating were **inert** under a walled posture — the Layer-0 tenant wall AND-composes over sharing's Layer-1 widening, so such a grant could not open a read across the wall. The costs were `sys_record_share` bloat (every org-stamped rule scanning the whole table at `limit: 5000`) and a population that is wrong at rest, which any consumer reading `sys_record_share` directly, or any future softening of the wall, would inherit.
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#10119] A sharing rule's CRITERIA sweep is scoped to the rule's own
5+
* organization — and a platform-global rule's is still not.
6+
*
7+
* ## The defect
8+
*
9+
* `SharingRuleService.findMatchingRecords` / `recordMatches` ran the rule's
10+
* criteria query under a bare `SYSTEM_CTX` carrying no tenant, for EVERY rule.
11+
* The recipient half was already org-aware (`expandRecipient` threads
12+
* `rule.organization_id` into `TeamGraphService` / `BusinessUnitGraphService` /
13+
* `PositionGraphService`), so an org-stamped rule expanded recipients inside
14+
* its own organization and then swept every organization's records for
15+
* matches. `reconcile` materialised the cross product: `sys_record_share` rows
16+
* granting one org's users access to another org's records.
17+
*
18+
* ## Why this asserts rows AT REST rather than a read probe
19+
*
20+
* Those rows are INERT today. The Layer-0 tenant wall AND-composes over
21+
* sharing's Layer-1 widening, so a cross-org grant cannot open a read across
22+
* the wall. A "can this principal read it" probe therefore shows nothing on
23+
* either side of the fix and would read as "no defect". What is wrong is the
24+
* materialised population itself — `sys_record_share` bloat now, and rows that
25+
* become load-bearing the day anything reads that table directly or the wall
26+
* softens. So every assertion below reads `sys_record_share` straight off the
27+
* driver, unscoped, and asks which records were granted.
28+
*
29+
* ## Why a real driver
30+
*
31+
* The scope is a DRIVER decision: `SqlDriver.applyTenantScope` turns
32+
* `DriverOptions.tenantId` into `(organization_id = ? OR organization_id IS
33+
* NULL)`, and the engine only threads it when `execCtx.tenantId` is set on a
34+
* non-federated, tenancy-enabled object (`ObjectQLEngine.buildDriverOptions`).
35+
* A hand-written engine double proves none of that chain — it would report
36+
* green on a `tenantId` the real stack never applies. So these cases run a real
37+
* `SqlDriver` on better-sqlite3 `:memory:` behind a real `ObjectQL`, the way
38+
* `share-link-eligibility.test.ts` and `read-scope-provenance-mark.test.ts`
39+
* already do in this package.
40+
*
41+
* ## Both directions, deliberately
42+
*
43+
* A suite that only pinned "an org-stamped rule stops sweeping other orgs"
44+
* would stay green against an implementation that scoped EVERY rule — silently
45+
* killing the platform-global sweep that `organization_id = null` rules are
46+
* declared to perform (#7795, documented at the `deleteRule` guard). So each
47+
* org-stamped case is stated beside the null-org case it must not become.
48+
*
49+
* The NULL-org RECORD is here for the third direction: `applyTenantScope`
50+
* emits `field = ? OR field IS NULL` on purpose (#2734 — a bare equality hid
51+
* every platform-seeded row from every tenant). A scope "fixed" with a bare
52+
* equality would pass the cross-org assertions and silently lose the platform
53+
* record, so `deal_p1` is what distinguishes routing through the chokepoint
54+
* from reimplementing a worse copy of it.
55+
*/
56+
57+
import { describe, it, expect, afterEach } from 'vitest';
58+
import { ObjectQL } from '@objectstack/objectql';
59+
import { SqlDriver } from '@objectstack/driver-sql';
60+
import type { ExecutionContext } from '@objectstack/spec/kernel';
61+
62+
import { SharingService } from './sharing-service.js';
63+
import { SharingRuleService } from './sharing-rule-service.js';
64+
65+
const OBJECT = 'os10119_deal';
66+
67+
const DEAL_FIELDS: Record<string, Record<string, unknown>> = {
68+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
69+
stage: { type: 'text', name: 'stage', label: 'Stage' },
70+
owner_id: { type: 'text', name: 'owner_id', label: 'Owner' },
71+
organization_id: { type: 'text', name: 'organization_id', label: 'Org' },
72+
};
73+
74+
const SHARE_FIELDS: Record<string, Record<string, unknown>> = {
75+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
76+
object_name: { type: 'text', name: 'object_name', label: 'Object' },
77+
record_id: { type: 'text', name: 'record_id', label: 'Record' },
78+
recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' },
79+
recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' },
80+
access_level: { type: 'text', name: 'access_level', label: 'Access' },
81+
source: { type: 'text', name: 'source', label: 'Source' },
82+
source_id: { type: 'text', name: 'source_id', label: 'Source id' },
83+
reason: { type: 'text', name: 'reason', label: 'Reason' },
84+
granted_by: { type: 'text', name: 'granted_by', label: 'Grantor' },
85+
created_at: { type: 'text', name: 'created_at', label: 'Created' },
86+
updated_at: { type: 'text', name: 'updated_at', label: 'Updated' },
87+
};
88+
89+
const RULE_FIELDS: Record<string, Record<string, unknown>> = {
90+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
91+
organization_id: { type: 'text', name: 'organization_id', label: 'Org' },
92+
name: { type: 'text', name: 'name', label: 'Name' },
93+
label: { type: 'text', name: 'label', label: 'Label' },
94+
description: { type: 'text', name: 'description', label: 'Description' },
95+
object_name: { type: 'text', name: 'object_name', label: 'Object' },
96+
criteria_json: { type: 'text', name: 'criteria_json', label: 'Criteria' },
97+
recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' },
98+
recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' },
99+
access_level: { type: 'text', name: 'access_level', label: 'Access' },
100+
active: { type: 'boolean', name: 'active', label: 'Active' },
101+
managed_by: { type: 'text', name: 'managed_by', label: 'Managed by' },
102+
customized: { type: 'boolean', name: 'customized', label: 'Customized' },
103+
created_at: { type: 'text', name: 'created_at', label: 'Created' },
104+
updated_at: { type: 'text', name: 'updated_at', label: 'Updated' },
105+
};
106+
107+
const ORG_A = 'org_a';
108+
const ORG_B = 'org_b';
109+
110+
/** The evaluator's own elevation — what every internal pass already carries. */
111+
const SYSTEM: ExecutionContext = { isSystem: true, positions: [], permissions: [] };
112+
113+
/** An ORG_A sharing administrator — what stamps `organization_id` on a rule. */
114+
const ORG_A_ADMIN = {
115+
tenantId: ORG_A,
116+
positions: [],
117+
permissions: [],
118+
systemPermissions: ['manage_sharing'],
119+
} as unknown as ExecutionContext;
120+
121+
interface Booted {
122+
driver: SqlDriver;
123+
ql: ObjectQL;
124+
rules: SharingRuleService;
125+
/** Every `sys_record_share` row in the database, read straight off the driver. */
126+
sharedRecordIds: () => Promise<string[]>;
127+
}
128+
129+
const open: SqlDriver[] = [];
130+
131+
/**
132+
* A fresh database, engine and rule service, seeded with the same five deals
133+
* every case reasons about:
134+
*
135+
* deal_a1 ORG_A stage=won <- ORG_A's own match
136+
* deal_a2 ORG_A stage=lost <- ORG_A's own non-match (criteria still bite)
137+
* deal_b1 ORG_B stage=won <- the cross-org match this card is about
138+
* deal_b2 ORG_B stage=won <- second one, so a count cannot pass by luck
139+
* deal_p1 (null) stage=won <- platform row: belongs to no tenant, so it
140+
* belongs to no OTHER tenant either (#2734)
141+
*/
142+
async function boot(): Promise<Booted> {
143+
const driver = new SqlDriver({
144+
client: 'better-sqlite3',
145+
connection: { filename: ':memory:' },
146+
useNullAsDefault: true,
147+
});
148+
open.push(driver);
149+
150+
const ql = new ObjectQL();
151+
ql.registerDriver(driver as never, true);
152+
await ql.init();
153+
ql.registerObject({
154+
name: OBJECT,
155+
label: 'Deal',
156+
sharingModel: 'private',
157+
fields: DEAL_FIELDS,
158+
} as never);
159+
ql.registerObject({
160+
name: 'sys_record_share',
161+
label: 'Record Share',
162+
isSystem: true,
163+
fields: SHARE_FIELDS,
164+
} as never);
165+
ql.registerObject({
166+
name: 'sys_sharing_rule',
167+
label: 'Sharing Rule',
168+
isSystem: true,
169+
fields: RULE_FIELDS,
170+
} as never);
171+
await driver.initObjects([
172+
{ name: OBJECT, fields: DEAL_FIELDS } as never,
173+
{ name: 'sys_record_share', fields: SHARE_FIELDS } as never,
174+
{ name: 'sys_sharing_rule', fields: RULE_FIELDS } as never,
175+
]);
176+
177+
// Seeded through the driver, not the engine: the fixture is the DATA at
178+
// rest, and routing it through the engine's write path would drag the
179+
// system-write organization rules (#8844) into a card about reads.
180+
await driver.create(OBJECT, { id: 'deal_a1', stage: 'won', owner_id: 'u_a', organization_id: ORG_A } as never);
181+
await driver.create(OBJECT, { id: 'deal_a2', stage: 'lost', owner_id: 'u_a', organization_id: ORG_A } as never);
182+
await driver.create(OBJECT, { id: 'deal_b1', stage: 'won', owner_id: 'u_b', organization_id: ORG_B } as never);
183+
await driver.create(OBJECT, { id: 'deal_b2', stage: 'won', owner_id: 'u_b', organization_id: ORG_B } as never);
184+
await driver.create(OBJECT, { id: 'deal_p1', stage: 'won', owner_id: 'u_p' } as never);
185+
186+
const sharing = new SharingService({ engine: ql as never });
187+
const rules = new SharingRuleService({ engine: ql as never, sharing });
188+
189+
return {
190+
driver,
191+
ql,
192+
rules,
193+
sharedRecordIds: async () => {
194+
const rows = await driver.find('sys_record_share', {} as never);
195+
return rows.map((r: any) => String(r.record_id)).sort();
196+
},
197+
};
198+
}
199+
200+
afterEach(async () => {
201+
while (open.length) await open.pop()?.disconnect?.();
202+
});
203+
204+
/** `criteria` every seeded `won` deal matches, in every organization. */
205+
const WON = { stage: 'won' };
206+
207+
describe('[#10119] sharing-rule criteria sweep is scoped to the rule\'s organization', () => {
208+
describe('findMatchingRecords — the whole-rule evaluation pass', () => {
209+
it('an ORG-STAMPED rule materialises NO grant on another organization\'s records', async () => {
210+
const { rules, sharedRecordIds } = await boot();
211+
212+
const rule = await rules.defineRule(
213+
{
214+
name: 'os10119_org_a_won',
215+
label: 'ORG_A won deals',
216+
object: OBJECT,
217+
criteria: WON,
218+
recipientType: 'user',
219+
recipientId: 'u_a',
220+
accessLevel: 'read',
221+
} as never,
222+
ORG_A_ADMIN,
223+
);
224+
expect(rule.organization_id).toBe(ORG_A);
225+
226+
const result = await rules.evaluateRule(rule.id, ORG_A_ADMIN);
227+
const granted = await sharedRecordIds();
228+
229+
// ORG_B's records are the defect. Stated as their own assertion so a
230+
// failure names them rather than printing a set diff.
231+
expect(granted).not.toContain('deal_b1');
232+
expect(granted).not.toContain('deal_b2');
233+
// ORG_A's own match, plus the null-org platform row the tenant
234+
// chokepoint deliberately keeps visible (#2734). `deal_a2` is absent
235+
// because the CRITERIA still bite — scoping did not replace them.
236+
expect(granted).toEqual(['deal_a1', 'deal_p1']);
237+
expect(result.matchedRecords).toBe(2);
238+
});
239+
240+
it('a NULL-ORG (platform-global) rule still sweeps EVERY organization', async () => {
241+
const { rules, sharedRecordIds } = await boot();
242+
243+
// Defined under the system context, which is what stamps
244+
// `organization_id: null` — the platform-global class (#7795).
245+
const rule = await rules.defineRule(
246+
{
247+
name: 'os10119_platform_won',
248+
label: 'Platform won deals',
249+
object: OBJECT,
250+
criteria: WON,
251+
recipientType: 'user',
252+
recipientId: 'u_plat',
253+
accessLevel: 'read',
254+
} as never,
255+
SYSTEM,
256+
);
257+
expect(rule.organization_id).toBeNull();
258+
259+
const result = await rules.evaluateRule(rule.id, SYSTEM);
260+
const granted = await sharedRecordIds();
261+
262+
// The declared platform-global behaviour, pinned so scoping the
263+
// org-stamped case above cannot quietly take it away.
264+
expect(granted).toEqual(['deal_a1', 'deal_b1', 'deal_b2', 'deal_p1']);
265+
expect(result.matchedRecords).toBe(4);
266+
});
267+
});
268+
269+
describe('recordMatches — the per-record write-hook pass', () => {
270+
it('an ORG-STAMPED rule does not match another organization\'s record', async () => {
271+
const { rules, sharedRecordIds } = await boot();
272+
273+
await rules.defineRule(
274+
{
275+
name: 'os10119_org_a_won_hook',
276+
label: 'ORG_A won deals',
277+
object: OBJECT,
278+
criteria: WON,
279+
recipientType: 'user',
280+
recipientId: 'u_a',
281+
accessLevel: 'read',
282+
} as never,
283+
ORG_A_ADMIN,
284+
);
285+
286+
// The afterInsert/afterUpdate shape: one record, every rule on the
287+
// object. `deal_b1` belongs to ORG_B and this rule to ORG_A.
288+
const results = await rules.evaluateAllForRecord(OBJECT, 'deal_b1', SYSTEM);
289+
290+
expect(results).toHaveLength(1);
291+
expect(results[0]!.grantsCreated).toBe(0);
292+
expect(await sharedRecordIds()).toEqual([]);
293+
});
294+
295+
it('a NULL-ORG rule still matches every organization\'s record', async () => {
296+
const { rules, sharedRecordIds } = await boot();
297+
298+
await rules.defineRule(
299+
{
300+
name: 'os10119_platform_won_hook',
301+
label: 'Platform won deals',
302+
object: OBJECT,
303+
criteria: WON,
304+
recipientType: 'user',
305+
recipientId: 'u_plat',
306+
accessLevel: 'read',
307+
} as never,
308+
SYSTEM,
309+
);
310+
311+
const results = await rules.evaluateAllForRecord(OBJECT, 'deal_b1', SYSTEM);
312+
313+
expect(results).toHaveLength(1);
314+
expect(results[0]!.grantsCreated).toBe(1);
315+
expect(await sharedRecordIds()).toEqual(['deal_b1']);
316+
});
317+
});
318+
});

0 commit comments

Comments
 (0)