Skip to content

Commit 3cba6af

Browse files
committed
feat(webapp): WorkOS Directory Sync (SCIM) for Identity & Access
Extend the SSO plugin contract for directory sync and apply membership effects from the accounts webhook worker: provision users in mapped groups (role from group mapping, else the org default role), deprovision on removal, and keep a sticky-removal tombstone so JIT never silently re-adds a removed user. JIT and Directory Sync coexist; roles default to Developer (the JIT default-role picker has no 'None'). Changing a group's role in the dashboard re-applies it to that group's current members immediately. The Directory Sync settings section (group→role mapping, external-domain + manual-membership policy, deferred Save) appears once a domain is verified — independent of SSO — gated by the hasSso flag. The settings page polls the whole page while entitled with override-aware drafts so in-progress edits are never clobbered.
1 parent ad74568 commit 3cba6af

14 files changed

Lines changed: 1173 additions & 84 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/plugins": patch
3+
---
4+
5+
Extend the SSO plugin contract with WorkOS Directory Sync (SCIM) support.

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@ 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";
8+
import { ssoController } from "~/services/sso.server";
89

910
export const INVITE_NOT_FOUND = "Invite not found";
11+
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
12+
"Membership for this organization is managed by Directory Sync, so invites can't be accepted.";
1013
export const ENV_SETUP_INCOMPLETE =
1114
"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.";
1215

1316
export function isAcceptInviteFormError(error: unknown): error is Error {
1417
return (
1518
error instanceof Error &&
16-
(error.message === INVITE_NOT_FOUND || error.message === ENV_SETUP_INCOMPLETE)
19+
(error.message === INVITE_NOT_FOUND ||
20+
error.message === ENV_SETUP_INCOMPLETE ||
21+
error.message === INVITE_BLOCKED_DIRECTORY_MANAGED)
1722
);
1823
}
1924

@@ -417,6 +422,14 @@ export async function acceptInvite({
417422
throw new Error(INVITE_NOT_FOUND);
418423
}
419424

425+
// Directory-managed membership: accepting an invite would add a member
426+
// outside the directory. Block it (the invite can still be revoked by an
427+
// admin). Fail-open on a plugin error so a hiccup doesn't strand joiners.
428+
const membershipPolicy = await ssoController.getMembershipPolicy(invite.organizationId);
429+
if (membershipPolicy.isOk() && !membershipPolicy.value.manualMembershipAllowed) {
430+
throw new Error(INVITE_BLOCKED_DIRECTORY_MANAGED);
431+
}
432+
420433
const maximumConcurrencyLimit = await getDefaultEnvironmentConcurrencyLimit(
421434
invite.organizationId,
422435
"DEVELOPMENT"
@@ -501,6 +514,12 @@ export async function acceptInvite({
501514
});
502515
}
503516

517+
// Deliberate re-admission clears any sticky-removal tombstone so this
518+
// membership isn't shadowed by a prior removal (best-effort; no-op in OSS).
519+
await ssoController
520+
.clearMembershipRemoval({ organizationId: invite.organization.id, userId: user.id })
521+
.unwrapOr(undefined);
522+
504523
return { remainingInvites, organization: invite.organization };
505524
}
506525

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

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Prisma, prisma } from "~/db.server";
22
import { logger } from "~/services/logger.server";
33
import { rbac } from "~/services/rbac.server";
4+
import {
5+
getValidPersonalAccessTokens,
6+
revokePersonalAccessToken,
7+
} from "~/services/personalAccessToken.server";
48

59
export type EnsureOrgMemberParams = {
610
userId: string;
@@ -9,7 +13,7 @@ export type EnsureOrgMemberParams = {
913
// value is an RBAC role id; when an RBAC plugin is installed it gets
1014
// attached after the OrgMember row is created.
1115
roleId: string | null;
12-
source: "sso_jit" | "invite" | "manual";
16+
source: "sso_jit" | "invite" | "manual" | "directory_sync";
1317
};
1418

1519
export type EnsureOrgMemberResult = { created: boolean; orgMemberId: string };
@@ -132,3 +136,107 @@ export async function ensureOrgMember(
132136

133137
return { created: true, orgMemberId: member.id };
134138
}
139+
140+
// Find-or-create a User for a directory-provisioned member. Directory Sync
141+
// can provision a user before they have ever logged in, so the User row may
142+
// not exist yet. Email is the natural key (lowercased). New rows are marked
143+
// SSO since the user will authenticate via the org's IdP.
144+
export async function ensureUserForDirectory(params: {
145+
email: string;
146+
firstName: string | null;
147+
lastName: string | null;
148+
}): Promise<{ userId: string }> {
149+
const email = params.email.toLowerCase().trim();
150+
const existing = await prisma.user.findFirst({ where: { email }, select: { id: true } });
151+
if (existing) return { userId: existing.id };
152+
153+
const name = [params.firstName, params.lastName].filter(Boolean).join(" ").trim() || null;
154+
const created = await prisma.user.create({
155+
data: {
156+
email,
157+
authenticationMethod: "SSO",
158+
name,
159+
displayName: name,
160+
},
161+
select: { id: true },
162+
});
163+
return { userId: created.id };
164+
}
165+
166+
// Whether the user holds the Owner system role in this org. Owner is the one
167+
// role Directory Sync must never strip (it can't be auto-granted and is the
168+
// org's recovery anchor), so deprovision is guarded against removing the last
169+
// one. Identified by the RBAC system role; OSS-safe (no plugin → not Owner).
170+
function isOwnerRole(role: { name: string; isSystem: boolean } | null): boolean {
171+
return !!role && role.isSystem && role.name === "Owner";
172+
}
173+
174+
export type RemoveOrgMemberForDirectoryResult =
175+
| { removed: true }
176+
| { removed: false; reason: "not_a_member" | "last_owner_protected" };
177+
178+
// Deprovision a directory-removed user from an org: hard-delete the
179+
// OrgMember, drop the RBAC role, force-logout (nextSessionEnd), and revoke
180+
// the user's personal access tokens ONLY when this was their last org (PATs
181+
// are user-global, so revoking on a single-org removal would break their CLI
182+
// access to other orgs). Refuses to remove the org's last Owner.
183+
export async function removeOrgMemberForDirectory(params: {
184+
userId: string;
185+
organizationId: string;
186+
}): Promise<RemoveOrgMemberForDirectoryResult> {
187+
const { userId, organizationId } = params;
188+
189+
const member = await prisma.orgMember.findFirst({
190+
where: { userId, organizationId },
191+
select: { id: true },
192+
});
193+
if (!member) return { removed: false, reason: "not_a_member" };
194+
195+
// Last-Owner guard: never leave the org without an Owner. Resolve every
196+
// member's RBAC role and bail if this user is the only Owner.
197+
const members = await prisma.orgMember.findMany({
198+
where: { organizationId },
199+
select: { userId: true },
200+
});
201+
const roles = await rbac.getUserRoles(
202+
members.map((m) => m.userId),
203+
organizationId
204+
);
205+
if (isOwnerRole(roles.get(userId) ?? null)) {
206+
const otherOwners = members.filter(
207+
(m) => m.userId !== userId && isOwnerRole(roles.get(m.userId) ?? null)
208+
);
209+
if (otherOwners.length === 0) {
210+
logger.warn("removeOrgMemberForDirectory: refusing to remove last Owner", {
211+
userId,
212+
organizationId,
213+
});
214+
return { removed: false, reason: "last_owner_protected" };
215+
}
216+
}
217+
218+
await prisma.orgMember.delete({ where: { id: member.id } });
219+
const removeRole = await rbac.removeUserRole({ userId, organizationId });
220+
if (!removeRole.ok) {
221+
logger.warn("removeOrgMemberForDirectory: failed to remove RBAC role", {
222+
userId,
223+
organizationId,
224+
error: removeRole.error,
225+
});
226+
}
227+
228+
// Force logout everywhere.
229+
await prisma.user.update({ where: { id: userId }, data: { nextSessionEnd: new Date() } });
230+
231+
// Revoke PATs only if the user no longer belongs to ANY org — PATs are
232+
// user-global and used by the CLI across every org the user is in.
233+
const remainingMemberships = await prisma.orgMember.count({ where: { userId } });
234+
if (remainingMemberships === 0) {
235+
const tokens = await getValidPersonalAccessTokens(userId);
236+
for (const token of tokens) {
237+
await revokePersonalAccessToken(token.id, userId);
238+
}
239+
}
240+
241+
return { removed: true };
242+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server";
3333
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3434
import { scheduleEmail } from "~/services/scheduleEmail.server";
3535
import { rbac } from "~/services/rbac.server";
36+
import { ssoController } from "~/services/sso.server";
3637
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3738
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
3839
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
@@ -171,7 +172,7 @@ export const action = dashboardAction(
171172
},
172173
authorization: { action: "manage", resource: { type: "members" } },
173174
},
174-
async ({ request, params, user }) => {
175+
async ({ request, params, user, context }) => {
175176
const userId = user.id;
176177
const { organizationSlug } = params;
177178

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

186+
// Directory-managed membership: inviting is disabled (the directory is the
187+
// authority). Enforced here; the Team page also hides the invite button.
188+
if (context.organizationId) {
189+
const policy = await ssoController.getMembershipPolicy(context.organizationId);
190+
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
191+
return json(
192+
{ errors: { body: "Membership is managed by Directory Sync" } },
193+
{ status: 403 }
194+
);
195+
}
196+
}
197+
185198
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
186199
// role → don't pass one through; the runtime fallback handles it.
187200
// Validation: the chosen role must be in the org's assignable set

0 commit comments

Comments
 (0)