Summary
ApprovalService.expandBusinessUnitUsers screens the unit rows with the null-inclusive tenant predicate but reads the member rows with no organization predicate at all, under an elevated context that carries no tenant either. On any deployment whose org chart came from seed data, a department:<id> approver on tenant A's request resolves to tenant B's users.
Found while implementing #14547, which is the identical defect one plugin over (plugin-sharing's BusinessUnitGraphService). Filed rather than fixed in that PR: it is a different package, a different verification surface, and possibly another lane's file.
Evidence, at origin/main431979e67
packages/plugins/plugin-approvals/src/approval-service.ts:
privatebusinessUnitOrgScope(filter,organizationId){if(!organizationId)returnfilter;return{ ...filter,$or: [{organization_id: organizationId},{organization_id: null}]};}:1740-1746 (seed check) and the descendant BFS both route through it — correct.:1771 — the member read, which does not:
rows=awaitthis.engine.find('sys_business_unit_member',{where: {business_unit_id: {$in: Array.from(seen)}},fields: ['user_id'],limit: 10000,context: SYSTEM_CTX,});:375 — SYSTEM_CTX is { isSystem: true, positions: [], permissions: [] }. It carries no tenantId, so the engine threads none and SqlDriver.applyTenantScope adds nothing. The member query is unscoped by organization.
Why the two facts compose into a leak
A sys_business_unit row written by seed data carries organization_id = NULL, so the same unit id exists identically in every tenant. The null-inclusive unit screen — correctly — lets tenant A's request resolve that seeded unit. The unscoped member read then returns every membership row hanging off it, including rows stamped with tenant B.
sys_business_unit_member rows are organization-stamped on the REST/session write path (the engine threads the caller's tenantId and SqlDriver.injectTenantOnInsert fills the column), so a real multi-tenant deployment does have B-stamped rows on the shared seeded unit. That is the leak.
Note the asymmetry with the sibling expansions in the same file: sys_team_member and the position reads are organization-screened. The business-unit member read is the one that is not.
Suggested shape
The same asymmetric pair #14547 settled on for plugin-sharing: keep the unit screen null-inclusive (it is the anchor the request names, and a NULL there is the documented platform/seeded class), and give the member read a strictorganization_id equality (a NULL on a membership row means unknown tenancy, not platform-global — seed replay and elevated system writes both leave it NULL, tracked in #14570 — so routing must fail closed on it).
Failing closed will make some seeded-membership deployments resolve an empty approver slate; #3807's own summary says an empty department approver slot is loud in approvals (a stuck approval), unlike the sharing side where it was silent, so that direction is observable. Worth confirming during implementation rather than assuming.
Scope note
Not fixed under #14547 — that card is plugin-sharing and this is plugin-approvals: a separate package, separate tests and a separate gate surface.
Generated by Claude Code
Summary
ApprovalService.expandBusinessUnitUsersscreens the unit rows with the null-inclusive tenant predicate but reads the member rows with no organization predicate at all, under an elevated context that carries no tenant either. On any deployment whose org chart came from seed data, adepartment:<id>approver on tenant A's request resolves to tenant B's users.Found while implementing #14547, which is the identical defect one plugin over (
plugin-sharing'sBusinessUnitGraphService). Filed rather than fixed in that PR: it is a different package, a different verification surface, and possibly another lane's file.Evidence, at
origin/main431979e67packages/plugins/plugin-approvals/src/approval-service.ts::1733-1735— the unit screen, null-inclusive since approvals: adepartmentapprover never resolves when the business unit hasorganization_id = null(every seeded BU) #3807::1740-1746(seed check) and the descendant BFS both route through it — correct.:1771— the member read, which does not::375—SYSTEM_CTXis{ isSystem: true, positions: [], permissions: [] }. It carries notenantId, so the engine threads none andSqlDriver.applyTenantScopeadds nothing. The member query is unscoped by organization.Why the two facts compose into a leak
A
sys_business_unitrow written by seed data carriesorganization_id = NULL, so the same unit id exists identically in every tenant. The null-inclusive unit screen — correctly — lets tenant A's request resolve that seeded unit. The unscoped member read then returns every membership row hanging off it, including rows stamped with tenant B.sys_business_unit_memberrows are organization-stamped on the REST/session write path (the engine threads the caller'stenantIdandSqlDriver.injectTenantOnInsertfills the column), so a real multi-tenant deployment does have B-stamped rows on the shared seeded unit. That is the leak.Note the asymmetry with the sibling expansions in the same file:
sys_team_memberand the position reads are organization-screened. The business-unit member read is the one that is not.Suggested shape
The same asymmetric pair #14547 settled on for
plugin-sharing: keep the unit screen null-inclusive (it is the anchor the request names, and a NULL there is the documented platform/seeded class), and give the member read a strictorganization_idequality (a NULL on a membership row means unknown tenancy, not platform-global — seed replay and elevated system writes both leave it NULL, tracked in #14570 — so routing must fail closed on it).Failing closed will make some seeded-membership deployments resolve an empty approver slate; #3807's own summary says an empty
departmentapprover slot is loud in approvals (a stuck approval), unlike the sharing side where it was silent, so that direction is observable. Worth confirming during implementation rather than assuming.Scope note
Not fixed under #14547 — that card is
plugin-sharingand this isplugin-approvals: a separate package, separate tests and a separate gate surface.Generated by Claude Code