Skip to content

Commit aa765b9

Browse files
os-warrenclaude
andauthored
fix(plugin-approvals): screen the team approver expansion to the request's organization (#10546)
* fix(plugin-approvals): screen the `team` approver expansion to the request's organization `team` was the last approver expansion that resolved people without asking which organization was asking. A request in org_a routed to a team stamped `organization_id: org_b` placed that team's members into pending_approvers, handing approval authority over the record outside its tenant. Screen the TEAM rather than its members: `sys_team` carries `organization_id` outright, so a team id transitively names exactly one organization and one row answers the question — unlike `sys_user` (#10153's `manager` screen), which carries no tenancy fact and must be placed via `sys_member`. The screen is fail-open on an ABSENT tenancy fact (null org stamp, missing row, unreadable table, request with no organization) and fail-closed only on a present and negative one, matching `managerIsProvablyOutsideOrg` and `businessUnitOrgScope`. Both call sites are threaded: the static `team` branch screens against the request's own organization, and the `expression` / `resolveAs: 'team'` branch against `directoryOrg` — `expression` IS org-scoped, so a declaration there retargets a sibling organization legitimately. Fixes #10230 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx * chore(gates): record the new team-approver engine double in the pinned ledger `check:engine-double-contract` retained the new test file's delete/update doubles as unrecorded coverage. Regenerated with --write: 2 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c109edb commit aa765b9

5 files changed

Lines changed: 428 additions & 8 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
**Who loses access:** members of a team belonging to a *different* organization
6+
than the record being approved. Concretely — a request raised in `org_a` routed
7+
to a `team` approver whose `sys_team.organization_id` is `org_b` used to place
8+
every `sys_team_member` of that team into `pending_approvers`, giving them the
9+
approve/reject buttons on a record they are not a tenant of. They no longer
10+
enter the slate, and the step falls back to the dead `team:<id>` literal with
11+
the existing `#3807` "expanded to nobody" warning — the same shape a cross-org
12+
`position` approver has always produced (#10230).
13+
14+
`team` was the last approver expansion that resolved people without asking
15+
which organization was asking; `department`, `position`, `org_membership_level`
16+
and (since #10153) `manager` all do. The screen reads the team's own
17+
`organization_id`, so it costs one row and a team that fails it never fans out.
18+
19+
**Who does not lose access**, deliberately: a team stamped with the request's
20+
own organization; a team stamped with **no** organization (`organization_id:
21+
null` on a platform object means "owned by no organization" — what a seed
22+
writes, since a seed cannot know the id the runtime mints at boot); a team id
23+
with no `sys_team` row at all; and any request that carries no organization —
24+
all four leave routing exactly as it was, because the tenancy fact is absent
25+
rather than negative.
26+
27+
⚠️ One externally observable accept→reject change beyond the routing itself:
28+
under the non-default `onEmptyApprovers: 'fail'` policy, a node whose *sole*
29+
approver was a cross-org team used to open a request and now throws
30+
`NO_APPROVERS`. Under the default (`admin_rescue`) the node still opens.

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

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -971,7 +971,12 @@ export class ApprovalService implements IApprovalService {
971971

972972
try {
973973
if (type === 'team') {
974-
const users = await this.expandTeamUsers(String(a.value));
974+
// #10230: the request's OWN organization, not `directoryOrg`. They are
975+
// provably equal on this branch (`team` is not org-scoped, so an
976+
// `organization` declaration is refused above), and naming the request
977+
// org says what the screen asserts: tenancy of the record being
978+
// approved, never an ADR-0105 D9 retarget this type does not have.
979+
const users = await this.expandTeamUsers(String(a.value), organizationId);
975980
if (users.length) return users;
976981
} else if (type === 'department' || type === 'business_unit' || type === 'bu') {
977982
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -1139,7 +1144,14 @@ export class ApprovalService implements IApprovalService {
11391144
try {
11401145
if (resolveAs === 'department') users = await this.expandBusinessUnitUsers(key, directoryOrg);
11411146
else if (resolveAs === 'position') users = await this.expandPositionUsers(key, directoryOrg);
1142-
else if (resolveAs === 'team') users = await this.expandTeamUsers(key);
1147+
// #10230: `directoryOrg` and NOT the request org, the opposite of the
1148+
// static `team` branch — and deliberately so. `expression` IS org-scoped
1149+
// (APPROVER_ORG_SCOPED), so a declaration here resolves to a legitimately
1150+
// retargeted sibling organization and the team must belong to the
1151+
// directory actually being consulted. `filterApproversWhoCanRead` below
1152+
// then applies the D2 read screen to what comes back, exactly as it
1153+
// already does for the other `resolveAs` kinds.
1154+
else if (resolveAs === 'team') users = await this.expandTeamUsers(key, directoryOrg);
11431155
else {
11441156
throw new Error(
11451157
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' — `
@@ -1164,9 +1176,30 @@ export class ApprovalService implements IApprovalService {
11641176
return { slots, raw };
11651177
}
11661178

1167-
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1168-
private async expandTeamUsers(teamId: string): Promise<string[]> {
1179+
/**
1180+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
1181+
*
1182+
* Takes an organization for the reason every sibling expansion does
1183+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
1184+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
1185+
* THIS organization". Before #10230 this one did not ask, and it was the last
1186+
* expansion that did not — a `team` approver naming ANOTHER organization's
1187+
* team routed that organization's people an approval over a record they are
1188+
* not a tenant of.
1189+
*
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.
1199+
*/
1200+
private async expandTeamUsers(teamId: string, organizationId?: string | null): Promise<string[]> {
11691201
if (!teamId) return [];
1202+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
11701203
let rows: any[] = [];
11711204
try {
11721205
rows = await this.engine.find('sys_team_member', {
@@ -1179,6 +1212,66 @@ export class ApprovalService implements IApprovalService {
11791212
return Array.from(new Set((rows ?? []).map((r: any) => String(r.user_id ?? '')).filter(Boolean)));
11801213
}
11811214

1215+
/**
1216+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
1217+
*
1218+
* "Provably" carries the same posture the sibling screen states at length in
1219+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
1220+
*
1221+
* - the team row carries an `organization_id` and it is not the request's
1222+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
1223+
* - the row carries no `organization_id`, does not exist, or the read failed
1224+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
1225+
*
1226+
* The `organization_id = null` limb is not timidity — it is the reading
1227+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
1228+
* shape: null on a platform object means "owned by no organization", which is
1229+
* what a seed writes because a seed cannot know the organization id the
1230+
* runtime mints at boot. Treating null as "not mine" would delete every
1231+
* seeded team approver at once — a larger behaviour change than the hole
1232+
* being closed. Measured, and not hypothetically: this package's own
1233+
* `team_ok` expansion fixture is exactly such a stack (it has
1234+
* `sys_team_member` rows, a request carrying an organization, and no
1235+
* `sys_team` row at all).
1236+
*
1237+
* Screening the TEAM before reading its members is also what keeps the cost
1238+
* at one row: a team that fails the screen never fans out.
1239+
*/
1240+
private async teamIsProvablyOutsideOrg(
1241+
teamId: string,
1242+
organizationId?: string | null,
1243+
): Promise<boolean> {
1244+
const requestOrg = organizationId ? String(organizationId) : '';
1245+
// No organization on the request ⇒ nothing to screen against, and no read.
1246+
// The ordinary single-organization / embedded stack costs nothing here.
1247+
if (!requestOrg) return false;
1248+
let rows: any[] = [];
1249+
try {
1250+
// No `as any` on this options bag — #4918's ratchet grandfathers this
1251+
// file for its EXISTING erasures only, and a NEW one must carry the
1252+
// declared type. `ApprovalEngine.find` already accepts it as written.
1253+
rows = await this.engine.find('sys_team', {
1254+
where: { id: teamId },
1255+
fields: ['id', 'organization_id'],
1256+
limit: 1,
1257+
context: SYSTEM_CTX,
1258+
});
1259+
} catch { return false; } // team unreadable — see the fail-open note above
1260+
const row: any = Array.isArray(rows) ? rows[0] : null;
1261+
const teamOrg = row?.organization_id ? String(row.organization_id) : '';
1262+
if (!teamOrg) return false; // no tenancy fact on this team
1263+
if (teamOrg === requestOrg) return false; // it is this org's team — route as before
1264+
this.logger?.warn?.(
1265+
`[approvals] #10230: team '${teamId}' was dropped from the approver slate — `
1266+
+ `'sys_team.organization_id' is '${teamOrg}', not the request's organization `
1267+
+ `'${requestOrg}', so routing this approval to its members would put approval `
1268+
+ `authority over the record outside its tenant. Point the approver at a team in `
1269+
+ `this organization, or route this step with an approver type that names someone in it.`,
1270+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg },
1271+
);
1272+
return true;
1273+
}
1274+
11821275
/**
11831276
* Tenant scope for a `sys_business_unit` read that may legitimately be
11841277
* env-wide (#3807).

packages/plugins/plugin-approvals/src/manager-approver-org-screen.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
* no read to perform it).
2323
* B/B2 — the sibling `position` expansion IS screened, and its screen is not
2424
* reject-everything.
25-
* W — `team` is still NOT screened. #10230 owns that; this card did not
26-
* touch it, and the pin says so out loud.
25+
* W — `team` was still NOT screened when this file was written; #10230
26+
* closed that and INVERTED this pin. It stays here as the cross-file
27+
* statement that no unscreened expansion is left.
2728
* C-a — THE ACCEPT-TO-REJECT FLIP. Under `onEmptyApprovers: 'fail'` a node
2829
* whose sole approver is a cross-org `manager` used to OPEN; it now
2930
* throws `NO_APPROVERS`. That throw is PRE-EXISTING code and a bare
@@ -237,11 +238,15 @@ describe('#10153 manager approver org screen', () => {
237238
expect(err).toBeTruthy();
238239
});
239240

240-
it('W — `team` is STILL not org-screened (#10230 owns it; this card did not touch it)', async () => {
241+
it('W — `team` IS org-screened now too (#10230 landed; the gap this pin held is closed)', async () => {
241242
engine._tables['sys_team'] = [{ id: 'team_b', name: 'B team', organization_id: 'org_b' }];
242243
engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_b', user_id: 'u_team_b' }];
243244
const req = opened(await svc.openNodeRequest(input([{ type: 'team', value: 'team_b' }]), CTX_A));
244245
console.log('[PROBE W] org_a request, org_b team -> pending_approvers =', JSON.stringify(req.pending_approvers));
245-
expect(req.pending_approvers).toEqual(['u_team_b']);
246+
// Inverted by #10230, which this pin was written to hand off to. The two
247+
// directions and the `null` / absent-row limbs live in that card's own file
248+
// (`team-approver-org-screen.test.ts`); what stays HERE is the cross-file
249+
// fact this file exists to keep true — the last unscreened expansion is gone.
250+
expect(req.pending_approvers).toEqual(['team:team_b']);
246251
});
247252
});

0 commit comments

Comments
 (0)