Skip to content

Commit 9b51981

Browse files
os-zhuangclaude
andauthored
fix(plugin-sharing): business_unit recipient expands exactly one unit, not the subtree (#7807) (#8097)
The two business-unit recipient kinds were declared as two widths and enforced as one. `SharingRuleService.expandRecipient` routed both through the identical `BusinessUnitGraphService.expandUsers` call, whose first act is a BFS over `parent_business_unit_id`, so the branches differed only in their comments. A rule authored `recipient_type: 'business_unit'` — declared by the spec, the lint red-line table and ADR-0057 D5 as "exactly one business unit's members (no subtree)" — reached that unit plus every descendant unit's members. On a three-level tree a division-anchored rule silently granted to every department and office beneath it, and `unit_and_subordinates`, documented as the strictly wider grant of the pair, was not wider at all. Maintainer ruling 2026-08-12, direction 1: narrow the runtime to the declaration. `business_unit` now resolves through a new `expandUnitMembers()` — no descent — while `unit_and_subordinates` keeps the subtree walk unchanged. Both kinds survive; neither is merged or retired. `expandUsers()` keeps its contract meaning for the `bu:` approver prefix and org rollups. The narrow width caches separately, since one map keyed by unit id would let whichever width ran first answer for the other. Both widths are pinned as a pair on one three-level fixture, at the graph, the service and the REST level, because a change narrowing BOTH kinds would satisfy the headline while destroying the distinction. Verified by two ablations: with the narrowing reverted exactly the 8 narrow assertions go red and every wide one stays green; with both branches narrowed exactly the wide assertions go red. Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 531fb31 commit 9b51981

10 files changed

Lines changed: 638 additions & 37 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): the `business_unit` sharing-rule recipient expands exactly one unit, not its whole subtree (#7807)
6+
7+
⚠️ **This is an intentional over-grant fix, and it REDUCES visible rows for any
8+
deployment that authored `business_unit` sharing rules.** Read the migration note
9+
below before upgrading if you use that recipient kind.
10+
11+
## What was wrong
12+
13+
The two business-unit recipient kinds were **declared** as two different widths
14+
and **enforced** as the same width. `SharingRuleService.expandRecipient` routed
15+
both through the identical `BusinessUnitGraphService.expandUsers` call, whose
16+
first act is a BFS over `parent_business_unit_id` — so the two branches differed
17+
only in their comments.
18+
19+
A rule authored as `recipient_type: 'business_unit'`, which the authoring spec
20+
(`ShareRecipientType`), the org-axis lint red-line table and ADR-0057 D5 all
21+
describe as *"exactly one business unit's members (no subtree)"*, in fact reached
22+
that unit **plus every descendant unit's members**. On a three-level tree a rule
23+
anchored at a division silently granted to every department and office beneath
24+
it.
25+
26+
Two consequences, and the second is why this is filed as security rather than
27+
tidiness: the narrow spelling over-granted **silently**, which is the worst
28+
failure shape for generated security metadata (an agent that asks for the narrow
29+
grant should get the narrow grant); and `unit_and_subordinates`, documented as
30+
the *strictly wider* grant of the pair, was not wider at all, leaving the
31+
distinction the lint red-line draws unenforceable in practice.
32+
33+
## What changed
34+
35+
`business_unit` now resolves through a new
36+
`BusinessUnitGraphService.expandUnitMembers()` — members whose
37+
`business_unit_id` equals the named unit, with no descent. It keeps every other
38+
guarantee the subtree walk had: an inactive or out-of-tenant anchor unit
39+
contributes nobody, and an unreadable unit fails closed rather than granting.
40+
41+
`unit_and_subordinates` is **unchanged** and keeps the subtree walk — it is the
42+
kind whose declared semantics *is* the hierarchy widening (ADR-0057 D5). The two
43+
kinds remain two kinds; neither is merged into the other or retired.
44+
`expandUsers()` also keeps its meaning for the `bu:` approver prefix and org
45+
rollups, which are subtree consumers by contract.
46+
47+
Grant recomputation on business-unit graph writes (#7729) still covers both
48+
recipient kinds, because a unit-only expansion still reads `sys_business_unit`
49+
for its anchor's `active` flag and tenant scope and `sys_business_unit_member`
50+
for its members. What changed there is blast radius, not coverage: re-parenting a
51+
unit no longer moves a `business_unit` rule's recipients, while deactivating the
52+
anchor or editing its membership still does.
53+
54+
## Migration
55+
56+
**In-tree cost is zero** — no shipped example app or seeded rule authors
57+
`business_unit` (the showcase and CRM apps use `position` and
58+
`unit_and_subordinates`), so nothing in this repository changes behaviour.
59+
60+
**Out-of-tree deployments:** if you authored a `business_unit` rule and were
61+
relying — knowingly or not — on it reaching descendant units, those descendant
62+
members **lose the grants that rule materialised**. Grants are reconciled on the
63+
next evaluation pass, so the reduction lands without any action on your part.
64+
65+
If the subtree reach was what you actually wanted, change the rule's recipient to
66+
`unit_and_subordinates`, which has always meant exactly that and is unaffected by
67+
this release. If you wanted the narrow grant, you now have it.

packages/plugins/plugin-sharing/src/bu-tree-recompute.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,14 @@ describe('#7729 business-unit graph writes recompute BU-tree sharing rules', ()
319319
expect(grantsFor('priya')).toBe(0);
320320
});
321321

322-
it('covers `business_unit` recipients too — today they walk the same subtree resolver', async () => {
322+
// [#7807] narrowed `business_unit` to exactly one unit's members. This case
323+
// still belongs here, and deliberately: a unit-only expansion still reads
324+
// `sys_business_unit` (its own `active` flag, its tenant scope) and
325+
// `sys_business_unit_member`, so a write to either table can still move who
326+
// the rule reaches. The anchor below is the unit priya is a DIRECT member
327+
// of, which is what makes this a test of RECOMPUTE COVERAGE rather than of
328+
// the subtree walk.
329+
it('covers `business_unit` recipients too — a unit-only expansion still reads both BU tables', async () => {
323330
engine._tables.sys_sharing_rule[0].recipient_type = 'business_unit';
324331
engine._tables.sys_sharing_rule[0].recipient_id = 'bu_west';
325332
await rules.evaluateRule(RULE, SYS);

packages/plugins/plugin-sharing/src/bu-tree-recompute.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,22 @@
3030
* |-------------------------|---------------------------------------------|---|
3131
* | `user` | the literal id | no |
3232
* | `team` | `TeamGraphService` (`sys_team_member`, `sys_member`, `sys_user`) | no |
33-
* | `business_unit` | `BusinessUnitGraphService.expandUsers` | YES |
33+
* | `business_unit` | `BusinessUnitGraphService.expandUnitMembers` | YES |
3434
* | `position` | `PositionGraphService` (`sys_user_position`, `sys_member`) | no |
3535
* | `unit_and_subordinates` | `BusinessUnitGraphService.expandUsers` | YES |
3636
* | `queue` | returns `[]` (no `sys_queue` yet) | no |
3737
*
38-
* `business_unit` is in that set on the strength of what the code does today:
39-
* `expandRecipient` routes it through the SAME `expandUsers` call as
40-
* `unit_and_subordinates`, so it walks `descendants()` and is just as exposed
41-
* to a re-parent. (The spec declares it as "exactly one business unit's
42-
* members (no subtree)" — that divergence is a separate defect and is filed
43-
* separately; covering the kind here is correct under either reading, since a
44-
* unit-only expansion still reads `sys_business_unit` for its own `active`
45-
* flag and `sys_business_unit_member` for its members.)
38+
* `business_unit` stays in that set after #7807 narrowed it to exactly one
39+
* unit's members. The divergence this file originally noted — `expandRecipient`
40+
* routing it through the SAME subtree `expandUsers` call as
41+
* `unit_and_subordinates`, against a spec declaring it as "exactly one business
42+
* unit's members (no subtree)" — was resolved in favour of the declaration, and
43+
* membership here was correct under either reading for the reason that fix
44+
* confirmed: a unit-only expansion still reads `sys_business_unit` for its own
45+
* `active` flag and tenant scope, and `sys_business_unit_member` for its
46+
* members. What changed is the blast radius, not the coverage — a re-parent no
47+
* longer moves a `business_unit` rule's recipients (its anchor's own membership
48+
* is what moves them), while a deactivation or a membership edit still does.
4649
*
4750
* Everything else is deliberately NOT recomputed. That exclusion is a
4851
* requirement, not an optimisation: a fix that recomputed every rule on every

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,83 @@ describe('BusinessUnitGraphService — subtree expansion', () => {
113113
});
114114
});
115115

116+
/**
117+
* [#7807] The two widths, pinned as a PAIR on one three-level tree.
118+
*
119+
* A division ⊃ department ⊃ office tree is the floor for this: on a two-level
120+
* fixture "exactly one unit" and "unit plus its children" can agree by
121+
* accident, so a two-level pin cannot tell the fixed behaviour from the
122+
* defect. Each assertion below names the width it guards, because a change
123+
* that narrowed BOTH kinds would satisfy the `business_unit` half while
124+
* destroying the distinction the spec draws between them.
125+
*/
126+
const DIV_UNITS: UnitRow[] = [
127+
{ id: 'bu_div', organization_id: null, active: true },
128+
{ id: 'bu_dept', parent_business_unit_id: 'bu_div', organization_id: null, active: true },
129+
{ id: 'bu_office', parent_business_unit_id: 'bu_dept', organization_id: null, active: true },
130+
];
131+
const DIV_MEMBERS: MemberRow[] = [
132+
{ business_unit_id: 'bu_div', user_id: 'u_div' },
133+
{ business_unit_id: 'bu_dept', user_id: 'u_dept' },
134+
{ business_unit_id: 'bu_office', user_id: 'u_office' },
135+
];
136+
137+
describe('BusinessUnitGraphService — the two widths are actually two widths (#7807)', () => {
138+
const graph = () => new BusinessUnitGraphService({ engine: makeEngine(DIV_UNITS, DIV_MEMBERS) });
139+
140+
it('NARROW — expandUnitMembers returns only the named unit, three levels notwithstanding', async () => {
141+
expect(await graph().expandUnitMembers('bu_div')).toEqual(['u_div']);
142+
});
143+
144+
it('WIDE — expandUsers still returns the whole subtree (the control)', async () => {
145+
expect((await graph().expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
146+
});
147+
148+
it('the narrow width skips even a DIRECT child, not merely the grandchild', async () => {
149+
const users = await graph().expandUnitMembers('bu_div');
150+
expect(users).not.toContain('u_dept');
151+
expect(users).not.toContain('u_office');
152+
});
153+
154+
it('a mid-tree unit expands to its own members only', async () => {
155+
expect(await graph().expandUnitMembers('bu_dept')).toEqual(['u_dept']);
156+
});
157+
158+
it('an inactive unit contributes nobody to the narrow width either', async () => {
159+
const units = DIV_UNITS.map((u) => (u.id === 'bu_div' ? { ...u, active: false } : u));
160+
const g = new BusinessUnitGraphService({ engine: makeEngine(units, DIV_MEMBERS) });
161+
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
162+
});
163+
164+
it('an unknown unit expands to nobody rather than to everybody', async () => {
165+
expect(await graph().expandUnitMembers('bu_nope')).toEqual([]);
166+
expect(await graph().expandUnitMembers('')).toEqual([]);
167+
});
168+
169+
it('the narrow width is org-predicated exactly like the wide one', async () => {
170+
const g = new BusinessUnitGraphService({
171+
engine: makeEngine(DIV_UNITS, DIV_MEMBERS),
172+
organizationId: 'org_a',
173+
});
174+
// Seeded (null-org) units are not visible to an org-scoped rule — the
175+
// same `[divergence]` posture the wide width holds below.
176+
expect(await g.expandUnitMembers('bu_div')).toEqual([]);
177+
});
178+
179+
it('the two widths do NOT share a cache entry for the same unit id', async () => {
180+
// Both maps are keyed by BU id. One shared map would let whichever width
181+
// ran first answer for the other — the over-grant returning through the
182+
// cache door.
183+
const g = graph();
184+
expect(await g.expandUnitMembers('bu_div')).toEqual(['u_div']);
185+
expect((await g.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
186+
// …and in the opposite order, on a fresh instance.
187+
const g2 = graph();
188+
expect((await g2.expandUsers('bu_div')).sort()).toEqual(['u_dept', 'u_div', 'u_office']);
189+
expect(await g2.expandUnitMembers('bu_div')).toEqual(['u_div']);
190+
});
191+
});
192+
116193
describe('BusinessUnitGraphService — org scoping (#3807)', () => {
117194
it('an org-less rule (today’s materialized shape) expands seeded units fine', async () => {
118195
// `expandRecipient` passes `rule.organization_id ?? null`, and every

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

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
99
type DeptCache = {
1010
descendants?: Map<string, string[]>;
1111
expandUsers?: Map<string, string[]>;
12+
/**
13+
* [#7807] Members of exactly one unit — a SEPARATE map from
14+
* {@link DeptCache.expandUsers} on purpose. Both are keyed by business-unit
15+
* id but answer different questions, so sharing one map would let a narrow
16+
* `business_unit` expansion be served a cached subtree answer (re-opening
17+
* the over-grant this issue closed) or vice versa, depending only on which
18+
* recipient kind happened to be evaluated first in the pass.
19+
*/
20+
unitMembers?: Map<string, string[]>;
1221
head?: Map<string, string | null>;
1322
};
1423

@@ -34,6 +43,14 @@ export interface BusinessUnitGraphOptions {
3443
* `active` flag as a hard filter (inactive departments contribute no
3544
* members and stop BFS descent into their subtrees).
3645
*
46+
* Two DIFFERENT widths live here, and keeping them distinct is the point
47+
* (#7807):
48+
* - {@link BusinessUnitGraphService.expandUsers} — the unit PLUS every
49+
* descendant unit (the `IBusinessUnitGraphService` contract; drives the
50+
* `unit_and_subordinates` recipient).
51+
* - {@link BusinessUnitGraphService.expandUnitMembers} — exactly that one
52+
* unit's members (drives the `business_unit` recipient).
53+
*
3754
* Reuses {@link TeamGraphService.managerOf} for user-level manager
3855
* lookup so callers can use this single service in approval / sharing
3956
* pipelines.
@@ -50,6 +67,7 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService {
5067
this.cache = opts.cache ?? {};
5168
this.cache.descendants ??= new Map();
5269
this.cache.expandUsers ??= new Map();
70+
this.cache.unitMembers ??= new Map();
5371
this.cache.head ??= new Map();
5472
this.teamGraph = opts.teamGraph;
5573
}
@@ -60,20 +78,7 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService {
6078
if (cached) return cached;
6179

6280
// Verify seed itself is active + within tenant scope.
63-
let seedActive = true;
64-
try {
65-
const seedRows = await this.engine.find('sys_business_unit', {
66-
where: this.orgScope({ id: businessUnitId }),
67-
fields: ['id', 'active'],
68-
limit: 1,
69-
context: SYSTEM_CTX,
70-
});
71-
const seedRow: any = Array.isArray(seedRows) ? seedRows[0] : null;
72-
if (!seedRow) seedActive = false;
73-
else if (seedRow.active === false) seedActive = false;
74-
} catch {
75-
seedActive = false;
76-
}
81+
const seedActive = await this.seedIsUsable(businessUnitId);
7782
if (!seedActive) {
7883
this.cache.descendants!.set(businessUnitId, []);
7984
return [];
@@ -107,6 +112,79 @@ export class BusinessUnitGraphService implements IBusinessUnitGraphService {
107112
return out;
108113
}
109114

115+
/**
116+
* Is the seed unit itself active and inside the tenant scope?
117+
*
118+
* Shared by both widths so they agree on what an unusable seed is: a unit
119+
* that does not exist, sits in another organization, or carries
120+
* `active: false` contributes NOBODY — it is never merely "expanded without
121+
* its descendants". A read failure answers `false` (fail closed: an
122+
* unreadable unit must not grant).
123+
*/
124+
private async seedIsUsable(businessUnitId: string): Promise<boolean> {
125+
try {
126+
const seedRows = await this.engine.find('sys_business_unit', {
127+
where: this.orgScope({ id: businessUnitId }),
128+
fields: ['id', 'active'],
129+
limit: 1,
130+
context: SYSTEM_CTX,
131+
});
132+
const seedRow: any = Array.isArray(seedRows) ? seedRows[0] : null;
133+
if (!seedRow) return false;
134+
if (seedRow.active === false) return false;
135+
return true;
136+
} catch {
137+
return false;
138+
}
139+
}
140+
141+
/**
142+
* [#7807] Members of EXACTLY ONE business unit — no subtree descent.
143+
*
144+
* This is the enforcement of the `business_unit` sharing-rule recipient,
145+
* which the spec (`ShareRecipientType`), the lint red-line table and
146+
* ADR-0057 D5 all declare as "exactly one business unit's members (no
147+
* subtree)". Until #7807 the runtime routed it through
148+
* {@link BusinessUnitGraphService.expandUsers} instead, so a rule anchored
149+
* at a division silently reached every department and office beneath it —
150+
* an over-grant, and one that made the strictly-wider `unit_and_subordinates`
151+
* kind not wider at all.
152+
*
153+
* Deliberately NOT a variant of `expandUsers`: that method is the
154+
* `IBusinessUnitGraphService` contract's SUBTREE expansion ("all user ids in
155+
* `businessUnitId` or any descendant business unit") and keeps that meaning
156+
* for `unit_and_subordinates`, the `bu:` approver prefix and org rollups.
157+
* The two widths are now two methods rather than one method and two
158+
* comments.
159+
*/
160+
async expandUnitMembers(businessUnitId: string): Promise<string[]> {
161+
if (!businessUnitId) return [];
162+
const cached = this.cache.unitMembers!.get(businessUnitId);
163+
if (cached) return cached;
164+
165+
if (!(await this.seedIsUsable(businessUnitId))) {
166+
this.cache.unitMembers!.set(businessUnitId, []);
167+
return [];
168+
}
169+
170+
let rows: any[] = [];
171+
try {
172+
rows = await this.engine.find('sys_business_unit_member', {
173+
where: { business_unit_id: businessUnitId },
174+
fields: ['user_id'],
175+
limit: 10000,
176+
context: SYSTEM_CTX,
177+
});
178+
} catch {
179+
rows = [];
180+
}
181+
const users = Array.from(
182+
new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)),
183+
);
184+
this.cache.unitMembers!.set(businessUnitId, users);
185+
return users;
186+
}
187+
110188
async expandUsers(businessUnitId: string): Promise<string[]> {
111189
if (!businessUnitId) return [];
112190
const cached = this.cache.expandUsers!.get(businessUnitId);

0 commit comments

Comments
 (0)