Skip to content

Commit 56e02e3

Browse files
committed
fix(webapp): centralize the last-member guard in removeTeamMember
removeTeamMember now lives in its own leaf module with an org-scoped delete. Add an atomic (Serializable) guard so an organization can't be left with zero members, enforced centrally for both the dashboard team page and the management API. It throws ServiceValidationError (404 member-not-found, 400 last-member) so the PAT action builder maps the response status; existing messages are unchanged so the model's tests still hold. The API members route imports the relocated module and passes the prisma client, and drops its own now-removed copy plus the now-unused imports.
1 parent 5f558e5 commit 56e02e3

4 files changed

Lines changed: 76 additions & 26 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
2-
import { Prisma as PrismaNamespace, type Prisma, prisma, $transaction } from "~/db.server";
2+
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
33
import { createEnvironment } from "./organization.server";
44
import { customAlphabet } from "nanoid";
55
import { logger } from "~/services/logger.server";
66
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
77
import { rbac } from "~/services/rbac.server";
88
import { ssoController } from "~/services/sso.server";
9-
import { ServiceValidationError } from "~/v3/services/common.server";
109

1110
export const INVITE_NOT_FOUND = "Invite not found";
1211
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { PrismaClient } from "@trigger.dev/database";
2+
import { ServiceValidationError } from "~/v3/services/common.server";
23

34
// Leaf module with a type-only Prisma import (caller passes the client) so it
45
// can be unit-tested without importing `~/db.server`, which eagerly connects
5-
// the global prisma singleton.
6+
// the global prisma singleton. ServiceValidationError is a plain error class
7+
// with no imports, so it stays leaf-safe and lets callers map it to a status.
68
export async function removeTeamMember(
79
{
810
userId,
@@ -20,22 +22,38 @@ export async function removeTeamMember(
2022
});
2123

2224
if (!org) {
23-
throw new Error("User does not have access to this organization");
25+
throw new ServiceValidationError("User does not have access to this organization", 403);
2426
}
2527

26-
// Scope both the lookup and the delete to org.id, in a transaction, so the
27-
// member id is only ever resolved within the actor's organization.
28-
return prismaClient.$transaction(async (tx) => {
29-
const target = await tx.orgMember.findFirst({
30-
where: { id: memberId, organizationId: org.id },
31-
include: { organization: true, user: true },
32-
});
28+
// Serializable so the "keep at least one member" check and the delete are
29+
// atomic: at ReadCommitted two concurrent removals could each see >1 member
30+
// and both delete, orphaning the org. The guard lives here, not per-caller,
31+
// so every surface (dashboard + management API) is TOCTOU-safe. Raw
32+
// $transaction (not the ~/db.server helper) keeps this module leaf/testable.
33+
return prismaClient.$transaction(
34+
async (tx) => {
35+
// Scope both the lookup and the delete to org.id, so the member id is
36+
// only ever resolved within the actor's organization.
37+
const target = await tx.orgMember.findFirst({
38+
where: { id: memberId, organizationId: org.id },
39+
include: { organization: true, user: true },
40+
});
3341

34-
if (!target) {
35-
throw new Error("Member not found in this organization");
36-
}
42+
if (!target) {
43+
throw new ServiceValidationError("Member not found in this organization", 404);
44+
}
3745

38-
await tx.orgMember.delete({ where: { id: target.id } });
39-
return target;
40-
});
46+
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
47+
if (memberCount <= 1) {
48+
throw new ServiceValidationError(
49+
"Cannot remove the last member of an organization",
50+
400
51+
);
52+
}
53+
54+
await tx.orgMember.delete({ where: { id: target.id } });
55+
return target;
56+
},
57+
{ isolationLevel: "Serializable" }
58+
);
4159
}

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { prisma } from "~/db.server";
4-
import { removeTeamMember } from "~/models/member.server";
4+
import { removeTeamMember } from "~/models/removeTeamMember.server";
55
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
66
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
77
import { ssoController } from "~/services/sso.server";
@@ -44,14 +44,17 @@ export const action = createActionPATApiRoute(
4444
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
4545
}
4646

47-
// removeTeamMember enforces the last-member guard and throws
48-
// ServiceValidationError (member-not-found / last-member), which the
49-
// builder maps to its status.
50-
const removed = await removeTeamMember({
51-
userId: authentication.userId,
52-
slug: organization.slug,
53-
memberId: params.memberId,
54-
});
47+
// Org-scoped, TOCTOU-safe delete shared with the dashboard Team page. The
48+
// model throws ServiceValidationError (member-not-found 404, last-member
49+
// guard 400), which the builder maps to the response status.
50+
const removed = await removeTeamMember(
51+
{
52+
userId: authentication.userId,
53+
slug: organization.slug,
54+
memberId: params.memberId,
55+
},
56+
prisma
57+
);
5558

5659
// Sticky removal: record a tombstone so passive SSO-JIT won't re-add them
5760
// (best-effort; no-op without the SSO plugin).

apps/webapp/test/removeTeamMember.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,34 @@ describe("removeTeamMember", () => {
155155
).rejects.toThrow("Member not found in this organization");
156156
}
157157
);
158+
159+
containerTest(
160+
"refuses to remove the sole member (last-member guard, locks the message)",
161+
async ({ prisma }) => {
162+
const slug = `orgsolo_${Math.random().toString(36).slice(2, 10)}`;
163+
const soloUser = await prisma.user.create({
164+
data: { email: `solo_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
165+
});
166+
const organization = await prisma.organization.create({
167+
data: {
168+
title: slug,
169+
slug,
170+
members: { create: { userId: soloUser.id, role: "ADMIN" } },
171+
},
172+
include: { members: true },
173+
});
174+
const soloMember = organization.members[0];
175+
176+
await expect(
177+
removeTeamMember(
178+
{ userId: soloUser.id, slug: organization.slug, memberId: soloMember.id },
179+
prisma
180+
)
181+
).rejects.toThrow("Cannot remove the last member of an organization");
182+
183+
const stillThere = await prisma.orgMember.findUnique({ where: { id: soloMember.id } });
184+
expect(stillThere).not.toBeNull();
185+
}
186+
);
187+
158188
});

0 commit comments

Comments
 (0)