Files
Oskar Otwinowski 4efe0a07c4 fix(webapp): create dev environments for SSO and Directory Sync members (#4426)
Members added by SSO just-in-time provisioning or Directory Sync never
got
their per-member DEVELOPMENT environments - only invite acceptance and
project creation created them. `trigger dev` returned "Environment not
found" for those members and the dashboard had no dev view.

ensureOrgMember now queues provisioning for every membership it settles,
so
both paths are covered and members missing environments are repaired on
their next sync. Provisioning runs as a common-worker job to keep
sign-in
and directory webhooks off the per-project write loop. A failed enqueue
surfaces for Directory Sync, whose worker retries the idempotent effect,
and is swallowed for sign-in, where the next login enqueues again.
Environment creation now tolerates a concurrent creator so the
project-creation loop and the job cannot collide on the unique index.

Also fixes environment resolution ignoring dev-environment ownership: a
member without their own dev environment could be handed a colleague's
and
have it persisted as their dashboard preference.
2026-07-30 21:50:15 +02:00

317 lines
12 KiB
TypeScript

import { Prisma, prisma } from "~/db.server";
import type { MembershipSource } from "~/models/member.server";
import { logger } from "~/services/logger.server";
import { enqueueMemberDevelopmentEnvironments } from "~/services/memberDevEnvironments.server";
import { rbac } from "~/services/rbac.server";
import {
getValidPersonalAccessTokens,
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";
export type EnsureOrgMemberParams = {
userId: string;
organizationId: string;
// null = use the seeded MEMBER role from the existing enum. A non-null
// value is an RBAC role id; when an RBAC plugin is installed it gets
// attached after the OrgMember row is created.
roleId: string | null;
source: MembershipSource;
};
export type EnsureOrgMemberResult = {
created: boolean;
orgMemberId: string;
/** False when provisioning could not be queued; the membership is still valid. */
devEnvironmentsQueued: boolean;
};
// Completes a JIT role assignment for an ALREADY-existing membership whose
// RBAC role never got applied. This is a no-op when a role is already
// assigned, so it can never demote a deliberately-set role — it only fills
// in the gap left by an interrupted provision (see `ensureOrgMember`). Always
// best-effort: a valid membership already exists, so a failure here is logged
// and swallowed rather than thrown.
async function healMissingRoleAssignment(params: {
userId: string;
organizationId: string;
roleId: string;
source: EnsureOrgMemberParams["source"];
}): Promise<void> {
const { userId, organizationId, roleId, source } = params;
const currentRole = await rbac.getUserRole({ userId, organizationId });
if (currentRole !== null) return;
const result = await rbac.setUserRole({ userId, organizationId, roleId });
if (!result.ok) {
logger.warn("ensureOrgMember.setUserRole failed while healing unassigned membership", {
source,
userId,
organizationId,
roleId,
error: result.error,
});
}
}
// Idempotent OrgMember upsert. If the (userId, organizationId) row
// already exists this is a no-op (returns `{ created: false }`); we do
// NOT touch the existing role to avoid demoting a user that JIT happens
// to fire for again.
//
// Seat-limit enforcement lives at the call sites — every existing
// OrgMember insert in the codebase does its own seat check before
// calling in. This helper deliberately does none (SSO JIT and
// invite-accept are exempt by policy).
export async function ensureOrgMember(
params: EnsureOrgMemberParams
): Promise<EnsureOrgMemberResult> {
const { userId, organizationId, roleId, source } = params;
const existing = await prisma.orgMember.findFirst({
where: { userId, organizationId },
select: { id: true },
});
if (existing) {
// Existing membership is normally a pure no-op: we don't re-touch the
// role, since a user JIT fires for again may have been deliberately
// promoted and must not be demoted back to the JIT default.
//
// The one exception is self-healing a half-provisioned row. The create +
// setUserRole + compensating delete below are not transactional (the RBAC
// plugin writes on its own connection, so a single DB transaction isn't
// possible). If setUserRole failed AND that compensating delete also
// failed, the placeholder MEMBER row is orphaned — and this findFirst
// would short-circuit every future login, stranding the user on the
// placeholder role forever. So when a JIT role is requested but the RBAC
// layer shows no role assigned, complete the assignment now. It's gated on
// "no role assigned", so it can never demote a real one.
if (roleId !== null) {
await healMissingRoleAssignment({ userId, organizationId, roleId, source });
}
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});
return { created: false, orgMemberId: existing.id, devEnvironmentsQueued: enqueued };
}
// Two concurrent JIT/invite flows can both miss the findFirst above and
// race to create the same (userId, organizationId) row; the unique
// constraint makes one lose with P2002. Treat that as the idempotent
// "already a member" case rather than letting it break sign-in.
let member: { id: string };
try {
member = await prisma.orgMember.create({
data: {
userId,
organizationId,
role: "MEMBER",
},
select: { id: true },
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
const existingAfterConflict = await prisma.orgMember.findFirst({
where: { userId, organizationId },
select: { id: true },
});
if (existingAfterConflict) {
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});
return {
created: false,
orgMemberId: existingAfterConflict.id,
devEnvironmentsQueued: enqueued,
};
}
}
throw error;
}
if (roleId !== null) {
const result = await rbac.setUserRole({ userId, organizationId, roleId });
if (!result.ok) {
// The membership was just created with the legacy `MEMBER` enum role as
// a placeholder; the intended RBAC role failed to apply. Leaving the row
// in place would grant the user `MEMBER` access — potentially broader
// than the configured (e.g. restrictive) JIT default role they were
// supposed to get. Roll back so we never half-provision into an
// unintended privilege level, then throw so the caller can decide
// whether to skip provisioning or fail the flow.
logger.warn("ensureOrgMember.setUserRole failed; rolling back membership", {
source,
userId,
organizationId,
roleId,
error: result.error,
});
await prisma.orgMember.delete({ where: { id: member.id } });
throw new Error(`ensureOrgMember: failed to apply role ${roleId}: ${result.error}`);
}
}
const { enqueued } = await enqueueMemberDevelopmentEnvironments({
userId,
organizationId,
source,
});
return { created: true, orgMemberId: member.id, devEnvironmentsQueued: enqueued };
}
// Find-or-create a User for a directory-provisioned member. Directory Sync
// can provision a user before they have ever logged in, so the User row may
// not exist yet. Email is the natural key (lowercased). New rows are marked
// SSO since the user will authenticate via the org's IdP.
export async function ensureUserForDirectory(params: {
email: string;
firstName: string | null;
lastName: string | null;
}): Promise<{ userId: string }> {
const email = params.email.toLowerCase().trim();
const existing = await prisma.user.findFirst({ where: { email }, select: { id: true } });
if (existing) return { userId: existing.id };
const name = [params.firstName, params.lastName].filter(Boolean).join(" ").trim() || null;
// `User.email` is unique, so two concurrent directory events for the same
// email can both miss the lookup above and race on create; the loser gets
// P2002. Treat that as the idempotent "already exists" case (same pattern as
// `ensureOrgMember`) rather than throwing and burning a webhook retry.
try {
const created = await prisma.user.create({
data: {
email,
authenticationMethod: "SSO",
name,
displayName: name,
},
select: { id: true },
});
return { userId: created.id };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
const existingAfterConflict = await prisma.user.findFirst({
where: { email },
select: { id: true },
});
if (existingAfterConflict) return { userId: existingAfterConflict.id };
}
throw error;
}
}
// Whether the user holds the Owner system role in this org. Owner is the one
// role Directory Sync must never strip (it can't be auto-granted and is the
// org's recovery anchor), so deprovision is guarded against removing the last
// one. Identified by the RBAC system role; OSS-safe (no plugin → not Owner).
function isOwnerRole(role: { name: string; isSystem: boolean } | null): boolean {
return !!role && role.isSystem && role.name === "Owner";
}
export type RemoveOrgMemberForDirectoryResult =
| { removed: true }
| { removed: false; reason: "not_a_member" | "last_owner_protected" };
// Deprovision a directory-removed user from an org: hard-delete the
// OrgMember, drop the RBAC role, force-logout (nextSessionEnd), and revoke
// the user's personal access tokens ONLY when this was their last org (PATs
// are user-global, so revoking on a single-org removal would break their CLI
// access to other orgs). Refuses to remove the org's last Owner.
export async function removeOrgMemberForDirectory(params: {
userId: string;
organizationId: string;
}): Promise<RemoveOrgMemberForDirectoryResult> {
const { userId, organizationId } = params;
const member = await prisma.orgMember.findFirst({
where: { userId, organizationId },
select: { id: true },
});
if (!member) return { removed: false, reason: "not_a_member" };
// Last-Owner guard: never leave the org without an Owner. Resolve every
// member's RBAC role and bail if this user is the only Owner.
const members = await prisma.orgMember.findMany({
where: { organizationId },
select: { userId: true },
});
const roles = await rbac.getUserRoles(
members.map((m) => m.userId),
organizationId
);
if (isOwnerRole(roles.get(userId) ?? null)) {
const otherOwners = members.filter(
(m) => m.userId !== userId && isOwnerRole(roles.get(m.userId) ?? null)
);
if (otherOwners.length === 0) {
logger.warn("removeOrgMemberForDirectory: refusing to remove last Owner", {
userId,
organizationId,
});
return { removed: false, reason: "last_owner_protected" };
}
}
await prisma.orgMember.delete({ where: { id: member.id } });
const removeRole = await rbac.removeUserRole({ userId, organizationId });
if (!removeRole.ok) {
logger.warn("removeOrgMemberForDirectory: failed to remove RBAC role", {
userId,
organizationId,
error: removeRole.error,
});
}
// Post-delete cleanup is best-effort: the membership (the critical state) is
// already gone, so any throw here must not propagate. If it did, the webhook
// worker would retry, hit the `not_a_member` guard above, and skip the rest
// of the cleanup entirely — leaving sessions or PATs behind. Swallowing lets
// this single pass finish force-logout + PAT revocation.
// Force logout everywhere.
try {
await prisma.user.update({ where: { id: userId }, data: { nextSessionEnd: new Date() } });
} catch (error) {
logger.warn("removeOrgMemberForDirectory: failed to force logout", {
userId,
organizationId,
error,
});
}
// Revoke PATs only if the user no longer belongs to ANY org — PATs are
// user-global and used by the CLI across every org the user is in. Each
// revoke is guarded so a concurrent self-revoke (which would throw) or one
// bad token doesn't abort the rest.
try {
const remainingMemberships = await prisma.orgMember.count({ where: { userId } });
if (remainingMemberships === 0) {
const tokens = await getValidPersonalAccessTokens(userId);
for (const token of tokens) {
try {
await revokePersonalAccessToken(token.id, userId);
} catch (error) {
logger.warn("removeOrgMemberForDirectory: failed to revoke PAT", {
userId,
tokenId: token.id,
error,
});
}
}
}
} catch (error) {
logger.warn("removeOrgMemberForDirectory: PAT cleanup failed", {
userId,
organizationId,
error,
});
}
return { removed: true };
}