Skip to content

approvals: expandBusinessUnitUsers reads sys_business_unit_member with no organization predicate, so a department approver on a seeded unit resolves across tenants #14946

Description

@os-sales

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/main 431979e67

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

private businessUnitOrgScope(filter, organizationId) {
  if (!organizationId) return filter;
  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 = await this.engine.find('sys_business_unit_member', {
  where: { business_unit_id: { $in: Array.from(seen) } },
  fields: ['user_id'],
  limit: 10000,
  context: SYSTEM_CTX,
});
  • :375SYSTEM_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 strict organization_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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions