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
4549import { 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 */
291306describe ( '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 */
367414describe ( '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