Skip to content

Commit d659ff8

Browse files
committed
fix: enforce last-member guard atomically in removeTeamMember
1 parent afa49a4 commit d659ff8

3 files changed

Lines changed: 52 additions & 26 deletions

File tree

apps/webapp/app/models/member.server.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -92,24 +92,38 @@ export async function removeTeamMember({
9292
throw new ServiceValidationError("User does not have access to this organization", 403);
9393
}
9494

95-
// Scope the target to this org. A member id is a globally unique key, so
96-
// deleting by id alone would remove members of other orgs; bind it to the
97-
// resolved org and reject a foreign id.
98-
const member = await prisma.orgMember.findFirst({
99-
where: { id: memberId, organizationId: org.id },
100-
include: {
101-
organization: true,
102-
user: true,
103-
},
104-
});
95+
// Serializable: the "keep at least one member" check and the delete must not
96+
// interleave with a concurrent removal, or two requests could each pass the
97+
// count and leave the org with zero members. Guard lives here (not per-caller)
98+
// so the dashboard and the management API both get it.
99+
return prisma.$transaction(
100+
async (tx) => {
101+
// Scope the target to this org. A member id is a globally unique key, so
102+
// deleting by id alone would remove members of other orgs; bind it to the
103+
// resolved org and reject a foreign id.
104+
const member = await tx.orgMember.findFirst({
105+
where: { id: memberId, organizationId: org.id },
106+
include: {
107+
organization: true,
108+
user: true,
109+
},
110+
});
105111

106-
if (!member) {
107-
throw new ServiceValidationError("Member not found in this organization", 404);
108-
}
112+
if (!member) {
113+
throw new ServiceValidationError("Member not found in this organization", 404);
114+
}
109115

110-
await prisma.orgMember.delete({ where: { id: member.id } });
116+
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
117+
if (memberCount <= 1) {
118+
throw new ServiceValidationError("Cannot remove the last member of an organization", 400);
119+
}
111120

112-
return member;
121+
await tx.orgMember.delete({ where: { id: member.id } });
122+
123+
return member;
124+
},
125+
{ isolationLevel: PrismaNamespace.TransactionIsolationLevel.Serializable }
126+
);
113127
}
114128

115129
export async function inviteMembers({

apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,9 @@ export const action = createActionPATApiRoute(
3636
return json({ error: "Organization not found" }, { status: 404 });
3737
}
3838

39-
// An org must keep at least one member. The dashboard guards this in the
40-
// UI only; enforce it here since removeTeamMember doesn't.
41-
const memberCount = await prisma.orgMember.count({
42-
where: { organizationId: organization.id },
43-
});
44-
if (memberCount <= 1) {
45-
return json({ error: "Cannot remove the last member of an organization" }, { status: 400 });
46-
}
47-
48-
// removeTeamMember throws ServiceValidationError; the builder maps it to
49-
// its status (e.g. 404 for a member not in this org).
39+
// removeTeamMember enforces the last-member guard and throws
40+
// ServiceValidationError (member-not-found / last-member), which the
41+
// builder maps to its status.
5042
const removed = await removeTeamMember({
5143
userId: authentication.userId,
5244
slug: organization.slug,

apps/webapp/test/auth-api.e2e.full.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3016,4 +3016,24 @@ describe("API", () => {
30163016
expect(res.status).not.toBe(405);
30173017
});
30183018
});
3019+
3020+
// Member removal via the PAT action route. The last-member guard lives in
3021+
// removeTeamMember and surfaces as a ServiceValidationError the builder maps
3022+
// to 400 — so an org can't be emptied of its only member.
3023+
describe("Member removal — last-member guard", () => {
3024+
it("removing the last member is rejected: 400", async () => {
3025+
const server = getTestServer();
3026+
const { user, organization, pat } = await seedTestUserProject(server.prisma);
3027+
const member = await server.prisma.orgMember.findFirst({
3028+
where: { organizationId: organization.id, userId: user.id },
3029+
});
3030+
if (!member) throw new Error("seed did not create an org member");
3031+
3032+
const res = await server.webapp.fetch(
3033+
`/api/v1/orgs/${organization.id}/members/${member.id}`,
3034+
{ method: "DELETE", headers: { Authorization: `Bearer ${pat.token}` } }
3035+
);
3036+
expect(res.status).toBe(400);
3037+
});
3038+
});
30193039
});

0 commit comments

Comments
 (0)