diff --git a/.changeset/directory-sync-plugin-contract.md b/.changeset/directory-sync-plugin-contract.md new file mode 100644 index 00000000000..535ae1270c1 --- /dev/null +++ b/.changeset/directory-sync-plugin-contract.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +Extend the SSO plugin contract with WorkOS Directory Sync (SCIM) support. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 32de34217f1..d9906f6eed0 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -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) ); } @@ -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" @@ -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 }; } @@ -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, @@ -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, }, diff --git a/apps/webapp/app/models/orgMember.server.ts b/apps/webapp/app/models/orgMember.server.ts index c023e847c95..d3ec2153d79 100644 --- a/apps/webapp/app/models/orgMember.server.ts +++ b/apps/webapp/app/models/orgMember.server.ts @@ -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; @@ -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 }; @@ -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 }; + } 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 { + 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 }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index e6173da559f..010f57debc0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -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"; @@ -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; @@ -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 diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index 9e9d40704e6..a00ea0d97b2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -35,7 +35,10 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; -import type { Role } from "@trigger.dev/plugins"; +import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins"; +import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; +import { flag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { throwPermissionDenied } from "~/utils/permissionDenied"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -71,6 +74,30 @@ async function requireSsoEntitlement(orgId: string): Promise { } } +const EMPTY_DIRECTORY_SYNC_STATUS: DirectorySyncStatus = { + hasDirectory: false, + hasActiveDirectory: false, + allowExternalDomainSync: false, + allowManualMembership: true, + directoryDefaultRoleId: null, + userCount: 0, + directories: [], + groups: [], +}; + +// SSO availability for an org: the per-org feature flag wins, else the global +// flag (default off). This is the single rollout knob for the whole feature — +// SSO and Directory Sync are both gated by it (there is no separate dsync flag). +async function resolveHasSso(orgId: string): Promise { + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { featureFlags: true }, + }); + const perOrg = (org?.featureFlags as Record | null)?.[FEATURE_FLAG.hasSso]; + if (perOrg === true) return true; + return (await flag({ key: FEATURE_FLAG.hasSso, defaultValue: false })) === true; +} + const EMPTY_SSO_STATUS = { hasIdpOrg: false, enforced: false, @@ -78,6 +105,7 @@ const EMPTY_SSO_STATUS = { jitDefaultRoleId: null, idpOrgId: null, primaryConnectionId: null, + hasActiveDirectory: false, domains: [] as Array<{ domain: string; verified: boolean; @@ -125,6 +153,8 @@ export const loader = dashboardLoader( status: EMPTY_SSO_STATUS, orgTitle: context.orgTitle, jitRoles: [] as Role[], + directorySync: EMPTY_DIRECTORY_SYNC_STATUS, + hasSso: false, }); } @@ -135,12 +165,15 @@ export const loader = dashboardLoader( throwPermissionDenied(); } - const [statusResult, allRoles, assignableIds] = await Promise.all([ + const [statusResult, allRoles, assignableIds, dsyncResult, hasSso] = await Promise.all([ ssoController.getStatus(orgId), rbac.allRoles(orgId), rbac.getAssignableRoleIds(orgId), + ssoController.getDirectorySyncStatus(orgId), + resolveHasSso(orgId), ]); const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS; + const directorySync = dsyncResult.isOk() ? dsyncResult.value : EMPTY_DIRECTORY_SYNC_STATUS; // JIT can't promote new users to Owner — that role is reserved for // the founding member and explicit transfers. Plan-gated roles are @@ -149,7 +182,13 @@ export const loader = dashboardLoader( const assignable = new Set(assignableIds); const jitRoles = allRoles.filter((r) => r.name !== "Owner" && assignable.has(r.id)); - return typedjson({ status, orgTitle: context.orgTitle, jitRoles }); + return typedjson({ + status, + orgTitle: context.orgTitle, + jitRoles, + directorySync, + hasSso, + }); } ); @@ -160,6 +199,9 @@ const DEFAULT_JIT_ROLE_NAME = "Developer"; // which treats the string "false" as truthy (any non-empty string). const boolish = z.union([z.literal("true"), z.literal("false")]).transform((v) => v === "true"); +// Only-changed group→role mappings sent by the deferred Directory Sync Save. +const GroupRolesSchema = z.array(z.object({ groupId: z.string(), roleId: z.string() })); + const ActionSchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("save_config"), @@ -169,7 +211,16 @@ const ActionSchema = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("portal_link"), - intent: z.enum(["sso", "domain_verification"]), + intent: z.enum(["sso", "domain_verification", "dsync"]), + }), + // Directory Sync section is a single deferred Save (like the SSO config + // form): all settings + changed group mappings commit together. + z.object({ + action: z.literal("save_dsync_config"), + allowExternalDomainSync: boolish, + allowManualMembership: boolish, + directoryDefaultRoleId: z.string(), + groupRoles: z.string(), }), ]); @@ -201,6 +252,10 @@ export const action = dashboardAction( jitEnabled: formData.get("jitEnabled") ?? undefined, jitRoleId: formData.get("jitRoleId") ?? undefined, intent: formData.get("intent") ?? undefined, + allowExternalDomainSync: formData.get("allowExternalDomainSync") ?? undefined, + allowManualMembership: formData.get("allowManualMembership") ?? undefined, + directoryDefaultRoleId: formData.get("directoryDefaultRoleId") ?? undefined, + groupRoles: formData.get("groupRoles") ?? undefined, }); if (!parsed.success) { return new Response("Bad Request", { status: 400 }); @@ -238,6 +293,69 @@ export const action = dashboardAction( } return Response.json({ ok: true, url: result.value.url }); } + case "save_dsync_config": { + // Parse the changed group→role mappings the deferred Save sent. + let groupRoles: Array<{ groupId: string; roleId: string }>; + try { + groupRoles = GroupRolesSchema.parse(JSON.parse(parsed.data.groupRoles)); + } catch { + return new Response("Bad Request", { status: 400 }); + } + const defaultRoleId = + parsed.data.directoryDefaultRoleId === NULL_ROLE_VALUE + ? null + : parsed.data.directoryDefaultRoleId; + // Hoist out of the narrowed `parsed.data` — the discriminated-union + // narrowing doesn't survive into the thunk closures below. + const { allowExternalDomainSync, allowManualMembership } = parsed.data; + + // Apply the OrgSsoConfig columns first. Not one transaction (group + // mappings are separate rows), but each write is idempotent, so a retry + // of the whole Save converges. Thunks (not pre-started ResultAsyncs) so + // they run strictly one at a time and the first failure stops the rest + // rather than leaving later writes to apply in the background. + const configWrites = [ + () => + ssoController.setAllowExternalDomainSync({ + organizationId: orgId, + allowed: allowExternalDomainSync, + }), + () => + ssoController.setAllowManualMembership({ + organizationId: orgId, + allowed: allowManualMembership, + }), + () => + ssoController.setDirectoryDefaultRole({ organizationId: orgId, roleId: defaultRoleId }), + ]; + for (const write of configWrites) { + const result = await write(); + if (result.isErr()) { + return new Response(`Error: ${result.error}`, { status: 400 }); + } + } + + // Each group remap returns the membership effects it implies for that + // group's current members (roles recomputed against the new mapping, + // deprovision when cleared to "No access" and it was their last mapped + // group). Collect and apply them so the remap takes effect immediately. + const effects: DirectorySyncEffect[] = []; + for (const g of groupRoles) { + const result = await ssoController.setDirectoryGroupRole({ + organizationId: orgId, + groupId: g.groupId, + roleId: g.roleId === NULL_ROLE_VALUE ? null : g.roleId, + }); + if (result.isErr()) { + return new Response(`Error: ${result.error}`, { status: 400 }); + } + effects.push(...result.value.effects); + } + if (effects.length > 0) { + await applyDirectorySyncEffects(effects); + } + return redirect(`/orgs/${params.organizationSlug}/settings/sso`); + } } } ); @@ -250,8 +368,33 @@ function defaultJitRoleId(jitRoles: ReadonlyArray, current: string | null) return dev?.id ?? NULL_ROLE_VALUE; } +// A settings field that mirrors a server value but is locally editable, safe +// to use while the whole page polls: as long as the user hasn't touched the +// field, it adopts fresh server values from revalidation; once edited (dirty) +// it holds the user's value until the server catches up (a successful Save, or +// another admin setting the same value), at which point it snaps back to clean. +// `dirty` never fires a false positive from a poll because the override is +// dropped as soon as the server value matches it. +function useOverrideDraft(serverValue: T): { + value: T; + set: (next: T) => void; + dirty: boolean; +} { + const [override, setOverride] = useState<{ value: T } | null>(null); + useEffect(() => { + // Server caught up to the pending edit → clear the override (back to clean). + setOverride((current) => (current && Object.is(current.value, serverValue) ? null : current)); + }, [serverValue]); + const value = override ? override.value : serverValue; + return { + value, + set: (next) => setOverride({ value: next }), + dirty: override != null && !Object.is(override.value, serverValue), + }; +} + export default function Page() { - const { status, orgTitle, jitRoles } = useTypedLoaderData(); + const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData(); const organization = useOrganization(); const _plan = useCurrentPlan(); @@ -259,34 +402,26 @@ export default function Page() { const activeConnections = status.connections.filter((c) => c.state === "active"); const hasActive = activeConnections.length > 0; - // Deferred-save: each field starts mirrored from `status`, edits stay - // local until Save commits all three to the action. The `key` trick - // below resets local state after a successful save (when `status` - // changes via revalidation following the redirect). + // Deferred-save: each field mirrors `status` but stays locally editable. + // `useOverrideDraft` lets the page poll safely — untouched fields adopt + // fresh server values, edited fields are preserved until Save. const initialJitRoleId = defaultJitRoleId(jitRoles, status.jitDefaultRoleId); - const [draftEnforced, setDraftEnforced] = useState(status.enforced); - const [draftJitEnabled, setDraftJitEnabled] = useState(status.jitProvisioningEnabled); - const [draftJitRoleId, setDraftJitRoleId] = useState(initialJitRoleId); + const enforcedDraft = useOverrideDraft(status.enforced); + const jitEnabledDraft = useOverrideDraft(status.jitProvisioningEnabled); + const jitRoleDraft = useOverrideDraft(initialJitRoleId); + const draftEnforced = enforcedDraft.value; + const setDraftEnforced = enforcedDraft.set; + const draftJitEnabled = jitEnabledDraft.value; + const setDraftJitEnabled = jitEnabledDraft.set; + const draftJitRoleId = jitRoleDraft.value; + const setDraftJitRoleId = jitRoleDraft.set; - // Re-sync drafts when the loader returns fresh `status` (post-save - // redirect → revalidation). useEffect rather than a memo so we don't - // stomp in-flight edits during the same render. - useEffect(() => { - setDraftEnforced(status.enforced); - setDraftJitEnabled(status.jitProvisioningEnabled); - setDraftJitRoleId(defaultJitRoleId(jitRoles, status.jitDefaultRoleId)); - // jitRoles only changes if the org changes; the role list itself is - // stable across saves on a given org. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status.enforced, status.jitProvisioningEnabled, status.jitDefaultRoleId]); - - const isDirty = - draftEnforced !== status.enforced || - draftJitEnabled !== status.jitProvisioningEnabled || - draftJitRoleId !== initialJitRoleId; + const isDirty = enforcedDraft.dirty || jitEnabledDraft.dirty || jitRoleDraft.dirty; const [portalUrl, setPortalUrl] = useState(null); - const [portalIntent, setPortalIntent] = useState<"sso" | "domain_verification" | null>(null); + const [portalIntent, setPortalIntent] = useState<"sso" | "domain_verification" | "dsync" | null>( + null + ); const [enforceModalOpen, setEnforceModalOpen] = useState(false); const portalFetcher = useFetcher<{ ok: boolean; url?: string; error?: string }>(); const saveFetcher = useFetcher(); @@ -299,14 +434,14 @@ export default function Page() { } }, [portalFetcher.data]); - // Poll for fresh domain/connection state only while setup is incomplete: - // the user is finishing steps in the admin portal (another tab) and we - // want the page to reflect them without a manual reload. This covers both - // pre-active states (no IdP org yet, and IdP org but no active connection). - // Once there's an active connection we stop — the ActiveConnectionState form - // holds local draft edits that a revalidation must not stomp. The upsell - // state is excluded by `isEntitled`. - const shouldPoll = isEntitled && !hasActive; + // Poll the whole page while entitled — before an active connection this + // reflects portal progress (domain verified, connection activated), and + // once active it keeps SSO + Directory Sync state fresh (connection + // deleted/deactivated, directory activated/deactivated/deleted, new + // groups). Draft edits survive revalidation because every editable field + // goes through `useOverrideDraft` (dirty fields preserved, clean fields + // adopt server values). The upsell state is excluded by `isEntitled`. + const shouldPoll = isEntitled; useEffect(() => { if (!shouldPoll) return; const id = setInterval(() => { @@ -323,7 +458,7 @@ export default function Page() { return () => clearInterval(id); }, [shouldPoll, revalidator, portalFetcher.state, saveFetcher.state]); - const openPortal = (intent: "sso" | "domain_verification") => { + const openPortal = (intent: "sso" | "domain_verification" | "dsync") => { setPortalUrl(null); setPortalIntent(intent); portalFetcher.submit({ action: "portal_link", intent }, { method: "POST" }); @@ -355,8 +490,12 @@ export default function Page() { ) : !hasActive ? ( openPortal("sso")} onOpenDomain={() => openPortal("domain_verification")} + onOpenDsync={() => openPortal("dsync")} /> ) : ( ; + directorySync: DirectorySyncStatus; + jitRoles: ReadonlyArray; + hasSso: boolean; onOpenSso: () => void; onOpenDomain: () => void; + onOpenDsync: () => void; }) { const verifiedDomains = domains.filter((d) => d.state === "verified"); const failedDomains = domains.filter((d) => d.state === "failed"); @@ -512,6 +661,16 @@ function NoActiveConnectionState({ )} + + {/* Directory Sync is independent of SSO — once a domain is verified an org + can connect a directory without ever configuring SSO. */} + {hasVerifiedDomain && hasSso ? ( + + ) : null} ); } @@ -574,6 +733,8 @@ function ActiveConnectionState({ status, activeConnections, jitRoles, + directorySync, + hasSso, draftEnforced, draftJitEnabled, draftJitRoleId, @@ -594,12 +755,14 @@ function ActiveConnectionState({ }; activeConnections: ReadonlyArray<{ id: string; name: string | null; connectionType: string }>; jitRoles: ReadonlyArray; + directorySync: DirectorySyncStatus; + hasSso: boolean; draftEnforced: boolean; draftJitEnabled: boolean; draftJitRoleId: string; isDirty: boolean; isSaving: boolean; - onOpenPortal: (intent: "sso" | "domain_verification") => void; + onOpenPortal: (intent: "sso" | "domain_verification" | "dsync") => void; onToggleEnforced: (next: boolean) => void; onToggleJit: (next: boolean) => void; onChangeJitRole: (roleId: string | null) => void; @@ -607,6 +770,24 @@ function ActiveConnectionState({ }) { return (
+
+ Verified domains + {status.domains.length === 0 ? ( + + No domains verified yet. + + ) : ( + + )} + +
+
{orgTitle} – SSO connection {activeConnections.map((conn) => ( @@ -631,26 +812,7 @@ function ActiveConnectionState({
-
- Verified domains - {status.domains.length === 0 ? ( - - No domains verified yet. - - ) : ( - - )} - -
-
- Configuration
@@ -683,17 +845,13 @@ function ActiveConnectionState({ be granted automatically.
- + value={draftJitRoleId} - setValue={(v) => onChangeJitRole(v === NULL_ROLE_VALUE ? null : v)} - items={[{ id: NULL_ROLE_VALUE, name: "None", description: "" }, ...jitRoles]} + setValue={(v) => onChangeJitRole(v)} + items={[...jitRoles]} variant="tertiary/small" dropdownIcon - text={(v) => - v === NULL_ROLE_VALUE - ? "None" - : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") - } + text={(v) => jitRoles.find((r) => r.id === v)?.name ?? "Select a role"} > {(items) => items.map((role) => ( @@ -715,6 +873,278 @@ function ActiveConnectionState({
+ + {hasSso ? ( + onOpenPortal("dsync")} + /> + ) : null} +
+ ); +} + +function DirectorySyncSection({ + directorySync, + jitRoles, + onOpenPortal, +}: { + directorySync: DirectorySyncStatus; + jitRoles: ReadonlyArray; + onOpenPortal: () => void; +}) { + const fetcher = useFetcher(); + const isSaving = fetcher.state !== "idle"; + + // Deferred save: edits stay local until Save commits them all together + // (mirrors the SSO Configuration form). `useOverrideDraft` keeps the fields + // safe under whole-page polling — untouched fields adopt fresh server + // values, edited ones are preserved. Role values keep the NULL_ROLE_VALUE + // sentinel in the draft; the action converts it to null on write. + const externalDraft = useOverrideDraft(directorySync.allowExternalDomainSync); + const manualDraft = useOverrideDraft(directorySync.allowManualMembership); + const defaultRoleDraft = useOverrideDraft( + directorySync.directoryDefaultRoleId ?? NULL_ROLE_VALUE + ); + const draftExternal = externalDraft.value; + const setDraftExternal = externalDraft.set; + const draftManual = manualDraft.value; + const setDraftManual = manualDraft.set; + const draftDefaultRole = defaultRoleDraft.value; + const setDraftDefaultRole = defaultRoleDraft.set; + + // Group mappings vary in count, so instead of one draft per group we keep a + // sparse map of only the groups the user has edited (overrides). Rendering + // falls back to the server value, so new groups arriving via polling show up + // immediately, and an override is dropped once the server catches up to it. + const [draftGroupRoles, setDraftGroupRoles] = useState>({}); + useEffect(() => { + setDraftGroupRoles((current) => { + const next: Record = {}; + for (const g of directorySync.groups) { + const override = current[g.groupId]; + if (override === undefined) continue; + // Keep only overrides that still diverge from the server (drops + // saved/externally-matched edits and edits for removed groups). + if (override !== (g.mappedRoleId ?? NULL_ROLE_VALUE)) next[g.groupId] = override; + } + const currentKeys = Object.keys(current); + const unchanged = + currentKeys.length === Object.keys(next).length && + currentKeys.every((k) => next[k] === current[k]); + return unchanged ? current : next; + }); + }, [directorySync.groups]); + + const groupRolesDirty = directorySync.groups.some((g) => { + const override = draftGroupRoles[g.groupId]; + return override !== undefined && override !== (g.mappedRoleId ?? NULL_ROLE_VALUE); + }); + const isDirty = + externalDraft.dirty || manualDraft.dirty || defaultRoleDraft.dirty || groupRolesDirty; + + const submitSave = () => { + // Send only the group mappings that actually changed. + const changedGroups = directorySync.groups + .filter((g) => { + const override = draftGroupRoles[g.groupId]; + return override !== undefined && override !== (g.mappedRoleId ?? NULL_ROLE_VALUE); + }) + .map((g) => ({ groupId: g.groupId, roleId: draftGroupRoles[g.groupId] })); + fetcher.submit( + { + action: "save_dsync_config", + allowExternalDomainSync: draftExternal ? "true" : "false", + allowManualMembership: draftManual ? "true" : "false", + directoryDefaultRoleId: draftDefaultRole, + groupRoles: JSON.stringify(changedGroups), + }, + { method: "POST" } + ); + }; + + return ( +
+ Directory Sync + + Sync users and groups from your identity provider (SCIM). Members in mapped groups are + provisioned automatically, their role follows the group mapping, and removing a user from + your directory removes their access here. + + + {directorySync.directories.length === 0 ? ( + + ) : ( + <> + {directorySync.directories.map((dir) => ( +
+
+ + {dir.name ?? dir.type} + + + {dir.type} · {dir.state === "active" ? "Active" : "Inactive"} ·{" "} + {directorySync.userCount} {directorySync.userCount === 1 ? "user" : "users"} + +
+
+ ))} + + +
+
+ + Sync users outside verified domains + + + By default only directory users whose email domain is verified for this org are + provisioned. Turn on to also provision users on other domains (e.g. contractors). + +
+ +
+ +
+
+ + Allow manual membership management + + + On by default. Turn off to let Directory Sync manage membership exclusively — while + a directory is active, inviting, removing, and leaving are disabled in the + dashboard. + +
+ +
+ +
+
+ + Default role for users without a mapped group + + + Directory users who belong to no mapped group are provisioned at this role + (Developer by default). Choose "No access" to leave them unprovisioned until they + join a mapped group. + +
+ + value={draftDefaultRole} + setValue={(v) => setDraftDefaultRole(v)} + items={[{ id: NULL_ROLE_VALUE, name: "No access", description: "" }, ...jitRoles]} + variant="tertiary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "No access" + : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") + } + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + +
+ +
+ + Group → role mapping + + {directorySync.groups.length === 0 ? ( + + No directory groups synced yet. Groups appear here once your directory syncs them. + + ) : ( + directorySync.groups.map((group) => { + const value = + draftGroupRoles[group.groupId] ?? group.mappedRoleId ?? NULL_ROLE_VALUE; + return ( +
+ + {group.name} + + + value={value} + setValue={(v) => + setDraftGroupRoles((prev) => ({ ...prev, [group.groupId]: v })) + } + items={[ + { id: NULL_ROLE_VALUE, name: "No access", description: "" }, + ...jitRoles, + ]} + variant="tertiary/small" + dropdownIcon + text={(v) => + v === NULL_ROLE_VALUE + ? "No access" + : (jitRoles.find((r) => r.id === v)?.name ?? "Select a role") + } + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + +
+ ); + }) + )} +
+ +
+ +
+ + )}
); } @@ -725,7 +1155,7 @@ function PortalLinkDialog({ onClose, }: { url: string | null; - intent: "sso" | "domain_verification" | null; + intent: "sso" | "domain_verification" | "dsync" | null; onClose: () => void; }) { const purpose = @@ -733,7 +1163,9 @@ function PortalLinkDialog({ ? "This single-use link opens domain verification. Send it to whoever manages your DNS or identity provider so they can confirm your organization owns its email domains." : intent === "sso" ? "This single-use link opens identity-provider setup. Send it to whoever manages your identity provider so they can connect it to Trigger.dev." - : "This single-use link opens your organization's SSO setup."; + : intent === "dsync" + ? "This single-use link opens directory sync (SCIM) setup. Send it to whoever manages your identity provider so they can connect your directory to Trigger.dev." + : "This single-use link opens your organization's SSO setup."; return ( (open ? undefined : onClose())}> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 3d63a0507a6..ac6675e3206 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -56,6 +56,7 @@ import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; +import { ssoController } from "~/services/sso.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; @@ -113,7 +114,13 @@ export const loader = dashboardLoader( const canManageMembers = ability.can("manage", { type: "members" }); const canManageBilling = ability.can("manage", { type: "billing" }); - return typedjson({ ...result, canManageMembers, canManageBilling }); + // When Directory Sync is the authority (allowManualMembership off + a + // directory active), manual invite/remove/leave are disabled. Fail-open to + // "allowed" so a plugin hiccup never strands the team page. + const policy = await ssoController.getMembershipPolicy(orgId); + const manualMembershipAllowed = policy.isOk() ? policy.value.manualMembershipAllowed : true; + + return typedjson({ ...result, canManageMembers, canManageBilling, manualMembershipAllowed }); } ); @@ -277,6 +284,16 @@ export const action = dashboardAction( return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); } + // Directory-managed membership: manual removal + self-leave are disabled + // (membership is driven by the directory). Enforced here, not just in the + // UI. Fail-open on a plugin error. + const removePolicy = await ssoController.getMembershipPolicy(orgId); + if (removePolicy.isOk() && !removePolicy.value.manualMembershipAllowed) { + return json({ ok: false, error: "Membership is managed by Directory Sync" } as const, { + status: 403, + }); + } + try { const deletedMember = await removeTeamMember({ userId, @@ -284,6 +301,16 @@ export const action = dashboardAction( slug: organizationSlug, }); + // Sticky removal: record a tombstone so passive SSO-JIT won't re-add + // them on next login (best-effort; no-op without the SSO plugin). + await ssoController + .recordMembershipRemoval({ + organizationId: orgId, + userId: deletedMember.userId, + reason: isSelfLeave ? "self_leave" : "manual_removal", + }) + .unwrapOr(undefined); + if (deletedMember.userId === userId) { return redirectWithSuccessMessage("/", request, `You left the organization`); } @@ -318,6 +345,7 @@ export default function Page() { memberRoles, canManageMembers, canManageBilling, + manualMembershipAllowed, } = useTypedLoaderData(); // Build a userId → roleId map so the dropdown's defaultValue matches // each member's current assignment without re-querying. @@ -361,7 +389,23 @@ export default function Page() { ))} - {!canManageMembers ? ( + {!manualMembershipAllowed ? ( + // Directory Sync is the membership authority — manual invites are + // disabled. The invite action + acceptInvite enforce this too. + + Invite a team member + + } + content="Membership is managed by Directory Sync" + disableHoverableContent + /> + ) : !canManageMembers ? ( // Gate the invite affordance on manage:members. The action // route enforces this independently — hiding it here just // avoids dead UI for non-managers. @@ -477,6 +521,7 @@ export default function Page() { member={member} memberCount={members.length} canManageMembers={canManageMembers} + manualMembershipAllowed={manualMembershipAllowed} /> @@ -559,14 +604,36 @@ function LeaveRemoveButton({ member, memberCount, canManageMembers, + manualMembershipAllowed, }: { userId: string; member: Member; memberCount: number; canManageMembers: boolean; + manualMembershipAllowed: boolean; }) { const organization = useOrganization(); + // Directory-managed membership: neither removing others nor leaving is + // allowed — the directory drives membership. Enforced server-side too. + if (!manualMembershipAllowed) { + const isSelf = userId === member.user.id; + return ( + + {isSelf ? "Leave team" : "Remove from team"} + + } + disableHoverableContent + content="Membership is managed by Directory Sync" + /> + ); + } + if (userId === member.user.id) { if (memberCount === 1) { return ( diff --git a/apps/webapp/app/routes/invite-resend.tsx b/apps/webapp/app/routes/invite-resend.tsx index f2938b67a61..3cba88bd20a 100644 --- a/apps/webapp/app/routes/invite-resend.tsx +++ b/apps/webapp/app/routes/invite-resend.tsx @@ -6,6 +6,7 @@ import { $replica } from "~/db.server"; import { resendInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; +import { ssoController } from "~/services/sso.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder"; @@ -30,7 +31,7 @@ export const action = dashboardAction( }, authorization: { action: "manage", resource: { type: "members" } }, }, - async ({ request, user }) => { + async ({ request, user, context }) => { const formData = await request.formData(); const submission = parseWithZod(formData, { schema: resendSchema }); @@ -38,6 +39,17 @@ export const action = dashboardAction( return json(submission.reply()); } + // Resending is an "add" — blocked when membership is directory-managed. + 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 } + ); + } + } + try { const invite = await resendInvite({ inviteId: submission.value.inviteId, diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts new file mode 100644 index 00000000000..902c3d90154 --- /dev/null +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -0,0 +1,131 @@ +import type { DirectorySyncEffect } from "@trigger.dev/plugins"; +import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; +import { + ensureOrgMember, + ensureUserForDirectory, + removeOrgMemberForDirectory, +} from "~/models/orgMember.server"; +import { createPlatformNotification } from "~/services/platformNotifications.server"; + +const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected"; + +// Raise a user-scoped, deduped notification when the directory tried to +// remove the org's last Owner. We keep the member and tell the Owner what to +// do; a single undismissed notification is enough (don't spam on every retry). +async function notifyLastOwnerProtected(userId: string, organizationId: string): Promise { + const existing = await prisma.platformNotification.findFirst({ + where: { + scope: "USER", + userId, + surface: "WEBAPP", + title: LAST_OWNER_NOTIFICATION_TITLE, + archivedAt: null, + endsAt: { gt: new Date() }, + }, + select: { id: true, interactions: { where: { userId }, select: { webappDismissedAt: true } } }, + }); + if (existing && !existing.interactions[0]?.webappDismissedAt) { + return; + } + + const endsAt = new Date(); + endsAt.setFullYear(endsAt.getFullYear() + 1); + + const result = await createPlatformNotification({ + title: LAST_OWNER_NOTIFICATION_TITLE, + surface: "WEBAPP", + scope: "USER", + userId, + endsAt: endsAt.toISOString(), + priority: 10, + payload: { + version: "1", + data: { + type: "card", + title: "Directory Sync kept your Owner access", + description: + "Your identity provider tried to remove you from this organization, but you are its only Owner. " + + "We kept your membership to prevent a lockout. Assign another Owner, then the directory change will apply.", + }, + }, + }); + if (result.isErr()) { + logger.warn("directorySync: failed to create last-owner notification", { + userId, + organizationId, + error: result.error, + }); + } +} + +// Apply one directory-sync membership effect against public.* tables. The +// plugin owns all enterprise.* state and never writes here; this is the only +// path that mutates User / OrgMember / roles / tokens from a directory event. +async function applyEffect(effect: DirectorySyncEffect): Promise { + switch (effect.kind) { + case "provision": { + const userId = + effect.userId ?? + ( + await ensureUserForDirectory({ + email: effect.email, + firstName: effect.firstName, + lastName: effect.lastName, + }) + ).userId; + + await ensureOrgMember({ + userId, + organizationId: effect.organizationId, + roleId: effect.roleId, + source: "directory_sync", + }); + + // Directory is authoritative for role: overwrite even for an existing + // member (ensureOrgMember only sets the role on first create). + if (effect.roleId) { + const result = await rbac.setUserRole({ + userId, + organizationId: effect.organizationId, + roleId: effect.roleId, + }); + if (!result.ok) { + throw new Error(`directorySync provision setUserRole failed: ${result.error}`); + } + } + return; + } + case "set_role": { + const result = await rbac.setUserRole({ + userId: effect.userId, + organizationId: effect.organizationId, + roleId: effect.roleId, + }); + if (!result.ok) { + throw new Error(`directorySync set_role failed: ${result.error}`); + } + return; + } + case "deprovision": { + const outcome = await removeOrgMemberForDirectory({ + userId: effect.userId, + organizationId: effect.organizationId, + }); + if (!outcome.removed && outcome.reason === "last_owner_protected") { + await notifyLastOwnerProtected(effect.userId, effect.organizationId); + } + return; + } + } +} + +// Apply all effects from a processed directory-sync webhook. Effects are +// idempotent, so a worker retry that re-applies them converges. A throw +// propagates to the worker for retry. +export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise { + for (const effect of effects) { + await applyEffect(effect); + } +} diff --git a/apps/webapp/app/v3/accountsWebhookWorker.server.ts b/apps/webapp/app/v3/accountsWebhookWorker.server.ts index 0c56968102f..cdd3ab50d34 100644 --- a/apps/webapp/app/v3/accountsWebhookWorker.server.ts +++ b/apps/webapp/app/v3/accountsWebhookWorker.server.ts @@ -4,6 +4,7 @@ import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; import { ssoController } from "~/services/sso.server"; +import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; // Dedicated worker for inbound account-management webhooks. The webhook // proxy route verifies the signature via the plugin and enqueues the @@ -72,6 +73,10 @@ function initializeWorker() { if (result.isErr()) { throw new Error(`account webhook processing failed: ${result.error}`); } + // Directory-sync events return membership effects to apply against + // public.* tables (the plugin never writes those). A throw here + // bubbles to the worker for retry; effects are idempotent. + await applyDirectorySyncEffects(result.value.effects); }, }, }); diff --git a/internal-packages/sso/src/fallback.ts b/internal-packages/sso/src/fallback.ts index 96eab9563c4..1e91d6857f8 100644 --- a/internal-packages/sso/src/fallback.ts +++ b/internal-packages/sso/src/fallback.ts @@ -1,4 +1,6 @@ import type { + DirectorySyncEffect, + DirectorySyncStatus, OrgSsoStatus, SsoBeginError, SsoCompleteError, @@ -55,7 +57,7 @@ class SsoFallbackController implements SsoController { generatePortalLink(_params: { organizationId: string; userId: string; - intent: "sso" | "domain_verification"; + intent: "sso" | "domain_verification" | "dsync"; returnUrl: string; }): ResultAsync<{ url: string }, SsoPortalError> { return errAsync("idp_org_unavailable" as const); @@ -91,6 +93,73 @@ class SsoFallbackController implements SsoController { return errAsync("feature_disabled" as const); } + getDirectorySyncStatus( + _organizationId: string + ): ResultAsync { + return okAsync({ + hasDirectory: false, + hasActiveDirectory: false, + allowExternalDomainSync: false, + allowManualMembership: true, + directoryDefaultRoleId: null, + userCount: 0, + directories: [], + groups: [], + }); + } + + setDirectoryGroupRole(_params: { + organizationId: string; + groupId: string; + roleId: string | null; + }): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError> { + return errAsync("feature_disabled" as const); + } + + setDirectoryDefaultRole(_params: { + organizationId: string; + roleId: string | null; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + setAllowExternalDomainSync(_params: { + organizationId: string; + allowed: boolean; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + // OSS has no directory, so manual membership is always allowed. + getMembershipPolicy( + _organizationId: string + ): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError> { + return okAsync({ manualMembershipAllowed: true }); + } + + setAllowManualMembership(_params: { + organizationId: string; + allowed: boolean; + }): ResultAsync { + return errAsync("feature_disabled" as const); + } + + recordMembershipRemoval(_params: { + organizationId: string; + userId: string; + reason: "manual_removal" | "self_leave"; + }): ResultAsync { + // No plugin → no JIT → nothing to guard against. No-op success. + return okAsync(undefined as void); + } + + clearMembershipRemoval(_params: { + organizationId: string; + userId: string; + }): ResultAsync { + return okAsync(undefined as void); + } + decideRouteForEmail(_email: string): ResultAsync { return okAsync({ kind: "no_sso" as const }); } @@ -160,7 +229,11 @@ class SsoFallbackController implements SsoController { return errAsync("feature_disabled" as const); } - processWebhookEvent(_event: SsoWebhookEvent): ResultAsync { - return errAsync("feature_disabled" as const); + processWebhookEvent( + _event: SsoWebhookEvent + ): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError> { + // No plugin: nothing to verify or act on. The host's webhook proxy + // already rejects unverified requests, so a call here just no-ops. + return okAsync({ effects: [] }); } } diff --git a/internal-packages/sso/src/index.ts b/internal-packages/sso/src/index.ts index 5f65ae6f972..0ec6df3adbd 100644 --- a/internal-packages/sso/src/index.ts +++ b/internal-packages/sso/src/index.ts @@ -1,4 +1,6 @@ import type { + DirectorySyncEffect, + DirectorySyncStatus, OrgSsoStatus, SsoBeginError, SsoCompleteError, @@ -108,7 +110,7 @@ export class LazyController implements SsoController { generatePortalLink(params: { organizationId: string; userId: string; - intent: "sso" | "domain_verification"; + intent: "sso" | "domain_verification" | "dsync"; returnUrl: string; }): ResultAsync<{ url: string }, SsoPortalError> { return this.call((c) => c.generatePortalLink(params)); @@ -144,6 +146,62 @@ export class LazyController implements SsoController { return this.call((c) => c.updateConfig(params)); } + getDirectorySyncStatus( + organizationId: string + ): ResultAsync { + return this.call((c) => c.getDirectorySyncStatus(organizationId)); + } + + setDirectoryGroupRole(params: { + organizationId: string; + groupId: string; + roleId: string | null; + }): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError> { + return this.call((c) => c.setDirectoryGroupRole(params)); + } + + setDirectoryDefaultRole(params: { + organizationId: string; + roleId: string | null; + }): ResultAsync { + return this.call((c) => c.setDirectoryDefaultRole(params)); + } + + setAllowExternalDomainSync(params: { + organizationId: string; + allowed: boolean; + }): ResultAsync { + return this.call((c) => c.setAllowExternalDomainSync(params)); + } + + getMembershipPolicy( + organizationId: string + ): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError> { + return this.call((c) => c.getMembershipPolicy(organizationId)); + } + + setAllowManualMembership(params: { + organizationId: string; + allowed: boolean; + }): ResultAsync { + return this.call((c) => c.setAllowManualMembership(params)); + } + + recordMembershipRemoval(params: { + organizationId: string; + userId: string; + reason: "manual_removal" | "self_leave"; + }): ResultAsync { + return this.call((c) => c.recordMembershipRemoval(params)); + } + + clearMembershipRemoval(params: { + organizationId: string; + userId: string; + }): ResultAsync { + return this.call((c) => c.clearMembershipRemoval(params)); + } + decideRouteForEmail(email: string): ResultAsync { return this.call((c) => c.decideRouteForEmail(email)); } @@ -207,7 +265,9 @@ export class LazyController implements SsoController { return this.call((c) => c.verifyWebhook(params)); } - processWebhookEvent(event: SsoWebhookEvent): ResultAsync { + processWebhookEvent( + event: SsoWebhookEvent + ): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError> { return this.call((c) => c.processWebhookEvent(event)); } } diff --git a/internal-packages/sso/src/loader.test.ts b/internal-packages/sso/src/loader.test.ts index de2c6731e1e..d5e75b4d59d 100644 --- a/internal-packages/sso/src/loader.test.ts +++ b/internal-packages/sso/src/loader.test.ts @@ -34,6 +34,39 @@ function makeStubController(overrides: Partial = {}): SsoControll generatePortalLink() { return okAsync({ url: "https://stub.example/portal" }); }, + getDirectorySyncStatus() { + return okAsync({ + hasDirectory: false, + hasActiveDirectory: false, + allowExternalDomainSync: false, + allowManualMembership: true, + directoryDefaultRoleId: null, + userCount: 0, + directories: [], + groups: [], + }); + }, + setDirectoryGroupRole() { + return okAsync({ effects: [] }); + }, + setDirectoryDefaultRole() { + return okAsync(undefined as void); + }, + setAllowExternalDomainSync() { + return okAsync(undefined as void); + }, + getMembershipPolicy() { + return okAsync({ manualMembershipAllowed: true }); + }, + setAllowManualMembership() { + return okAsync(undefined as void); + }, + recordMembershipRemoval() { + return okAsync(undefined as void); + }, + clearMembershipRemoval() { + return okAsync(undefined as void); + }, setEnforced() { return okAsync(undefined as void); }, @@ -77,7 +110,7 @@ function makeStubController(overrides: Partial = {}): SsoControll return errAsync("invalid_signature" as const); }, processWebhookEvent() { - return okAsync(undefined as void); + return okAsync({ effects: [] }); }, ...overrides, }; diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index fff50ec8775..cef1e3b356f 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -47,6 +47,10 @@ export type { SsoValidateError, SsoWebhookError, SsoWebhookEvent, + DirectoryState, + DirectoryGroupMapping, + DirectorySyncStatus, + DirectorySyncEffect, } from "./sso.js"; export { SSO_FLOWS } from "./sso.js"; diff --git a/packages/plugins/src/sso.ts b/packages/plugins/src/sso.ts index 4805195e411..fcfed8bdd97 100644 --- a/packages/plugins/src/sso.ts +++ b/packages/plugins/src/sso.ts @@ -59,6 +59,69 @@ export type SsoResolutionDecision = | { kind: "linked_by_email"; userId: string } | { kind: "create_new_user"; profile: SsoProfile }; +// === Directory sync (SCIM) domain types === + +export type DirectoryState = "active" | "inactive"; + +// A directory group and the role it grants. `mappedRoleId === null` means the +// group has no role mapping; its members get no role from this group (and are +// not provisioned on its account alone). +export type DirectoryGroupMapping = { + groupId: string; + name: string; + mappedRoleId: string | null; +}; + +export type DirectorySyncStatus = { + hasDirectory: boolean; + // True when at least one directory is in the "active" state. + hasActiveDirectory: boolean; + // Per-org override: when true, directory users whose email domain is NOT a + // verified org domain are still provisioned. Default false. + allowExternalDomainSync: boolean; + // Raw per-org setting (for the settings toggle). When false AND a directory + // is active, manual membership adds/removes are blocked (see + // getMembershipPolicy for the effective value). Default true. + allowManualMembership: boolean; + // Role assigned to directory users who are active but belong to no mapped + // group. `null` (default) means ungrouped users are NOT provisioned; set a + // role to provision them at it (on verified domains, subject to the + // external-domain setting). + directoryDefaultRoleId: string | null; + userCount: number; + directories: ReadonlyArray<{ + id: string; + name: string | null; + type: string; + state: DirectoryState; + }>; + groups: ReadonlyArray; +}; + +// A host-actionable membership mutation derived from a directory-sync event. +// The plugin owns all `enterprise.*` writes; these effects describe the +// `public.*` (User / OrgMember / role / token) writes the host must perform — +// the plugin never touches those tables. The host worker applies them +// idempotently. +// +// - `provision`: ensure the User exists (create when `userId === null`), +// ensure the OrgMember exists, and set its role. +// - `deprovision`: remove the membership (guarded against last-Owner), force +// logout, and revoke tokens per host policy. +// - `set_role`: overwrite the member's role (directory-authoritative). +export type DirectorySyncEffect = + | { + kind: "provision"; + userId: string | null; + email: string; + firstName: string | null; + lastName: string | null; + organizationId: string; + roleId: string | null; + } + | { kind: "deprovision"; userId: string; organizationId: string } + | { kind: "set_role"; userId: string; organizationId: string; roleId: string }; + // === Errors === export type SsoDecisionError = "internal"; @@ -110,7 +173,7 @@ export interface SsoController { generatePortalLink(params: { organizationId: string; userId: string; - intent: "sso" | "domain_verification"; + intent: "sso" | "domain_verification" | "dsync"; returnUrl: string; }): ResultAsync<{ url: string }, SsoPortalError>; @@ -142,6 +205,73 @@ export interface SsoController { jitDefaultRoleId: string | null; }): ResultAsync; + // --- Directory sync (SCIM) admin UI --- + + // Full directory-sync state for the settings section: directories, the + // group→role mapping table, the external-domain toggle, and a member count. + getDirectorySyncStatus( + organizationId: string + ): ResultAsync; + + // Map a directory group to an RBAC role (or clear the mapping with null). + // The role is validated against the org's assignable roles. Returns the + // membership effects the change implies for the group's current members + // (roles recomputed against the new mapping; deprovision when the group is + // cleared and it was a member's last mapped group) — the host applies them + // so a dashboard remap takes effect immediately, like a directory event. + setDirectoryGroupRole(params: { + organizationId: string; + groupId: string; + roleId: string | null; + }): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError>; + + // Set the role for directory users with no mapped group (null = don't + // provision ungrouped users). Owner is reserved and rejected. + setDirectoryDefaultRole(params: { + organizationId: string; + roleId: string | null; + }): ResultAsync; + + // Toggle whether directory users outside the org's verified domains are + // provisioned. Default false (verified domains only). + setAllowExternalDomainSync(params: { + organizationId: string; + allowed: boolean; + }): ResultAsync; + + // --- Membership management policy + removal tombstones --- + + // Effective policy for manual (dashboard) membership changes. Returns + // `manualMembershipAllowed: false` only when the org set allow-manual off + // AND a directory is active; otherwise true. Hosts use it to gate invite / + // accept-invite / remove / self-leave and to hide those buttons. + getMembershipPolicy( + organizationId: string + ): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError>; + + setAllowManualMembership(params: { + organizationId: string; + allowed: boolean; + }): ResultAsync; + + // Record that a user was removed from an org (manual removal or self-leave), + // so passive SSO-JIT won't silently re-add them on next login. Idempotent + // upsert keyed by (organizationId, userId). The host calls this after a + // successful team removal; DSync deprovisions record their own tombstone + // internally. No-op in the OSS fallback. + recordMembershipRemoval(params: { + organizationId: string; + userId: string; + reason: "manual_removal" | "self_leave"; + }): ResultAsync; + + // Clear a removal tombstone — a deliberate re-admission (the host calls this + // when an invite is accepted). Idempotent; no-op when absent. + clearMembershipRemoval(params: { + organizationId: string; + userId: string; + }): ResultAsync; + // --- Auth flow --- // Called by every login entry point BEFORE the strategy proceeds. @@ -228,9 +358,14 @@ export interface SsoController { }): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError>; // Process a previously-verified webhook event (the host's background - // worker calls this). Performs the plugin's own state writes; throws - // nothing — failures surface as `internal` so the worker retries. - processWebhookEvent(event: SsoWebhookEvent): ResultAsync; + // worker calls this). Performs the plugin's own `enterprise.*` state writes + // and returns any `public.*` membership effects the host must apply (empty + // for SSO/domain/connection events; populated for directory-sync events). + // Throws nothing — failures surface as `internal` so the worker retries; + // effect application on the host side must be idempotent. + processWebhookEvent( + event: SsoWebhookEvent + ): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError>; } export interface SsoPlugin {