Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/directory-sync-plugin-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/plugins": patch
---

Extend the SSO plugin contract with WorkOS Directory Sync (SCIM) support.
25 changes: 22 additions & 3 deletions apps/webapp/app/models/member.server.ts
Comment thread
0ski marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import { customAlphabet } from "nanoid";
import { logger } from "~/services/logger.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";

export const INVITE_NOT_FOUND = "Invite not found";
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
"Membership for this organization is managed by Directory Sync, so invites can't be accepted.";
export const ENV_SETUP_INCOMPLETE =
"You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists.";

export function isAcceptInviteFormError(error: unknown): error is Error {
return (
error instanceof Error &&
(error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE)
(error.message === INVITE_NOT_FOUND ||
error.message === ENV_SETUP_INCOMPLETE ||
error.message === INVITE_BLOCKED_DIRECTORY_MANAGED)
);
}

Expand Down Expand Up @@ -417,6 +422,14 @@ export async function acceptInvite({
throw new Error(INVITE_NOT_FOUND);
}

// Directory-managed membership: accepting an invite would add a member
// outside the directory. Block it (the invite can still be revoked by an
// admin). Fail-open on a plugin error so a hiccup doesn't strand joiners.
const membershipPolicy = await ssoController.getMembershipPolicy(invite.organizationId);
if (membershipPolicy.isOk() && !membershipPolicy.value.manualMembershipAllowed) {
throw new Error(INVITE_BLOCKED_DIRECTORY_MANAGED);
}

const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
invite.organizationId,
"DEVELOPMENT"
Expand Down Expand Up @@ -501,6 +514,12 @@ export async function acceptInvite({
});
}

// Deliberate re-admission clears any sticky-removal tombstone so this
// membership isn't shadowed by a prior removal (best-effort; no-op in OSS).
await ssoController
.clearMembershipRemoval({ organizationId: invite.organization.id, userId: user.id })
.unwrapOr(undefined);

return { remainingInvites, organization: invite.organization };
}

Expand All @@ -513,7 +532,7 @@ export async function declineInvite({
}) {
return await prisma.$transaction(async (tx) => {
//1. delete invite
const declinedInvite = await prisma.orgMemberInvite.delete({
const declinedInvite = await tx.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
Expand All @@ -524,7 +543,7 @@ export async function declineInvite({
});

//2. check for other invites
const remainingInvites = await prisma.orgMemberInvite.findMany({
const remainingInvites = await tx.orgMemberInvite.findMany({
where: {
email: user.email,
},
Expand Down
157 changes: 156 additions & 1 deletion apps/webapp/app/models/orgMember.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Prisma, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { rbac } from "~/services/rbac.server";
import {
getValidPersonalAccessTokens,
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";

export type EnsureOrgMemberParams = {
userId: string;
Expand All @@ -9,7 +13,7 @@ export type EnsureOrgMemberParams = {
// 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: "sso_jit" | "invite" | "manual";
source: "sso_jit" | "invite" | "manual" | "directory_sync";
};

export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
Expand Down Expand Up @@ -132,3 +136,154 @@ export async function ensureOrgMember(

return { created: true, orgMemberId: member.id };
}

// 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 };
Comment thread
0ski marked this conversation as resolved.
} 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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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 } });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const removeRole = await rbac.removeUserRole({ userId, organizationId });
if (!removeRole.ok) {
logger.warn("removeOrgMemberForDirectory: failed to remove RBAC role", {
userId,
organizationId,
error: removeRole.error,
});
}
Comment thread
0ski marked this conversation as resolved.

// 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,
});
}
Comment thread
0ski marked this conversation as resolved.

// 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 };
}
Comment thread
0ski marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
Expand Down Expand Up @@ -171,7 +172,7 @@ export const action = dashboardAction(
},
authorization: { action: "manage", resource: { type: "members" } },
},
async ({ request, params, user }) => {
async ({ request, params, user, context }) => {
const userId = user.id;
const { organizationSlug } = params;

Expand All @@ -182,6 +183,18 @@ export const action = dashboardAction(
return json(submission.reply());
}

// Directory-managed membership: inviting is disabled (the directory is the
// authority). Enforced here; the Team page also hides the invite button.
if (context.organizationId) {
const policy = await ssoController.getMembershipPolicy(context.organizationId);
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
return json(
{ errors: { body: "Membership is managed by Directory Sync" } },
{ status: 403 }
);
}
}

// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
// role → don't pass one through; the runtime fallback handles it.
// Validation: the chosen role must be in the org's assignable set
Expand Down
Loading
Loading