Skip to content

Commit 43ff2e0

Browse files
committed
fix(webapp): don't apply an invite's role to an existing org member
`acceptInvite` looked up an existing OrgMember and skipped creating one when it found one, but still passed the invite's `rbacRoleId` to `rbac.setUserRole` regardless. For a user who was already a member that is a role *change*, not an initial assignment, so accepting a long-pending invite that carries a lesser role attempted to demote them — including demoting an org's sole Owner, which the role layer then correctly refuses. - `acceptInvite` now tracks whether this accept created the membership and only applies the invite's role in that case, following the rule `ensureOrgMember` already documents. A create that loses the unique-constraint race counts as pre-existing, since whichever flow won it owns that membership's role. - `assignInviteRbacRole` classifies a refusal instead of logging every one at `error`: a `last_owner` code logs at `info`, matching the directory-sync role paths, and anything else — including a refusal that carries no code — logs at `warn`. The helper is best-effort and never throws, so it was logging an expected outcome at an inappropriate severity. - `inviteMembers` skips emails that already belong to a member of the org. The invite table's unique constraint only dedupes pending invites, so nothing previously stopped an existing member being invited again. Skipped addresses are reported the same way an already-pending invite is: left out of the returned list. Adds coverage for all three in apps/webapp/test/member.server.test.ts.
1 parent 205bdc3 commit 43ff2e0

3 files changed

Lines changed: 239 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Accepting an old invitation could change the role of someone who was already in the organization. An invitation now leaves an existing member's role untouched, and people who are already in an organization are no longer sent invitations to it.

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

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ export async function inviteMembers({
100100
throw new Error("User does not have access to this organization");
101101
}
102102

103+
const uniqueEmails = new Set(emails);
104+
105+
// Emails that already belong to a member of this org. The unique constraint
106+
// on the invite table dedupes against *pending invites* only, so nothing else
107+
// stops an existing member being invited again — and accepting such an invite
108+
// is a role change on an established membership, not a join (see
109+
// `acceptInvite`). Skip those emails and report them exactly like an
110+
// already-pending one: left out of the returned list, without failing the
111+
// rest of the batch.
112+
const existingMembers = await prisma.orgMember.findMany({
113+
where: {
114+
organizationId: org.id,
115+
user: { email: { in: [...uniqueEmails] } },
116+
},
117+
select: { user: { select: { email: true } } },
118+
});
119+
const existingMemberEmails = new Set(existingMembers.map((member) => member.user.email));
120+
103121
// Create one invite per unique email and return ONLY the invites actually
104122
// created by this call. A P2002 means the email is already invited to this org
105123
// (unique org+email) — skip it so one duplicate can't fail the batch, and
@@ -109,7 +127,11 @@ export async function inviteMembers({
109127
include: { organization: true; inviter: true };
110128
}>[] = [];
111129

112-
for (const email of new Set(emails)) {
130+
for (const email of uniqueEmails) {
131+
if (existingMemberEmails.has(email)) {
132+
continue;
133+
}
134+
113135
try {
114136
const invite = await prisma.orgMemberInvite.create({
115137
data: {
@@ -276,12 +298,29 @@ async function assignInviteRbacRole({
276298
roleId: rbacRoleId,
277299
});
278300
if (!roleResult.ok) {
279-
logger.error("acceptInvite: skipped RBAC role assignment", {
280-
organizationId,
281-
userId,
282-
rbacRoleId,
283-
reason: roleResult.error,
284-
});
301+
// An org must keep at least one Owner, so the plugin refuses a change
302+
// that would remove the last one. That's expected, not a failure — the
303+
// same outcome the directory-sync role paths log at info.
304+
if (roleResult.code === "last_owner") {
305+
logger.info("acceptInvite: kept last Owner, skipped RBAC role assignment", {
306+
organizationId,
307+
userId,
308+
rbacRoleId,
309+
});
310+
} else {
311+
// Any other refusal (plan gating, no plugin installed, a validation
312+
// conflict) is a documented `{ok:false}` outcome of this contract, and
313+
// this helper is best-effort and never throws — the membership stands
314+
// either way. Log it and move on rather than treating it as an error.
315+
// Unrecognised codes land here too, so a refusal that carries no code
316+
// is still reported at a level that matches its impact.
317+
logger.warn("acceptInvite: skipped RBAC role assignment", {
318+
organizationId,
319+
userId,
320+
rbacRoleId,
321+
reason: roleResult.error,
322+
});
323+
}
285324
}
286325
} catch (error) {
287326
logger.error("acceptInvite: RBAC role assignment threw", {
@@ -415,6 +454,13 @@ export async function acceptInvite({
415454
},
416455
});
417456

457+
// Whether this accept is what made the user a member. Only a membership
458+
// created here gets the invite's RBAC role applied further down — for anyone
459+
// who was already a member, applying it would be a role change rather than an
460+
// initial assignment. A create that lost the P2002 race counts as existing
461+
// too: whoever won it owns the role for that membership.
462+
let membershipCreated = false;
463+
418464
if (!member) {
419465
try {
420466
member = await prisma.orgMember.create({
@@ -424,6 +470,7 @@ export async function acceptInvite({
424470
role: invite.role,
425471
},
426472
});
473+
membershipCreated = true;
427474
} catch (error) {
428475
if (
429476
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
@@ -473,12 +520,19 @@ export async function acceptInvite({
473520

474521
const remainingInvites = await getUsersInvites({ email: user.email });
475522

476-
// If the invite carried an explicit RBAC role, assign it. Best-effort: the
477-
// invite is already consumed and membership created above, so a failure here
478-
// — a returned {ok:false} or a thrown error from the plugin — must not block
479-
// joining the org. Swallow and log either way; without the catch a plugin
480-
// throw escapes and turns the whole invite-accept into a 400.
481-
if (invite.rbacRoleId) {
523+
// If the invite carried an explicit RBAC role, assign it — but only to a
524+
// membership this accept actually created. For someone who was already a
525+
// member this is a role *change*, not an initial assignment: we do NOT touch
526+
// the existing role to avoid demoting a member who has since been promoted
527+
// (a long-pending invite can carry a lesser role than they now hold), the
528+
// same rule `ensureOrgMember` follows.
529+
//
530+
// Best-effort for a new member: the invite is already consumed and membership
531+
// created above, so a failure here — a returned {ok:false} or a thrown error
532+
// from the plugin — must not block joining the org. Swallow and log either
533+
// way; without the catch a plugin throw escapes and turns the whole
534+
// invite-accept into a 400.
535+
if (invite.rbacRoleId && membershipCreated) {
482536
await assignInviteRbacRole({
483537
userId: user.id,
484538
organizationId: invite.organization.id,

apps/webapp/test/member.server.test.ts

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,28 @@ const prismaHolder = vi.hoisted(() => ({
66
client: null as PrismaClient | null,
77
}));
88

9+
type SetUserRoleParams = { userId: string; organizationId: string; roleId: string };
10+
type SetUserRoleResult = { ok: true } | { ok: false; error: string; code?: "last_owner" };
11+
12+
const rbacHolder = vi.hoisted(() => ({
13+
setUserRoleCalls: [] as SetUserRoleParams[],
14+
setUserRoleResult: { ok: true } as SetUserRoleResult,
15+
}));
16+
917
vi.mock("~/services/rbac.server", () => ({
1018
rbac: {
11-
setUserRole: async () => ({ ok: true as const }),
19+
setUserRole: async (params: SetUserRoleParams) => {
20+
rbacHolder.setUserRoleCalls.push(params);
21+
return rbacHolder.setUserRoleResult;
22+
},
1223
},
1324
}));
1425

26+
function resetRbac(result: SetUserRoleResult = { ok: true }) {
27+
rbacHolder.setUserRoleCalls.length = 0;
28+
rbacHolder.setUserRoleResult = result;
29+
}
30+
1531
vi.mock("~/db.server", () => ({
1632
get prisma() {
1733
if (!prismaHolder.client) {
@@ -39,7 +55,7 @@ function randomHex(len = 12): string {
3955

4056
async function seedInviteFixture(
4157
prisma: PrismaClient,
42-
opts: { activeProjectCount: number; deletedProjectCount?: number }
58+
opts: { activeProjectCount: number; deletedProjectCount?: number; rbacRoleId?: string }
4359
) {
4460
const suffix = randomHex(8);
4561
const inviter = await prisma.user.create({
@@ -99,6 +115,7 @@ async function seedInviteFixture(
99115
organizationId: organization.id,
100116
inviterId: inviter.id,
101117
role: "MEMBER",
118+
rbacRoleId: opts.rbacRoleId ?? null,
102119
},
103120
});
104121

@@ -253,6 +270,153 @@ describe("acceptInvite", () => {
253270
expect(member).toBeNull();
254271
}
255272
);
273+
274+
postgresTest(
275+
"applies the invite RBAC role when it creates the membership",
276+
{ timeout: 60_000 },
277+
async ({ prisma }) => {
278+
prismaHolder.client = prisma;
279+
resetRbac();
280+
const { acceptInvite } = await import("../app/models/member.server");
281+
282+
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
283+
activeProjectCount: 1,
284+
rbacRoleId: "role_admin",
285+
});
286+
287+
await acceptInvite({
288+
inviteId: invite.id,
289+
organizationId: organization.id,
290+
user: { id: invitee.id, email: invitee.email },
291+
});
292+
293+
expect(rbacHolder.setUserRoleCalls).toEqual([
294+
{ userId: invitee.id, organizationId: organization.id, roleId: "role_admin" },
295+
]);
296+
}
297+
);
298+
299+
postgresTest(
300+
"does not apply the invite RBAC role to a user who is already a member",
301+
{ timeout: 60_000 },
302+
async ({ prisma }) => {
303+
prismaHolder.client = prisma;
304+
resetRbac();
305+
const { acceptInvite } = await import("../app/models/member.server");
306+
307+
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
308+
activeProjectCount: 1,
309+
rbacRoleId: "role_admin",
310+
});
311+
312+
// The user is already in the org, so accepting the invite must not touch
313+
// their role — it could be a demotion.
314+
await prisma.orgMember.create({
315+
data: {
316+
organizationId: organization.id,
317+
userId: invitee.id,
318+
role: "MEMBER",
319+
},
320+
});
321+
322+
await acceptInvite({
323+
inviteId: invite.id,
324+
organizationId: organization.id,
325+
user: { id: invitee.id, email: invitee.email },
326+
});
327+
328+
expect(rbacHolder.setUserRoleCalls).toEqual([]);
329+
330+
// The invite is still consumed and the membership left intact.
331+
const remainingInvite = await prisma.orgMemberInvite.findFirst({
332+
where: { id: invite.id },
333+
});
334+
expect(remainingInvite).toBeNull();
335+
336+
const member = await prisma.orgMember.findFirst({
337+
where: { userId: invitee.id, organizationId: organization.id },
338+
});
339+
expect(member).not.toBeNull();
340+
}
341+
);
342+
343+
postgresTest(
344+
"still joins the org when the RBAC role assignment is refused",
345+
{ timeout: 60_000 },
346+
async ({ prisma }) => {
347+
prismaHolder.client = prisma;
348+
resetRbac({
349+
ok: false,
350+
error: "An organisation must have at least one Owner",
351+
code: "last_owner",
352+
});
353+
const { acceptInvite } = await import("../app/models/member.server");
354+
355+
const { invitee, organization, invite } = await seedInviteFixture(prisma, {
356+
activeProjectCount: 1,
357+
rbacRoleId: "role_admin",
358+
});
359+
360+
const { organization: joinedOrg } = await acceptInvite({
361+
inviteId: invite.id,
362+
organizationId: organization.id,
363+
user: { id: invitee.id, email: invitee.email },
364+
});
365+
366+
expect(joinedOrg.id).toBe(organization.id);
367+
expect(rbacHolder.setUserRoleCalls).toHaveLength(1);
368+
369+
const member = await prisma.orgMember.findFirst({
370+
where: { userId: invitee.id, organizationId: organization.id },
371+
});
372+
expect(member).not.toBeNull();
373+
374+
resetRbac();
375+
}
376+
);
377+
});
378+
379+
describe("inviteMembers", () => {
380+
postgresTest(
381+
"skips emails that already belong to a member of the org",
382+
{ timeout: 60_000 },
383+
async ({ prisma }) => {
384+
prismaHolder.client = prisma;
385+
const { inviteMembers } = await import("../app/models/member.server");
386+
387+
const { inviter, invitee, organization, invite } = await seedInviteFixture(prisma, {
388+
activeProjectCount: 0,
389+
});
390+
391+
// Drop the seeded pending invite so the skip can only come from the
392+
// membership check, not from the pending-invite dedupe.
393+
await prisma.orgMemberInvite.delete({ where: { id: invite.id } });
394+
395+
await prisma.orgMember.create({
396+
data: {
397+
organizationId: organization.id,
398+
userId: invitee.id,
399+
role: "MEMBER",
400+
},
401+
});
402+
403+
const newcomerEmail = `newcomer-${randomHex(8)}@test.local`;
404+
405+
const created = await inviteMembers({
406+
slug: organization.slug,
407+
emails: [invitee.email, newcomerEmail],
408+
userId: inviter.id,
409+
});
410+
411+
expect(created.map((row) => row.email)).toEqual([newcomerEmail]);
412+
413+
const invites = await prisma.orgMemberInvite.findMany({
414+
where: { organizationId: organization.id },
415+
select: { email: true },
416+
});
417+
expect(invites.map((pending) => pending.email)).toEqual([newcomerEmail]);
418+
}
419+
);
256420
});
257421

258422
describe("provisionMemberDevelopmentEnvironments", () => {

0 commit comments

Comments
 (0)