@@ -268,6 +268,18 @@ function actingUserId(context: ExecutionContext | undefined): string | null {
268268 */
269269const 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