Skip to content

Commit c5d0c2f

Browse files
os-warrenclaude
andauthored
fix(approvals): screen expanded team members to the request's organization (#10739)
* fix(approvals): screen expanded team members to the request's organization (#10547) #10230 made a `team` approver prove the TEAM's tenancy and deferred its members on purpose. `sys_team_member` carries `team_id` and `user_id` and no organization column, so a team that passed that screen still routed every user id it listed. Measured on a fixture before the change, not read off the schema — an `org_a` request against an `org_a` team whose member holds membership only in `org_b`: [PROBE M1] pending_approvers = ["u_outsider"] [PROBE M8] sys_member reads = 0 The expansion now screens the members with the provably-outside (fail-open) posture `managerIsProvablyOutsideOrg` and `teamIsProvablyOutsideOrg` already pin, in ONE `$in` read for the whole slate: - membership rows exist and none is the request's organization => present and NEGATIVE => dropped, loudly; - no rows, an unreadable table, a possibly-truncated read, or a request carrying no organization => ABSENT => routing unchanged, and the last case reads nothing. A truncated read fails OPEN deliberately: this read is the only evidence that a member IS a tenant here, so incomplete evidence must not be spent as proof of absence. #3807 is the recorded cost of reading an absent fact as a negative one. Decides nothing #7497 asks: no reads are granted and no read screen is applied to any approver type that lacks one today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042 * chore(gates): record the #10547 test file's pinned engine doubles `check:engine-double-contract` reported the new `team-member-org-screen.test.ts` double as RETAINED — pinned coverage the ledger did not yet record, so it protected nothing. Regenerated with `--write`: 2 rows added (one delete, one update), none lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0f14f70b-575c-5f2b-a235-4000a55db042 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bfadf84 commit c5d0c2f

4 files changed

Lines changed: 513 additions & 10 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
Screen expanded `team` approver members to the request's organization (#10547).
6+
7+
#10230 made a `team` approver prove the TEAM's tenancy, and deferred the
8+
members on purpose. `sys_team_member` carries `team_id` and `user_id` and no
9+
organization column, so a team that passed that screen still routed every user
10+
id it listed — including a user whose only `sys_member` row is in another
11+
organization. Measured on a fixture, not read off the schema: an `org_a`
12+
request against an `org_a` team returned `["u_outsider","u_insider"]` with zero
13+
`sys_member` reads.
14+
15+
The expansion now screens the members with the same provably-outside posture
16+
the neighbouring screens pin, in ONE `$in` read for the whole slate:
17+
18+
- membership rows exist for the user and none is the request's organization
19+
(present and NEGATIVE) — dropped, with a warning naming the users, the team
20+
and both organizations;
21+
- no membership rows, an unreadable `sys_member`, a possibly-truncated read, or
22+
a request carrying no organization (ABSENT) — routing is left exactly as it
23+
was, and the no-organization case performs no read at all.
24+
25+
Holding membership elsewhere is not disqualifying; holding none here is.
26+
27+
⚠️ Behaviour change, confined to one non-default policy: a node whose only
28+
approver is a team staffed entirely by users provably outside the organization
29+
now resolves to no one. Under the default `onEmptyApprovers: 'admin_rescue'` it
30+
still opens, routed to the dead `team:<id>` literal as any unresolved slate is;
31+
under `onEmptyApprovers: 'fail'` it now throws `NO_APPROVERS` where it
32+
previously opened.
33+
34+
Residual condition on the security value: the screen can only act on tenancy
35+
facts that exist. A deployment that stamps an organization on its approval
36+
requests but does not materialize `sys_member` rows sees no change — by design,
37+
since #3807 recorded what treating an absent fact as a negative one costs.

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

Lines changed: 148 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,18 @@ function actingUserId(context: ExecutionContext | undefined): string | null {
268268
*/
269269
const OOO_MAX_CHAIN = 8;
270270

271+
/**
272+
* Row cap on the single `sys_member` read that screens an expanded team slate
273+
* (#10547, {@link ApprovalService.dropMembersProvablyOutsideOrg}).
274+
*
275+
* Sized above the 10000-member cap the `sys_team_member` expansion itself
276+
* carries, because the screen reads MEMBERSHIP rows and a person may hold
277+
* several. A result that comes back at the cap is treated as no evidence at
278+
* all — see the truncation note on that method; the cap is therefore a
279+
* fail-open threshold, never a silent trim of the slate.
280+
*/
281+
const MEMBER_SCREEN_READ_LIMIT = 50000;
282+
271283
/**
272284
* Approver types resolved by QUERYING a graph rather than by taking `value`
273285
* literally (#3807). Each can legitimately come back empty — an unstaffed
@@ -1187,15 +1199,35 @@ export class ApprovalService implements IApprovalService {
11871199
* team routed that organization's people an approval over a record they are
11881200
* not a tenant of.
11891201
*
1190-
* ⚠️ The screen is on the TEAM, not on its members, and that is the whole
1191-
* difference from the screen next door ({@link managerIsProvablyOutsideOrg},
1192-
* #10153). `sys_user` carries no tenancy fact at all, so a manager can only
1193-
* be placed by his `sys_member` rows; `sys_team` carries `organization_id`
1194-
* outright (`packages/platform-objects/src/identity/sys-team.object.ts`), so
1195-
* a team id transitively names exactly one organization and ONE row answers
1196-
* the question. Screening the MEMBERS instead would be both a wider read and
1197-
* a different assertion — it would rule on #7497 (does approver routing imply
1198-
* record read visibility?), which this card does not.
1202+
* TWO screens run here, and they assert different things (#10230, #10547):
1203+
*
1204+
* 1. the TEAM must not provably belong to another organization
1205+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
1206+
* `organization_id` outright
1207+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
1208+
* team id transitively names exactly one organization and ONE row
1209+
* answers the question;
1210+
* 2. each expanded MEMBER must not provably hold membership only in other
1211+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
1212+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
1213+
* column at all, so passing (1) says nothing whatever about the people
1214+
* it lists.
1215+
*
1216+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
1217+
* deferral is that (1) does not imply (2) even a little: a member removed
1218+
* from the organization but left on the team, a team re-parented across
1219+
* organizations (`/organization/update-team` accepts `organizationId` in its
1220+
* partial body), or a `sys_team_member` row written by a seed rather than
1221+
* through better-auth all produce a team that passes (1) carrying a user who
1222+
* is provably a tenant of somewhere else. Measured on this tree, not read off
1223+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
1224+
*
1225+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
1226+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
1227+
* fact, so `sys_member` rows are the only evidence that a person is placed
1228+
* anywhere. Like that screen, this one grants no reads and applies no read
1229+
* screen to any approver type that lacks one today, so it decides nothing
1230+
* #7497 (does approver routing imply record read visibility?) asks.
11991231
*/
12001232
private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise<string[]> {
12011233
if (!teamId) return [];
@@ -1209,7 +1241,10 @@ export class ApprovalService implements IApprovalService {
12091241
context: SYSTEM_CTX,
12101242
} as any);
12111243
} catch { rows = []; }
1212-
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
1244+
const users = Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
1245+
// #10547: the TEAM proved its tenancy above; its members have not proved
1246+
// theirs, and `sys_team_member` holds no fact that could. One read.
1247+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
12131248
}
12141249

12151250
/**
@@ -1272,6 +1307,109 @@ export class ApprovalService implements IApprovalService {
12721307
return true;
12731308
}
12741309

1310+
/**
1311+
* Drop the expanded team members who are PROVABLY tenants of other
1312+
* organizations and not of `organizationId`. (#10547)
1313+
*
1314+
* Returns the survivors, in the order they were expanded.
1315+
*
1316+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
1317+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
1318+
* assertion about the same table:
1319+
*
1320+
* - membership rows exist for this user, none in `organizationId`
1321+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
1322+
* - no membership rows at all for him, the read failed, or the request
1323+
* carries no organization
1324+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
1325+
*
1326+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
1327+
* cost of getting it wrong: a stack that stamps an organization on requests
1328+
* but never materializes `sys_member` rows would otherwise lose EVERY team
1329+
* approver at once. This package's own `team_ok` expansion fixture and
1330+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
1331+
* a request organization and no `sys_member` table at all — so the absent
1332+
* limb is exercised by neighbours on every run of this suite.
1333+
*
1334+
* ONE read for the whole slate, never one per person: the expansion is capped
1335+
* at 10000 members and a per-user query would turn a single team approver
1336+
* into 10000 round trips.
1337+
*
1338+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
1339+
* the only evidence that a member IS a tenant here, so a result cut off at
1340+
* the limit could be missing the very row that keeps a legitimate approver on
1341+
* the slate — screening him out on missing evidence, which inverts the
1342+
* posture into fail-CLOSED precisely where it must not. When the read comes
1343+
* back at the cap it is treated as no evidence at all.
1344+
*/
1345+
private async dropMembersProvablyOutsideOrg(
1346+
teamId: string,
1347+
userIds: string[],
1348+
organizationId?: string | null,
1349+
): Promise<string[]> {
1350+
const requestOrg = organizationId ? String(organizationId) : '';
1351+
// No organization on the request ⇒ nothing to screen against, and no read.
1352+
// The ordinary single-organization / embedded stack costs nothing here.
1353+
if (!requestOrg || !userIds.length) return userIds;
1354+
let rows: any[] = [];
1355+
try {
1356+
// No `as any` on this options bag — #4918's ratchet grandfathers this
1357+
// file for its EXISTING erasures only, and a NEW one must carry the
1358+
// declared type. `ApprovalEngine.find` already accepts it as written.
1359+
rows = await this.engine.find('sys_member', {
1360+
where: { user_id: { $in: userIds } },
1361+
fields: ['user_id', 'organization_id'],
1362+
limit: MEMBER_SCREEN_READ_LIMIT,
1363+
context: SYSTEM_CTX,
1364+
});
1365+
} catch { return userIds; } // membership unreadable — see the fail-open note
1366+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
1367+
// Possibly truncated ⇒ the evidence is incomplete ⇒ no evidence.
1368+
this.logger?.warn?.(
1369+
`[approvals] #10547: the membership screen for team '${teamId}' read `
1370+
+ `${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row `
1371+
+ `cap, so the result may be truncated. Routing is left unchanged rather than risk `
1372+
+ `dropping a member whose proof of membership fell outside the read.`,
1373+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length },
1374+
);
1375+
return userIds;
1376+
}
1377+
const orgsByUser = new Map<string, string[]>();
1378+
for (const r of rows ?? []) {
1379+
const uid = String((r as any)?.user_id ?? '');
1380+
const org = String((r as any)?.organization_id ?? '');
1381+
if (!uid || !org) continue;
1382+
const seen = orgsByUser.get(uid);
1383+
if (seen) seen.push(org); else orgsByUser.set(uid, [org]);
1384+
}
1385+
const kept: string[] = [];
1386+
const dropped: Array<{ userId: string; organizationIds: string[] }> = [];
1387+
for (const uid of userIds) {
1388+
const orgs = orgsByUser.get(uid);
1389+
if (!orgs?.length) { kept.push(uid); continue; } // no tenancy fact recorded
1390+
if (orgs.includes(requestOrg)) { kept.push(uid); continue; } // a member here
1391+
dropped.push({ userId: uid, organizationIds: orgs });
1392+
}
1393+
if (dropped.length) {
1394+
this.logger?.warn?.(
1395+
`[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from `
1396+
+ `the approver slate — ${dropped.map(d => `'${d.userId}'`).join(', ')} hold membership `
1397+
+ `in other organization(s), none of them the request's organization '${requestOrg}', so `
1398+
+ `routing this approval to them would put approval authority over the record outside its `
1399+
+ `tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry `
1400+
+ `no organization of their own. Remove them from the team, grant them a membership in `
1401+
+ `this organization, or route this step with an approver type that names someone in it.`,
1402+
{
1403+
teamId,
1404+
requestOrganizationId: requestOrg,
1405+
droppedUserIds: dropped.map(d => d.userId),
1406+
droppedMemberOrganizationIds: dropped.map(d => d.organizationIds),
1407+
},
1408+
);
1409+
}
1410+
return kept;
1411+
}
1412+
12751413
/**
12761414
* Tenant scope for a `sys_business_unit` read that may legitimately be
12771415
* env-wide (#3807).

0 commit comments

Comments
 (0)