Skip to content

Commit ea03c7c

Browse files
fix(plugin-approvals): screen expanded business-unit members to the directory organization (#14946) (#15912)
* test(plugin-approvals): pin the business-unit MEMBER org screen (#14946) — red against the unmodified service Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * fix(plugin-approvals): screen expanded business-unit members to the directory organization (#14946) `expandBusinessUnitUsers` screened the unit rows with the null-inclusive `businessUnitOrgScope` (#3807) but read `sys_business_unit_member` with no organization predicate, under SYSTEM_CTX which carries no tenant. A seeded unit id exists in every tenant, so tenant A's department approver resolved tenant B's members. The member read now carries a strict organization_id equality (`businessUnitMemberScope`): the column is injected and only the session write path fills it, so NULL means unknown tenancy, not global. Existing fixtures that pinned org-less membership rows on stamped or seeded units are re-anchored onto stamped rows; the org-less case is pinned on its own (B3) as the declared, loud cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * test(plugin-approvals): judge the #14946 fake engine's find bound by presence; re-anchor the system-context page; ledger the new pin file - the fake engine's `find` no longer reads `this` (the objectql-double-limit probe calls it unbound) and applies the caller's bound after the filter, by presence, instead of a default page of 1000 - content/docs/permissions/system-context.mdx: six approval-service.ts line anchors re-aimed by +57, the net insertion of the businessUnitMemberScope docblock above them (check-system-context-census --fix) - engine-double-contract ledger learns business-unit-member-org-screen.test.ts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cee3961 commit ea03c7c

6 files changed

Lines changed: 365 additions & 12 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Fix: a `department` approver on a seeded business unit no longer routes the approval to another organization's members.
6+
7+
`ApprovalService.expandBusinessUnitUsers` screened the `sys_business_unit` rows with the null-inclusive tenant predicate (#3807 — a seeded unit carries no organization and is admitted on purpose) but read `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either. A seeded unit id exists identically in every tenant, so a `department:<id>` approver on tenant A's request resolved the shared unit and then collected every tenant's membership rows hanging off it — approval authority over A's record, routed to B's users. The member read now carries a strict `organization_id` equality against the directory organization the approver resolves in: the same screen `plugin-sharing` applies to these rows, and the same posture this package already takes for `sys_team_member` and `sys_user_position`.
8+
9+
The screen is strict rather than null-inclusive on purpose. `sys_business_unit_member.organization_id` is filled by REST/session writes but left NULL by seed replay and by elevated system-context writes (tracked in #14570), so a NULL on a membership row means unknown tenancy, not "platform-global", and routing fails closed on it. Declared cost: on a deployment whose membership rows (not merely its units) were seeded or system-written, a `department` approver on a request that carries an organization now expands to nobody — the slot falls to the `department:<id>` literal, the existing `expanded to nobody` warning (#3807) names it, and `onEmptyApprovers` governs the request as for any unstaffed target. The repair is to stamp those membership rows. A request that carries no organization is unchanged, and so is every unit-level screen.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ The largest single consumer — **17 of the 105 sites**.
145145
|:--|:---|:---|:---|:---|
146146
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
147147
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
148-
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3248`, `:3396`, `:3564`, `:3635`, `:3824`, `:3864` |
148+
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:963`, `:1072`, `:3305`, `:3453`, `:3621`, `:3692`, `:3881`, `:3921` |
149149
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
150150
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
151151
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -429,10 +429,12 @@ describe('ApprovalService (node era)', () => {
429429
{ id: 'd1', active: true, organization_id: 't1' },
430430
{ id: 'd2', active: true, organization_id: 't1' },
431431
];
432+
// #14946: membership rows are stamped (the session write path fills
433+
// `organization_id`); the member read is now strictly screened to it.
432434
engine._tables['sys_business_unit_member'] = [
433-
{ id: 'm1', business_unit_id: 'd1', user_id: 'u2' },
434-
{ id: 'm2', business_unit_id: 'd1', user_id: 'u3' },
435-
{ id: 'm3', business_unit_id: 'd2', user_id: 'u4' },
435+
{ id: 'm1', business_unit_id: 'd1', user_id: 'u2', organization_id: 't1' },
436+
{ id: 'm2', business_unit_id: 'd1', user_id: 'u3', organization_id: 't1' },
437+
{ id: 'm3', business_unit_id: 'd2', user_id: 'u4', organization_id: 't1' },
436438
];
437439
const req = await svc.openNodeRequest(
438440
exprInput('vars.picked_departments', {
@@ -913,8 +915,8 @@ describe('ApprovalService (node era)', () => {
913915
{ id: 'bu2', parent_business_unit_id: 'bu1', organization_id: 't1', active: true },
914916
];
915917
engine._tables['sys_business_unit_member'] = [
916-
{ id: 'bm1', business_unit_id: 'bu1', user_id: 'u5' },
917-
{ id: 'bm2', business_unit_id: 'bu2', user_id: 'u6' },
918+
{ id: 'bm1', business_unit_id: 'bu1', user_id: 'u5', organization_id: 't1' },
919+
{ id: 'bm2', business_unit_id: 'bu2', user_id: 'u6', organization_id: 't1' },
918920
];
919921
const req = await svc.openNodeRequest(positionInput({
920922
config: {
@@ -944,9 +946,13 @@ describe('ApprovalService (node era)', () => {
944946
{ id: 'bu_seeded', organization_id: null, active: true },
945947
{ id: 'bu_seeded_child', parent_business_unit_id: 'bu_seeded', organization_id: null, active: true },
946948
];
949+
// #14946: the SEEDED rows are the units; the membership rows are stamped,
950+
// as the session write path leaves them. An org-less membership row on a
951+
// seeded unit is pinned on its own in
952+
// `business-unit-member-org-screen.test.ts` (B3) — it does NOT route.
947953
engine._tables['sys_business_unit_member'] = [
948-
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
949-
{ id: 'bm2', business_unit_id: 'bu_seeded_child', user_id: 'u6' },
954+
{ id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5', organization_id: 't1' },
955+
{ id: 'bm2', business_unit_id: 'bu_seeded_child', user_id: 'u6', organization_id: 't1' },
950956
];
951957
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
952958
// Both the seed check AND the subtree descent must see the null-org rows.
@@ -973,8 +979,8 @@ describe('ApprovalService (node era)', () => {
973979
{ id: 'bu_theirs', parent_business_unit_id: 'bu_seeded', organization_id: 't2', active: true },
974980
];
975981
engine._tables['sys_business_unit_member'] = [
976-
{ id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u5' },
977-
{ id: 'bm2', business_unit_id: 'bu_theirs', user_id: 'intruder' },
982+
{ id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u5', organization_id: 't1' },
983+
{ id: 'bm2', business_unit_id: 'bu_theirs', user_id: 'intruder', organization_id: 't2' },
978984
];
979985
const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
980986
expect(req.pending_approvers).toEqual(['u5']);

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,7 +1734,60 @@ export class ApprovalService implements IApprovalService {
17341734
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
17351735
}
17361736

1737-
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1737+
/**
1738+
* Tenant scope for the `sys_business_unit_member` read — a STRICT equality,
1739+
* deliberately NOT {@link businessUnitOrgScope} (#14946).
1740+
*
1741+
* The two screens answer different questions. The UNIT is the anchor the
1742+
* approver NAMES, and a seeded unit carries `organization_id = null` by
1743+
* construction (a seed cannot know the id the runtime mints at boot), so
1744+
* #3807 admits the null there on purpose. The MEMBER rows are the SET BEING
1745+
* ROUTED TO — enumerated by the platform, never named by anyone — and a
1746+
* seeded unit id exists identically in every tenant. Before this screen the
1747+
* member read carried no organization predicate at all, under
1748+
* {@link SYSTEM_CTX} which carries no tenant either, so tenant A's request
1749+
* resolved the shared unit and then collected EVERY tenant's membership rows
1750+
* hanging off it: approval authority over A's record, routed to B's users.
1751+
*
1752+
* Why the null arm is NOT copied here — measured on this tree:
1753+
* - `sys_business_unit_member` declares no `organization_id`; the column
1754+
* is injected (`applySystemFields`) and the tenancy census lists it in;
1755+
* - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`);
1756+
* - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId`
1757+
* from every `sys_` object), and elevated system-context writes do NOT
1758+
* (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
1759+
* So a NULL on a member row means UNKNOWN tenancy, not "platform-global",
1760+
* and unknown tenancy is not a member of this organization. This is the
1761+
* ruling `plugin-sharing`'s `memberScope` already applies to the same rows
1762+
* (#14547 / #14949), and the posture this file already takes for
1763+
* `sys_team_member` and `sys_user_position`.
1764+
*
1765+
* The cost is declared, not hidden: an organization whose MEMBERSHIP rows
1766+
* were seeded or system-written expands to nobody even on a unit it can
1767+
* see. That is not silent — the graph-type fallback in `expandApprover`
1768+
* warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the
1769+
* request as for any unstaffed target — and the repair is to stamp the
1770+
* membership rows, never to widen this screen. ⛔ Do not "unify" the two
1771+
* screens: one method serving both re-opens whichever half it does not
1772+
* implement.
1773+
*/
1774+
private businessUnitMemberScope(
1775+
filter: Record<string, unknown>,
1776+
organizationId?: string | null,
1777+
): Record<string, unknown> {
1778+
if (!organizationId) return filter;
1779+
return { ...filter, organization_id: organizationId };
1780+
}
1781+
1782+
/**
1783+
* Recursive department — walks `sys_business_unit.parent_business_unit_id`.
1784+
*
1785+
* Two tenant screens, and they are different on purpose: the UNIT rows
1786+
* (seed check and descent) go through the null-inclusive
1787+
* {@link businessUnitOrgScope}; the MEMBER read goes through the strict
1788+
* {@link businessUnitMemberScope}. `organizationId` is the DIRECTORY
1789+
* organization the approver resolves in (ADR-0105 D9), for both.
1790+
*/
17381791
private async expandBusinessUnitUsers(businessUnitId: string, organizationId?: string | null): Promise<string[]> {
17391792
if (!businessUnitId) return [];
17401793
// Seed sanity check: skip if dept doesn't exist or is inactive within tenant.
@@ -1769,7 +1822,11 @@ export class ApprovalService implements IApprovalService {
17691822
let rows: any[] = [];
17701823
try {
17711824
rows = await this.engine.find('sys_business_unit_member', {
1772-
where: { business_unit_id: { $in: Array.from(seen) } },
1825+
// #14946: tenant-screened — {@link businessUnitMemberScope} is STRICT
1826+
// on purpose and is not {@link businessUnitOrgScope}. The units above
1827+
// proved their tenancy (or are seeded); these rows have not, and the
1828+
// shared seeded unit id is exactly where other tenants' rows sit.
1829+
where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
17731830
fields: ['user_id'],
17741831
limit: 10000,
17751832
context: SYSTEM_CTX,

0 commit comments

Comments
 (0)