Files
calendar/packages/trpc/server/routers/viewer/teams/removeMember/LegacyRemoveMemberService.ts
T
Pedro CastroGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>keith@cal.com <keithwillcode@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ea66a9093c fix(teams): add organization scope to membership operations (#26964)
* refactor(teams): improve membership validation for org admins

- Add organization scope validation to removeMember handler
- Ensure team operations are scoped to users organization context
- Update related tests

* test(teams): align removeMember tests with updated interface

- Add organizationId to test contexts
- Add team.findMany mock for org validation
- Add test cases for org scope validation

* refactor(teams): use TeamRepository for org validation in LegacyRemoveMemberService

- Add findByIdsAndOrgId method to TeamRepository
- Inject TeamRepository into LegacyRemoveMemberService via constructor
- Update RemoveMemberServiceFactory to instantiate and inject TeamRepository
- Update tests to mock TeamRepository instead of direct prisma calls

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 11:26:05 -03:00

130 lines
4.1 KiB
TypeScript

import * as teamQueries from "@calcom/features/ee/teams/lib/queries";
import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { BaseRemoveMemberService } from "./BaseRemoveMemberService";
import type { RemoveMemberContext, RemoveMemberPermissionResult } from "./IRemoveMemberService";
export class LegacyRemoveMemberService extends BaseRemoveMemberService {
constructor(private teamRepository: TeamRepository) {
super();
}
private async validateTeamsBelongToOrganization(
teamIds: number[],
organizationId: number
): Promise<boolean> {
const teams = await this.teamRepository.findByIdsAndOrgId({ teamIds, orgId: organizationId });
const validTeamIds = new Set(teams.map((t) => t.id));
return teamIds.every((id) => validTeamIds.has(id));
}
async checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult> {
const { userId, isOrgAdmin, organizationId, teamIds } = context;
if (isOrgAdmin) {
if (!organizationId) {
return { hasPermission: false };
}
const teamsValid = await this.validateTeamsBelongToOrganization(teamIds, organizationId);
if (!teamsValid) {
return { hasPermission: false };
}
const userRoles = new Map<number, MembershipRole | null>();
teamIds.forEach((teamId) => {
userRoles.set(teamId, MembershipRole.ADMIN);
});
return {
hasPermission: true,
userRoles,
};
}
// Get all memberships in a single query
const membershipRoles = await prisma.membership.findMany({
where: {
userId,
teamId: {
in: teamIds,
},
},
select: {
role: true,
teamId: true,
},
});
// Create a map for O(1) lookup
const userRoles = new Map<number, MembershipRole | null>();
membershipRoles.forEach((m) => {
userRoles.set(m.teamId, m.role);
});
// Check if user has admin or owner role in all teams
const allowedRoles: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
let hasPermission = true;
for (const teamId of teamIds) {
const userRole = userRoles.get(teamId);
if (!userRole || !allowedRoles.includes(userRole)) {
hasPermission = false;
break;
}
}
return {
hasPermission,
userRoles,
};
}
async validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void> {
const { userId, memberIds, teamIds, isOrgAdmin } = context;
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === userId;
// Only a team owner can remove another team owner (org admins are exempt)
if (!isOrgAdmin) {
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
memberIds.map(async (memberId) => {
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
teamIds.map(async (teamId) => {
return (
(await teamQueries.isTeamOwner(memberId, teamId)) &&
!(await teamQueries.isTeamOwner(userId, teamId))
);
})
).then((results) => results.some((result) => result));
return isAnyTeamOwnerAndCurrentUserNotOwner;
})
).then((results) => results.some((result) => result));
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
});
}
}
// Check if user is trying to remove themselves from a team they own (prevent this)
if (isRemovingSelf && hasPermission) {
const isOwnerOfAnyTeam = await Promise.all(
teamIds.map(async (teamId) => await teamQueries.isTeamOwner(userId, teamId))
).then((results) => results.some((result) => result));
if (isOwnerOfAnyTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
}
}
}
}