@@ -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).
0 commit comments