Skip to content

Commit 5b217bd

Browse files
committed
fix(plugin-sharing): a seeded business unit is a usable sharing-rule recipient, and its members are tenant-screened (#14547)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
1 parent 7da4cc2 commit 5b217bd

4 files changed

Lines changed: 598 additions & 34 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14547] A business-unit sharing rule against a SEEDED unit, end to end.
5+
*
6+
* `business-unit-graph.test.ts` pins the two screens at the graph service.
7+
* This file drives the whole rule path — `evaluateRule` → `expandRecipient` →
8+
* `reconcile` → `sys_record_share` — because that is the layer the defect was
9+
* reported at and the layer at which it was silent: the rule was accepted, it
10+
* stayed `active: true`, it materialised zero grants, and nothing was logged.
11+
* Asserting the graph's return value alone would leave every one of those
12+
* observable facts unpinned.
13+
*
14+
* The fixture is the reported reproduction's shape, not an invented one:
15+
*
16+
* - `sys_business_unit` rows come from app SEED data and carry
17+
* `organization_id = NULL` — a seed cannot know the id the runtime mints
18+
* at boot;
19+
* - `sys_business_unit_member` rows are POSTed through the REST data API and
20+
* ARE organization-stamped (the engine threads the caller's tenant and the
21+
* SQL driver stamps the injected column);
22+
* - the `sys_sharing_rule` row is created by an organization admin and is
23+
* org-stamped too (an explicit `organization_id: null` in the payload is
24+
* overridden).
25+
*
26+
* Two of those three carry an organization and one does not, which is exactly
27+
* the combination the strict unit screen turned into zero grants.
28+
*/
29+
30+
import { describe, it, expect, beforeEach, vi } from 'vitest';
31+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
32+
import { SharingService } from './sharing-service.js';
33+
import { SharingRuleService } from './sharing-rule-service.js';
34+
35+
interface Row { [k: string]: any }
36+
37+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
38+
const ORG_A = 'org_a';
39+
const ORG_B = 'org_b';
40+
41+
/**
42+
* Filter matcher over the operators this path actually emits.
43+
*
44+
* `organization_id: null` must match a row that OMITS the column, because that
45+
* is what a NULL column reads back as and the whole `$or` arm exists for it. A
46+
* fake that answered otherwise would report the widened screen as still broken
47+
* — or, worse, report a screen that never widened as fixed.
48+
*/
49+
function matches(row: Row, f: any): boolean {
50+
if (!f || typeof f !== 'object') return true;
51+
for (const [k, v] of Object.entries(f)) {
52+
if (k === '$or') {
53+
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
54+
continue;
55+
}
56+
if (k === '$and') {
57+
if (!(v as any[]).every((sub) => matches(row, sub))) return false;
58+
continue;
59+
}
60+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
61+
const rv = row[k];
62+
if (v === null) {
63+
if (rv != null) return false;
64+
continue;
65+
}
66+
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
67+
const op: any = v;
68+
if ('$in' in op) { if (!op.$in.includes(rv)) return false; continue; }
69+
// `descendants()` filters children with `active: { $ne: false }`, so an
70+
// undefined `active` must PASS — the graph treats absent as active.
71+
if ('$ne' in op) { if (rv === op.$ne) return false; continue; }
72+
if ('$gte' in op) { if (!(rv >= op.$gte)) return false; continue; }
73+
}
74+
if (rv !== v) return false;
75+
}
76+
return true;
77+
}
78+
79+
function makeEngine() {
80+
const tables: Record<string, Row[]> = {};
81+
const ensure = (n: string) => (tables[n] ??= []);
82+
let seq = 0;
83+
return {
84+
_tables: tables,
85+
getSchema() { return undefined; },
86+
seed(object: string, rows: Row[]) { ensure(object).push(...rows.map((r) => ({ ...r }))); },
87+
async find(o: string, opts?: any) {
88+
const f = opts?.filter ?? opts?.where;
89+
return ensure(o).filter((r) => matches(r, f)).slice(0, opts?.limit ?? 10000);
90+
},
91+
async insert(o: string, data: any) {
92+
const row = { id: data.id ?? `${o}_${++seq}`, ...data };
93+
ensure(o).push(row);
94+
return row;
95+
},
96+
// The PRODUCER's own dispatch predicates, so a fixture that drifts to a
97+
// call shape `ObjectQL` would refuse fails here instead of going green.
98+
async update(o: string, data: any, options?: any) {
99+
const verdict = assertEngineUpdateDispatch(data, options);
100+
const t = ensure(o);
101+
const targets = verdict.kind === 'by-id'
102+
? t.filter((r) => r.id === verdict.id)
103+
: t.filter((r) => matches(r, options?.where));
104+
for (const r of targets) Object.assign(r, data);
105+
return verdict.kind === 'by-id' ? (targets[0] ?? null) : targets.length;
106+
},
107+
async delete(o: string, opts?: any) {
108+
assertEngineDeleteDispatch(opts);
109+
const t = ensure(o);
110+
const where = opts?.where ?? (opts?.id != null ? { id: opts.id } : {});
111+
for (let i = t.length - 1; i >= 0; i--) if (matches(t[i], where)) t.splice(i, 1);
112+
return { ok: true };
113+
},
114+
};
115+
}
116+
117+
const RULE = 'kpi_sheet_to_market_unit';
118+
119+
describe('#14547 — an org-stamped rule against a SEEDED business unit', () => {
120+
let engine: ReturnType<typeof makeEngine>;
121+
let rules: SharingRuleService;
122+
let warn: ReturnType<typeof vi.fn>;
123+
124+
/** Who currently holds a rule-materialised grant on `recordId`. */
125+
const granteesOf = (recordId: string): string[] =>
126+
(engine._tables.sys_record_share ?? [])
127+
.filter((r) => r.record_id === recordId && r.source === 'rule')
128+
.map((r) => String(r.recipient_id))
129+
.sort();
130+
131+
/** The warn lines this run emitted, as one searchable string each. */
132+
const warnLines = (): string[] => warn.mock.calls.map((c) => String(c[0]));
133+
const emptyExpansionWarns = (): any[][] =>
134+
warn.mock.calls.filter((c) => String(c[0]).includes('expands to NO recipients'));
135+
136+
beforeEach(() => {
137+
engine = makeEngine();
138+
warn = vi.fn();
139+
const sharing = new SharingService({ engine: engine as any });
140+
rules = new SharingRuleService({ engine: engine as any, sharing, logger: { warn } });
141+
142+
// Seed data: units written before any organization existed.
143+
engine.seed('sys_business_unit', [
144+
{ id: 'bu_market', name: 'Market', parent_business_unit_id: null, organization_id: null, active: true },
145+
{ id: 'bu_market_west', name: 'Market West', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
146+
]);
147+
engine.seed('kpi_entry_sheet', [{ id: 'kpi_1', subject: 'bu_market', owner_id: 'author' }]);
148+
});
149+
150+
/** Create the rule the reproduction created, org-stamped like a real one. */
151+
const seedRule = (recipientType: 'business_unit' | 'unit_and_subordinates', organizationId: string | null = ORG_A) => {
152+
engine.seed('sys_sharing_rule', [{
153+
id: 'srule_kpi', organization_id: organizationId, name: RULE,
154+
label: 'KPI sheet → Market', object_name: 'kpi_entry_sheet',
155+
criteria_json: JSON.stringify({ subject: 'bu_market' }),
156+
recipient_type: recipientType, recipient_id: 'bu_market',
157+
access_level: 'edit', active: true, managed_by: 'package',
158+
}]);
159+
};
160+
161+
describe('the reported defect: 201, active, zero shares, no log', () => {
162+
it('WIDE — `unit_and_subordinates` now materialises the grants', async () => {
163+
engine.seed('sys_business_unit_member', [
164+
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
165+
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
166+
]);
167+
seedRule('unit_and_subordinates');
168+
const result = await rules.evaluateRule(RULE, SYS);
169+
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
170+
expect(result.expandedUsers).toBe(2);
171+
// …and it did so QUIETLY: the new warn is for the empty case only.
172+
expect(emptyExpansionWarns()).toHaveLength(0);
173+
});
174+
175+
it('NARROW — `business_unit` materialises the anchor unit only', async () => {
176+
engine.seed('sys_business_unit_member', [
177+
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
178+
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2', organization_id: ORG_A },
179+
]);
180+
seedRule('business_unit');
181+
await rules.evaluateRule(RULE, SYS);
182+
// The two widths stay two widths (#7807) — the tenant screen moved, the
183+
// subtree boundary did not.
184+
expect(granteesOf('kpi_1')).toEqual(['u_1']);
185+
});
186+
});
187+
188+
describe('the leak the same change would have opened', () => {
189+
it('another organization’s members are never granted through the shared seeded unit', async () => {
190+
// ONE seeded unit id, two tenants' memberships hanging off it — the
191+
// shape that exists on any deployment whose org chart came from a seed.
192+
engine.seed('sys_business_unit_member', [
193+
{ id: 'bum_a', business_unit_id: 'bu_market', user_id: 'u_a', organization_id: ORG_A },
194+
{ id: 'bum_b', business_unit_id: 'bu_market', user_id: 'u_b', organization_id: ORG_B },
195+
]);
196+
seedRule('unit_and_subordinates', ORG_A);
197+
await rules.evaluateRule(RULE, SYS);
198+
expect(granteesOf('kpi_1')).toEqual(['u_a']);
199+
expect(granteesOf('kpi_1')).not.toContain('u_b');
200+
});
201+
});
202+
203+
describe('an active rule that grants nobody is LOUD', () => {
204+
it('warns naming the rule, the recipient kind and the unit', async () => {
205+
// Unit and memberships BOTH seeded: the unit resolves now, but org-less
206+
// membership rows are of unknown tenancy and are not members of an
207+
// org-stamped rule. The residual empty expansion is the case this warn
208+
// exists for.
209+
engine.seed('sys_business_unit_member', [
210+
{ id: 'bum_seeded', business_unit_id: 'bu_market', user_id: 'u_seeded' },
211+
]);
212+
seedRule('unit_and_subordinates');
213+
await rules.evaluateRule(RULE, SYS);
214+
215+
expect(granteesOf('kpi_1')).toEqual([]);
216+
const calls = emptyExpansionWarns();
217+
expect(calls).toHaveLength(1);
218+
expect(String(calls[0][0])).toContain('organization_id');
219+
expect(calls[0][1]).toMatchObject({
220+
rule: RULE,
221+
object: 'kpi_entry_sheet',
222+
recipientType: 'unit_and_subordinates',
223+
businessUnit: 'bu_market',
224+
organization: ORG_A,
225+
});
226+
});
227+
228+
it('warns for the NARROW width too', async () => {
229+
seedRule('business_unit');
230+
await rules.evaluateRule(RULE, SYS);
231+
expect(emptyExpansionWarns()).toHaveLength(1);
232+
expect(emptyExpansionWarns()[0][1]).toMatchObject({ recipientType: 'business_unit' });
233+
});
234+
235+
it('warns ONCE per rule per process, not once per evaluation', async () => {
236+
// The reconcilers call `expandRecipient` on every matched write. Without
237+
// the dedup one misconfigured rule dominates the deployment's log —
238+
// the same reasoning the inert-criteria warn already carries.
239+
seedRule('unit_and_subordinates');
240+
await rules.evaluateRule(RULE, SYS);
241+
await rules.evaluateRule(RULE, SYS);
242+
await rules.evaluateRule(RULE, SYS);
243+
expect(emptyExpansionWarns()).toHaveLength(1);
244+
expect(rules.emptyUnitExpansionRuleKeys).toEqual(['srule_kpi::bu_market']);
245+
});
246+
247+
it('says nothing when the rule grants somebody', async () => {
248+
engine.seed('sys_business_unit_member', [
249+
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
250+
]);
251+
seedRule('unit_and_subordinates');
252+
await rules.evaluateRule(RULE, SYS);
253+
expect(warnLines().join('\n')).not.toContain('expands to NO recipients');
254+
expect(rules.emptyUnitExpansionRuleKeys).toEqual([]);
255+
});
256+
257+
it('an INACTIVE rule is not warned about — it is meant to grant nobody', async () => {
258+
engine.seed('sys_sharing_rule', [{
259+
id: 'srule_off', organization_id: ORG_A, name: 'off_rule',
260+
label: 'Off', object_name: 'kpi_entry_sheet',
261+
criteria_json: JSON.stringify({ subject: 'bu_market' }),
262+
recipient_type: 'unit_and_subordinates', recipient_id: 'bu_market',
263+
access_level: 'edit', active: false, managed_by: 'package',
264+
}]);
265+
await rules.evaluateRule('off_rule', SYS);
266+
expect(emptyExpansionWarns()).toHaveLength(0);
267+
});
268+
});
269+
270+
describe('the org-less rule — the dominant shape today — is unmoved', () => {
271+
it('still expands every member of the seeded tree, stamped or not', async () => {
272+
engine.seed('sys_business_unit_member', [
273+
{ id: 'bum_1', business_unit_id: 'bu_market', user_id: 'u_1', organization_id: ORG_A },
274+
{ id: 'bum_2', business_unit_id: 'bu_market_west', user_id: 'u_2' },
275+
]);
276+
seedRule('unit_and_subordinates', null);
277+
await rules.evaluateRule(RULE, SYS);
278+
expect(granteesOf('kpi_1')).toEqual(['u_1', 'u_2']);
279+
});
280+
});
281+
});

0 commit comments

Comments
 (0)