Skip to content

Commit 3ea7271

Browse files
authored
fix(approvals): resolve department approvers against env-wide business units (#3807) (#3811)
`expandBusinessUnitUsers` scoped its `sys_business_unit` reads with a strict `organization_id = <request org>` equality, so a unit whose `organization_id` is `null` was invisible: the seed check found no row, the expansion returned `[]`, and the approver fell back to the dead `department:<id>` literal that routes to nobody. An app's org tree is seeded, and a seed cannot know the organization id the runtime mints at boot, so every seeded unit carries `organization_id = null` — while an approval request always carries one. Every business unit a flow author could pick therefore resolved to nobody, silently. Both the seed check and the subtree descent now scope to this org ∪ env-wide, the same predicate `sys_metadata`'s pending-draft listing settled on for the identical reason. The wall between two organizations is unchanged.
1 parent 680e8e8 commit 3ea7271

3 files changed

Lines changed: 132 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals): a `department` approver resolves against env-wide business units (#3807)
6+
7+
`expandBusinessUnitUsers` scoped its `sys_business_unit` reads with a strict
8+
`organization_id = <request org>` equality, so a unit whose `organization_id`
9+
is `null` was invisible: the seed check found no row, the expansion returned
10+
`[]`, and the approver fell back to the dead `department:<id>` literal that
11+
routes to nobody.
12+
13+
That is the normal case, not an edge case. An app's org tree is seeded, and a
14+
seed cannot know the organization id the runtime mints at boot, so every seeded
15+
unit carries `organization_id = null` — while an approval request always
16+
carries an org. Every business unit a flow author could pick therefore resolved
17+
to nobody, silently: the request opens, the slate is empty, and (with
18+
`lockRecord`) the record stays locked with no one able to act (#3424 is the
19+
downstream shape of the same dead end). Verified against a live showcase stack:
20+
a `{ type: 'department', value: 'bu_hq_finance' }` approver produced
21+
`pending_approvers: "department:bu_hq_finance"` while the unit's member sat
22+
right there in `sys_business_unit_member`.
23+
24+
Both the seed check and the subtree descent now scope to **this org ∪
25+
env-wide**`$or: [{ organization_id: <org> }, { organization_id: null }]`
26+
the same predicate `sys_metadata`'s pending-draft listing settled on for the
27+
identical reason (a strict equality silently dropping env-wide rows). The wall
28+
between two organizations is unchanged: another org's unit still fails the
29+
match, and a null-org parent does not drag another org's child unit into the
30+
subtree.
31+
32+
Note the same strict-equality scope exists in `plugin-sharing`'s
33+
`BusinessUnitGraphService.orgScope`. It is not reachable today — every
34+
materialized `sys_sharing_rule` row carries `organization_id = null`, so the
35+
filter is skipped — and is left alone here rather than widen an
36+
access-granting path on a defect that cannot currently fire.

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,69 @@ describe('ApprovalService (node era)', () => {
630630
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
631631
});
632632

633+
// #3807 — an app's org tree is normally SEEDED, and a seed cannot know the
634+
// organization id the runtime mints at boot, so those rows carry
635+
// `organization_id = null` while every approval request carries an org. The
636+
// old strict equality made each of them invisible and every `department`
637+
// approver resolved to the dead `department:<id>` literal.
638+
const departmentInput = (value: string) => positionInput({
639+
config: {
640+
approvers: [{ type: 'department' as const, value }],
641+
behavior: 'first_response' as const,
642+
lockRecord: true,
643+
},
644+
});
645+
646+
it('department approver: an env-wide (null-org) business unit still resolves (#3807)', async () => {
647+
engine._tables['sys_business_unit'] = [
648+
{ id: 'bu_seeded', organization_id: null, active: true },
649+
{ id: 'bu_seeded_child', parent_business_unit_id: 'bu_seeded', organization_id: null, active: true },
650+
];
651+
engine._tables['sys_business_unit_member'] = [
652+
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
653+
{ id: 'bm2', business_unit_id: 'bu_seeded_child', user_id: 'u6' },
654+
];
655+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
656+
// Both the seed check AND the subtree descent must see the null-org rows.
657+
expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
658+
});
659+
660+
it('department approver: another organization’s unit stays invisible (#3807 keeps the wall)', async () => {
661+
engine._tables['sys_business_unit'] = [
662+
{ id: 'bu_other', organization_id: 't2', active: true },
663+
{ id: 'bu_other_child', parent_business_unit_id: 'bu_other', organization_id: 't2', active: true },
664+
];
665+
engine._tables['sys_business_unit_member'] = [
666+
{ id: 'bm1', business_unit_id: 'bu_other', user_id: 'intruder' },
667+
{ id: 'bm2', business_unit_id: 'bu_other_child', user_id: 'intruder2' },
668+
];
669+
const req = await svc.openNodeRequest(departmentInput('bu_other'), CTX);
670+
expect(req.pending_approvers).toEqual(['department:bu_other']);
671+
});
672+
673+
it('department approver: a null-org subtree does not drag in another org’s child unit (#3807)', async () => {
674+
engine._tables['sys_business_unit'] = [
675+
{ id: 'bu_seeded', organization_id: null, active: true },
676+
{ id: 'bu_mine', parent_business_unit_id: 'bu_seeded', organization_id: 't1', active: true },
677+
{ id: 'bu_theirs', parent_business_unit_id: 'bu_seeded', organization_id: 't2', active: true },
678+
];
679+
engine._tables['sys_business_unit_member'] = [
680+
{ id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u5' },
681+
{ id: 'bm2', business_unit_id: 'bu_theirs', user_id: 'intruder' },
682+
];
683+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
684+
expect(req.pending_approvers).toEqual(['u5']);
685+
});
686+
687+
it('department approver: an inactive env-wide unit still contributes nobody (#3807)', async () => {
688+
engine._tables['sys_business_unit'] = [{ id: 'bu_seeded', organization_id: null, active: false }];
689+
engine._tables['sys_business_unit_member'] = [
690+
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
691+
];
692+
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
693+
expect(req.pending_approvers).toEqual(['department:bu_seeded']);
694+
});
695+
633696
// ── decideNode ──────────────────────────────────────────────────
634697

635698
it('decideNode: first_response approve finalizes immediately', async () => {

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -904,15 +904,41 @@ export class ApprovalService implements IApprovalService {
904904
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
905905
}
906906

907+
/**
908+
* Tenant scope for a `sys_business_unit` read that may legitimately be
909+
* env-wide (#3807).
910+
*
911+
* `organization_id = null` on a platform object means "owned by no
912+
* organization" — a row written by a seed, the file layer, or bootstrap,
913+
* i.e. before (or outside) any org exists. A strict
914+
* `organization_id = <request org>` equality made every such row invisible:
915+
* the seed check below found nothing, the whole expansion returned `[]`, and
916+
* the approver fell back to the dead `department:<id>` literal that routes to
917+
* nobody. That is not an edge case — an app's org tree is normally seeded
918+
* (a seed cannot know the org id the runtime mints at boot) while the
919+
* approval request always carries one, so EVERY department approver a
920+
* designer could pick resolved to nobody.
921+
*
922+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
923+
* pending-draft listing settled on for the identical reason. Another org's
924+
* unit still fails the match, so the wall between two organizations is
925+
* unchanged — only rows belonging to no org at all become visible.
926+
*/
927+
private businessUnitOrgScope(
928+
filter: Record<string, unknown>,
929+
organizationId?: string | null,
930+
): Record<string, unknown> {
931+
if (!organizationId) return filter;
932+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
933+
}
934+
907935
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
908936
private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
909937
if (!businessUnitId) return [];
910938
// Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
911939
try {
912940
const seed = await this.engine.find('sys_business_unit', {
913-
filter: organizationId
914-
? { id: businessUnitId, organization_id: organizationId }
915-
: { id: businessUnitId },
941+
filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
916942
fields: ['id', 'active'],
917943
limit: 1,
918944
context: SYSTEM_CTX,
@@ -927,8 +953,10 @@ export class ApprovalService implements IApprovalService {
927953
const parent = queue.shift()!;
928954
let kids: any[] = [];
929955
try {
930-
const filter: any = { parent_business_unit_id: parent, active: { $ne: false } };
931-
if (organizationId) filter.organization_id = organizationId;
956+
const filter = this.businessUnitOrgScope(
957+
{ parent_business_unit_id: parent, active: { $ne: false } },
958+
organizationId,
959+
);
932960
kids = await this.engine.find('sys_business_unit', { filter, fields: ['id'], limit: 1000, context: SYSTEM_CTX } as any);
933961
} catch { kids = []; }
934962
for (const k of kids ?? []) {

0 commit comments

Comments
 (0)