Skip to content

Commit 31430d3

Browse files
committed
revert(plugin-sharing): drop the NULL-inclusive business-unit screen before 17.3
Restore `BusinessUnitGraphService.orgScope` to the strict `organization_id = <rule org>` equality 17.2.0 ships, byte-identical to its form before e560b4d (#14949), and delete the docblock that argued for the NULL-inclusive shape. Maintainer ruling, ADR-0131 D8: that shape re-implements the predicate `SqlDriver.applyTenantScope` already owns, a second time in a second place, which is the duplication ADR-0131 exists to retire (#10103 cause 1) — and it had not shipped, so reverting costs nothing while releasing it would owe v18 a breaking change and a migration. KEPT, untouched: `memberScope` (byte-identical to origin/main) and `SharingRuleService.warnOnEmptyUnitExpansion`. Both member reads previously carried no organization predicate at all; a strict unit screen narrows which units are reachable but does not close that, because other organizations' member rows sit on org-stamped units too. #14547 therefore remains open and behaves as in 17.2.0. It is fixed structurally on the v18 line by ADR-0131 C1. Tests: the cases that pinned the NULL-inclusive unit screen now pin the restored behaviour and name #14547 as the open defect they reproduce. The member-screen pins are re-anchored from a seeded (org-less) unit onto an org-stamped one — with the unit screen strict, an org-less unit is invisible to an org-stamped rule, so those assertions would otherwise pass with `memberScope` deleted. See the PR body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
1 parent 8a96e66 commit 31430d3

4 files changed

Lines changed: 315 additions & 173 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
revert(plugin-sharing): drop the NULL-inclusive business-unit screen added after 17.2.0
6+
7+
17.3 does not ship the NULL-inclusive business-unit screen added after 17.2.0;
8+
#14547 remains as in 17.2.0 and is fixed structurally in v18 (ADR-0131 C1: the
9+
Default Organization exists before application seed datasets load, and the seed
10+
loader stamps `sys_business_unit` seeds).
11+
12+
`BusinessUnitGraphService.orgScope` briefly read
13+
`$or: [{ organization_id: <rule org> }, { organization_id: null }]` so that an
14+
org-stamped sharing rule could name a seeded (org-less) `sys_business_unit`
15+
row. It is restored to the strict `organization_id = <rule org>` equality
16+
17.2.0 ships. That shape re-implemented, a second time and in a second place,
17+
the predicate `SqlDriver.applyTenantScope` already owns — the duplication
18+
ADR-0131 exists to retire (#10103 cause 1) — and it had not been released, so
19+
reverting costs nothing while shipping it would have owed v18 a breaking change
20+
and a migration.
21+
22+
The other half of the same change is KEPT and is not touched:
23+
`BusinessUnitGraphService.memberScope` still screens both
24+
`sys_business_unit_member` reads with a strict equality. Those reads previously
25+
carried no organization predicate at all, so an org-stamped rule reaching any
26+
visible unit collected every tenant's membership rows hanging off it; a strict
27+
unit screen narrows which units are reachable but does not close that, because
28+
other organizations' member rows sit on org-stamped units too.
29+
`SharingRuleService.warnOnEmptyUnitExpansion` is also kept: it is what keeps the
30+
remaining #14547 symptom loud instead of silent.

packages/plugins/plugin-sharing/src/business-unit-graph.test.ts

Lines changed: 133 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,47 @@
33
/**
44
* BusinessUnitGraphService — the TWO tenant screens, pinned as a pair.
55
*
6-
* ## What this file used to say, and why it changed
6+
* ## Both screens are STRICT equalities, and that is the shipped posture
77
*
8-
* Until #14547 `orgScope()` AND-composed a strict `organization_id = <rule
9-
* org>` equality onto the UNIT read. A unit written with no organization at
10-
* all (a seeded / file-layer / bootstrap row — a seed cannot know the org id
11-
* the runtime mints at boot) therefore matched nothing, the seed check failed,
12-
* and BOTH widths expanded to zero members. #3807 had already fixed exactly
13-
* that on the approvals side; this file recorded the sharing side's divergence
14-
* as deliberate on the grounds that it was unreachable, because every
15-
* materialized `sys_sharing_rule` row carried `organization_id = null`.
8+
* - the UNIT screen (`orgScope`) AND-composes `organization_id = <rule org>`
9+
* onto the `sys_business_unit` read. A unit written with no organization
10+
* at all (a seeded / file-layer / bootstrap row — a seed cannot know the
11+
* org id the runtime mints at boot) therefore matches nothing for an
12+
* org-stamped rule, the seed check fails, and BOTH widths expand to zero
13+
* members;
14+
* - the MEMBER screen (`memberScope`) AND-composes the same equality onto
15+
* the `sys_business_unit_member` reads. Both member reads used to carry no
16+
* organization predicate at all — completely unscoped by organization —
17+
* which is the cross-tenant hole #14949 closed and this file still pins.
1618
*
17-
* It was reachable. #14547 is the external report: an org admin creating a
18-
* rule at runtime gets an org-stamped rule, the seeded unit carries none, and
19-
* the rule is accepted, stays active, materialises zero `sys_record_share`
20-
* rows and logs nothing. The `[divergence]` test that pinned the old posture
21-
* is gone — replaced, not merely flipped, because an assertion that keeps
22-
* passing while the mechanism under it changes is worse than no assertion.
19+
* ## #14547 is OPEN, and these tests reproduce it rather than fix it
2320
*
24-
* ## The pair this file now pins
21+
* #14547 is the external report of the unit half: an org admin creating a rule
22+
* at runtime gets an org-stamped rule, the seeded unit carries no
23+
* organization, and the rule is accepted, stays active and materialises zero
24+
* `sys_record_share` rows. #14949 briefly closed it by giving the UNIT screen
25+
* the platform's NULL-inclusive `$or` arm. That was reverted before 17.3 was
26+
* cut (ADR-0131 D8): it re-implements the predicate
27+
* `SqlDriver.applyTenantScope` already owns, a second time in a second place —
28+
* the duplication ADR-0131 exists to retire — and it had not shipped.
2529
*
26-
* The fix is ASYMMETRIC and both halves have to be pinned, because each one
27-
* alone is a defect:
30+
* So 17.3 behaves exactly as 17.2.0 does here, and the cases below name
31+
* **#14547** as the open defect they reproduce. It is fixed structurally on
32+
* the v18 line by ADR-0131 C1 (the Default Organization exists before
33+
* application seed datasets load, and the seed loader stamps
34+
* `sys_business_unit` seeds), so the row this screen reads carries an
35+
* organization and no screen has to special-case it. What #14949 left behind —
36+
* and what keeps the 17.x symptom LOUD rather than silent — is
37+
* `SharingRuleService.warnOnEmptyUnitExpansion`.
2838
*
29-
* - the UNIT screen (`orgScope`) is NULL-INCLUSIVE — the platform's own
30-
* `(organization_id = ? OR organization_id IS NULL)`, the predicate
31-
* `SqlDriver.applyTenantScope` writes and `plugin-approvals` already
32-
* applies to these very rows;
33-
* - the MEMBER screen (`memberScope`) is STRICT. Both member reads used to
34-
* carry no organization predicate at all, and the strict unit screen was
35-
* the only thing holding an org-stamped rule away from that unscoped
36-
* query. Widening the unit screen ALONE turns a silent under-grant into a
37-
* silent CROSS-TENANT OVER-GRANT, since a seeded unit id exists
38-
* identically in every tenant.
39+
* ## Why the member screen is pinned on ORG-STAMPED units
3940
*
40-
* So the security half is pinned separately from the functional half below: a
41-
* change that expands the right members while also expanding another
42-
* organization's members satisfies the functional pin completely.
41+
* ⚠️ The member pins below deliberately anchor on a unit stamped with the
42+
* rule's own organization. With the unit screen strict, a seeded (org-less)
43+
* unit is invisible to an org-stamped rule, so a member pin written on one
44+
* would pass no matter what `memberScope` did — the unit screen would answer
45+
* first and the assertion would never reach the member read. Anchoring on a
46+
* visible unit is what keeps these assertions about the MEMBER screen.
4347
*/
4448

4549
import { describe, it, expect } from 'vitest';
@@ -179,33 +183,36 @@ describe('BusinessUnitGraphService — the two widths are actually two widths (#
179183
});
180184

181185
it('the narrow width is org-predicated exactly like the wide one', async () => {
182-
// [#14547] Same fixture, new mechanism — and the mechanism is spelled out
183-
// because the ASSERTION did not move. `DIV_MEMBERS` carry no organization,
184-
// so before #14547 this returned `[]` because the strict UNIT screen hid
185-
// the seeded unit, and after it returns `[]` because the strict MEMBER
186-
// screen refuses membership rows of unknown tenancy. An unchanged
187-
// expectation over a changed cause is exactly the kind of pin that stops
188-
// guarding anything, so the two causes are separated below: the unit is
189-
// now visible (`descendants` sees the whole seeded tree), and it is the
190-
// members that are refused.
186+
// [#14547] `DIV_UNITS` are seeded (org-less) and `DIV_MEMBERS` carry no
187+
// organization either, so BOTH strict screens refuse this fixture. The
188+
// unit screen answers FIRST and is the one being pinned here: the subtree
189+
// walk cannot see the seeded tree at all, which is why the expansion is
190+
// empty. The member screen's own refusal is pinned separately, on a unit
191+
// the rule can actually see, in the MEMBER screen block below.
191192
const g = new BusinessUnitGraphService({
192193
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
193194
organizationId: 'org_a',
194195
});
195196
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
196-
expect((await g.descendants('bu_div')).sort()).toEqual(['bu_dept', 'bu_div', 'bu_office']);
197+
expect(await g.descendants('bu_div')).toEqual([]);
197198
});
198199

199-
it('[#14547] both widths reach org-stamped members of a SEEDED unit tree', async () => {
200-
// The one change that flips the outcome: the membership rows are stamped,
201-
// exactly as a REST/session write stamps them. The units stay seeded.
200+
it('[#14547] NEITHER width reaches a SEEDED unit tree, even with stamped members', async () => {
201+
// ⚠️ This reproduces the OPEN defect #14547 — it does not assert a fix.
202+
// The membership rows are stamped exactly as a REST/session write stamps
203+
// them, so the MEMBER screen would admit every one of them; the units stay
204+
// seeded, so the strict UNIT screen hides the tree before the member read
205+
// is ever issued. Both widths therefore answer nobody, which is precisely
206+
// the 17.2.0 symptom: rule accepted, active, zero shares.
207+
// Fixed structurally in v18 by ADR-0131 C1 (seed loader stamps
208+
// `sys_business_unit`), NOT by widening this screen.
202209
const members: MemberRow[] = DIV_MEMBERS.map((m) => ({ ...m, organization_id: 'org_a' }));
203210
const g = new BusinessUnitGraphService({
204211
engine: makeEngine(DIV_UNITS, members),
205212
organizationId: 'org_a',
206213
});
207-
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
208-
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
214+
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
215+
expect(await g.expandUsers('bu_div')).toEqual([]);
209216
});
210217

211218
it('the two widths do NOT share a cache entry for the same unit id', async () => {
@@ -280,54 +287,83 @@ describe('BusinessUnitGraphService — org scoping (#3807)', () => {
280287
});
281288

282289
/**
283-
* [#14547] The UNIT screen is null-inclusive — the divergence from
284-
* `plugin-approvals` (#3807) is CLOSED.
290+
* [#14547] The UNIT screen is STRICT — the divergence from `plugin-approvals`
291+
* (#3807) is OPEN, and 17.3 ships it exactly as 17.2.0 did.
292+
*
293+
* ⚠️ Every case in this block reproduces the OPEN defect **#14547**. None of
294+
* them asserts a fix. #14949 closed the defect by giving this screen the
295+
* platform's NULL-inclusive arm; that was reverted before the 17.3 tag
296+
* (ADR-0131 D8) because it wrote `SqlDriver.applyTenantScope`'s own predicate
297+
* a second time, in a second place, and had not shipped. The structural fix is
298+
* ADR-0131 C1 on the v18 line: the Default Organization exists before
299+
* application seed datasets load and the seed loader stamps
300+
* `sys_business_unit` seeds, so these rows arrive already carrying an
301+
* organization and this screen needs no NULL arm to find them.
285302
*
286-
* The `[divergence]` test that used to live in the block above pinned the
287-
* opposite posture on the grounds that it could not fire. It fired: the
288-
* external report is an org admin creating a rule at runtime against a unit
289-
* the app seeded.
303+
* ⛔ If a future change makes any assertion here fail, that is a NULL arm
304+
* coming back — re-read ADR-0131 D8 before "fixing" the test.
290305
*/
291306
describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
292307
const STAMPED_MEMBERS: MemberRow[] = SEEDED_MEMBERS.map((m) => ({
293308
...m,
294309
organization_id: 'org_a',
295310
}));
296311

297-
it('an org-scoped rule DOES see an env-wide (null-org) seeded unit', async () => {
312+
it('an org-scoped rule does NOT see an env-wide (null-org) seeded unit', async () => {
313+
// The reported reproduction, pinned as the behaviour 17.x ships: the
314+
// members are stamped and would pass the member screen, so the empty
315+
// answer is the UNIT screen's alone.
298316
const g = new BusinessUnitGraphService({
299317
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
300318
organizationId: 'org_a',
301319
});
302-
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
303-
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
320+
expect(await g.expandUsers('bu_root')).toEqual([]);
321+
expect(await g.expandUnitMembers('bu_root')).toEqual([]);
304322
});
305323

306-
it('the seed check and the subtree walk BOTH admit the seeded rows', async () => {
324+
it('the seed check and the subtree walk BOTH refuse the seeded rows', async () => {
307325
// `seedIsUsable` and the `descendants` BFS are two separate reads through
308-
// the same screen; a widening applied to one and not the other would still
309-
// answer `[]` for the subtree width.
326+
// the same screen. Pinning the BFS separately keeps a widening applied to
327+
// only one of them from passing as "unchanged".
310328
const g = new BusinessUnitGraphService({
311329
engine: makeEngine(SEEDED_UNITS, STAMPED_MEMBERS),
312330
organizationId: 'org_a',
313331
});
314-
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
332+
expect(await g.descendants('bu_root')).toEqual([]);
315333
});
316334

317-
it('`headOf` resolves the manager of a seeded unit too', async () => {
335+
it('`headOf` does not resolve the manager of a seeded unit either', async () => {
336+
// The third read through `orgScope`, pinned so the screen cannot be
337+
// widened at one call site while the other two stay strict.
318338
const units: UnitRow[] = [
319339
{ id: 'bu_root', organization_id: null, active: true, manager_user_id: 'u_head' },
320340
];
321341
const g = new BusinessUnitGraphService({
322342
engine: makeEngine(units, []),
323343
organizationId: 'org_a',
324344
});
345+
expect(await g.headOf('bu_root')).toBeNull();
346+
});
347+
348+
it('an org-stamped unit IS visible — the screen is strict, not broken', async () => {
349+
// The control that separates "strict" from "refuses everything". Same
350+
// fixture shape as the seeded case above; only the unit's organization
351+
// moves, and that single change flips every read to visible.
352+
const units: UnitRow[] = [
353+
{ id: 'bu_root', organization_id: 'org_a', active: true, manager_user_id: 'u_head' },
354+
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
355+
];
356+
const g = new BusinessUnitGraphService({
357+
engine: makeEngine(units, STAMPED_MEMBERS),
358+
organizationId: 'org_a',
359+
});
360+
expect((await g.expandUsers('bu_root')).sort()).toEqual(['u_child', 'u_root']);
361+
expect(await g.expandUnitMembers('bu_root')).toEqual(['u_root']);
362+
expect((await g.descendants('bu_root')).sort()).toEqual(['bu_child', 'bu_root']);
325363
expect(await g.headOf('bu_root')).toBe('u_head');
326364
});
327365

328-
it('ONLY the NULL arm widened — another org’s unit is still invisible', async () => {
329-
// The control that separates "null-inclusive" from "unscoped". Without it
330-
// a screen that had simply been deleted would pass every assertion above.
366+
it('another org’s unit is invisible too', async () => {
331367
const units: UnitRow[] = [
332368
{ id: 'bu_root', organization_id: 'org_b', active: true },
333369
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_b', active: true },
@@ -342,10 +378,14 @@ describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
342378
expect(await g.headOf('bu_root')).toBeNull();
343379
});
344380

345-
it('an INACTIVE seeded unit still contributes nobody', async () => {
346-
const units: UnitRow[] = SEEDED_UNITS.map((u) =>
347-
u.id === 'bu_root' ? { ...u, active: false } : u,
348-
);
381+
it('an INACTIVE org-stamped unit still contributes nobody', async () => {
382+
// `active: false` is a hard filter independent of either screen — pinned
383+
// on a VISIBLE unit so the empty answer is the active flag's doing and
384+
// not the tenant screen answering first.
385+
const units: UnitRow[] = [
386+
{ id: 'bu_root', organization_id: 'org_a', active: false },
387+
{ id: 'bu_child', parent_business_unit_id: 'bu_root', organization_id: 'org_a', active: true },
388+
];
349389
const g = new BusinessUnitGraphService({
350390
engine: makeEngine(units, STAMPED_MEMBERS),
351391
organizationId: 'org_a',
@@ -356,23 +396,32 @@ describe('BusinessUnitGraphService — the UNIT screen (#14547)', () => {
356396
});
357397

358398
/**
359-
* [#14547] The MEMBER screen is STRICT — the leak the unit widening would
360-
* otherwise have opened.
399+
* [#14949, KEPT] The MEMBER screen is STRICT.
400+
*
401+
* ⚠️ This is the SECURITY half of #14949 and it is NOT part of the ADR-0131 D8
402+
* revert. Both `sys_business_unit_member` reads used to carry no organization
403+
* predicate whatever — completely unscoped by organization — so an org-stamped
404+
* rule reaching any visible unit collected every tenant's membership rows off
405+
* it. `memberScope` closed that, and it stays closed.
361406
*
362-
* ⚠️ These are the SECURITY half and they are pinned apart from the functional
363-
* half on purpose: a change that expands the right members while also
364-
* expanding another organization's members passes every assertion in the block
365-
* above.
407+
* ⚠️ The anchor unit here is stamped with the rule's OWN organization, not
408+
* seeded. That is load-bearing: with the unit screen strict again, a seeded
409+
* (org-less) unit is invisible to an org-stamped rule, so a member assertion
410+
* written on one would be answered by the UNIT screen before the member read
411+
* ever ran — it would pass with `memberScope` deleted. Anchoring on a visible
412+
* unit is what keeps every assertion below a pin on the MEMBER screen.
366413
*/
367414
describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
368415
/**
369-
* One SEEDED unit id with two tenants' memberships hanging off it — the
370-
* shape that exists on every deployment whose org chart came from a seed,
371-
* and the one the widened unit screen makes reachable.
416+
* One unit tree the rule's organization owns, with two tenants' membership
417+
* rows hanging off it — the shape an unscoped member read turns into a
418+
* cross-tenant over-grant. `sys_business_unit_member` is not
419+
* organization-stamped on every write path, so mixed tenancy on one visible
420+
* unit is a real deployment shape, not a contrived one.
372421
*/
373422
const SHARED_SEED_UNITS: UnitRow[] = [
374-
{ id: 'bu_market', organization_id: null, active: true },
375-
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: null, active: true },
423+
{ id: 'bu_market', organization_id: 'org_a', active: true },
424+
{ id: 'bu_market_west', parent_business_unit_id: 'bu_market', organization_id: 'org_a', active: true },
376425
];
377426
const TWO_TENANT_MEMBERS: MemberRow[] = [
378427
{ business_unit_id: 'bu_market', user_id: 'u_a', organization_id: 'org_a' },
@@ -403,9 +452,9 @@ describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
403452
it('an org-LESS membership row is NOT a member of an org-scoped rule', async () => {
404453
// Unknown tenancy, not platform-global: `sys_business_unit_member` is not
405454
// organization-stamped by seed replay or by an elevated system write, so a
406-
// NULL here cannot be read the way a NULL on the UNIT row is read. The
407-
// grant fails closed, and `SharingRuleService` warns rather than staying
408-
// silent about it.
455+
// NULL here fails CLOSED. The anchor unit is visible to the rule, so this
456+
// empty answer is the member screen's own — `SharingRuleService` warns
457+
// rather than staying silent about it.
409458
const members: MemberRow[] = [
410459
{ business_unit_id: 'bu_market', user_id: 'u_seeded', organization_id: null },
411460
];
@@ -418,8 +467,9 @@ describe('BusinessUnitGraphService — the MEMBER screen (#14547)', () => {
418467
});
419468

420469
it('an org-LESS rule is unmoved — both screens stay no-ops', async () => {
421-
// The dominant shape today (declared rules bootstrap org-less). #14547
422-
// must not change what they expand to, in either direction.
470+
// The dominant shape today (declared rules bootstrap org-less). Neither
471+
// #14949 nor the ADR-0131 D8 revert of its unit half may change what a
472+
// platform-global rule expands to, in either direction.
423473
const g = new BusinessUnitGraphService({
424474
engine: makeEngine(SHARED_SEED_UNITS, TWO_TENANT_MEMBERS),
425475
organizationId: null,

0 commit comments

Comments
 (0)