diff --git a/.server-changes/sso-entitlement.md b/.server-changes/sso-entitlement.md new file mode 100644 index 00000000000..4a039b6855e --- /dev/null +++ b/.server-changes/sso-entitlement.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on. 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 e58afdb65df..46abc2d4f2b 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 @@ -39,7 +39,7 @@ import { prisma } from "~/db.server"; 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 { getSsoEntitlement } from "~/services/platform.v3.server"; import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins"; import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; import { flag } from "~/v3/featureFlags.server"; @@ -47,7 +47,6 @@ import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { throwPermissionDenied } from "~/utils/permissionDenied"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }]; @@ -62,17 +61,10 @@ async function resolveOrg(slug: string) { }); } -function planAllowsSso(plan: unknown): boolean { - if (!plan || typeof plan !== "object") return false; - const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription; - return subscription?.plan?.code === "enterprise"; -} - // Client-side upsell is cosmetic; gate real IdP mutations server-side. async function requireSsoEntitlement(orgId: string): Promise { - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { - throw new Response("SSO requires an Enterprise plan", { status: 403 }); + if ((await getSsoEntitlement(orgId)) !== "entitled") { + throw new Response("This organization is not entitled to SSO", { status: 403 }); } } @@ -142,16 +134,14 @@ export const loader = dashboardLoader( throw new Response("Not Found", { status: 404 }); } - // Not Enterprise: render the upsell for every role, skip role check + - // queries, return empty data. - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { + if ((await getSsoEntitlement(orgId)) !== "entitled") { return typedjson({ status: EMPTY_SSO_STATUS, orgTitle: context.orgTitle, jitRoles: [] as Role[], directorySync: EMPTY_DIRECTORY_SYNC_STATUS, hasSso: false, + isEntitled: false, }); } @@ -182,6 +172,7 @@ export const loader = dashboardLoader( jitRoles, directorySync, hasSso, + isEntitled: true, }); } ); @@ -374,11 +365,10 @@ function useOverrideDraft(serverValue: T): { } export default function Page() { - const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData(); + const { status, orgTitle, jitRoles, directorySync, hasSso, isEntitled } = + useTypedLoaderData(); const organization = useOrganization(); - const _plan = useCurrentPlan(); - const isEntitled = planAllowsSso(_plan); const activeConnections = status.connections.filter((c) => c.state === "active"); const hasActive = activeConnections.length > 0; diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts index 24e1dade5a2..555bb6f98ab 100644 --- a/apps/webapp/app/services/directorySyncEffects.server.ts +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -8,6 +8,7 @@ import { removeOrgMemberForDirectory, } from "~/models/orgMember.server"; import { createPlatformNotification } from "~/services/platformNotifications.server"; +import { getSsoEntitlement, type SsoEntitlement } from "~/services/platform.v3.server"; const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected"; @@ -145,8 +146,37 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { } } +/** + * Applies membership effects, skipping any org that isn't entitled to SSO. + * + * An unreadable entitlement throws rather than skipping: effects are + * idempotent and the worker retries, so retrying is lossless where dropping + * would silently lose a directory change. + */ export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise { + const entitlements = new Map(); + for (const effect of effects) { + let entitlement = entitlements.get(effect.organizationId); + if (entitlement === undefined) { + entitlement = await getSsoEntitlement(effect.organizationId); + entitlements.set(effect.organizationId, entitlement); + } + + if (entitlement === "unknown") { + throw retryableEffectError( + `directory sync: could not read the SSO entitlement for organization ${effect.organizationId}` + ); + } + + if (entitlement === "not_entitled") { + logger.warn("Directory Sync: skipping effect for org without the SSO entitlement", { + organizationId: effect.organizationId, + kind: effect.kind, + }); + continue; + } + await applyEffect(effect); } } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bded0b92065..f8b524e3cdd 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -196,6 +196,11 @@ function initializePlatformCache() { fresh: 60_000, stale: 120_000, }), + ssoEntitlement: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, + stale: 120_000, + }), }); return cache; @@ -206,12 +211,23 @@ const platformCache = singleton("platformCache", initializePlatformCache); function invalidateBillingLimitCaches(organizationId: string) { platformCache.billingLimit.remove(organizationId).catch(() => {}); platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); } export function bustBillingLimitCaches(organizationId: string) { invalidateBillingLimitCaches(organizationId); } +/** + * Clears the caches whose value is derived from the org's plan. Call after a + * plan change — a downgrade can revoke SSO, and serving the previous decision + * for the stale TTL would keep a surface open that the new plan doesn't allow. + */ +function invalidatePlanDerivedCaches(organizationId: string) { + platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); +} + // Clear the cached promo-credits read so a just-granted code shows on the usage // page immediately rather than after the stale TTL. export function bustPromoCreditsCache(organizationId: string) { @@ -537,7 +553,7 @@ export async function setPlan( case "free_connected": { // Selecting Free provisions the plan directly, so any free result is a success. opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); const response = redirect(newProjectPath(organization, "You're on the Free plan.")); await opts?.onFreePlanProvisioned?.(response); return response; @@ -548,13 +564,13 @@ export async function setPlan( case "updated_subscription": { // Invalidate billing cache since subscription changed opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully."); } case "canceled_subscription": { // Invalidate billing cache since subscription was canceled opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); } } @@ -757,6 +773,52 @@ export async function getEntitlement( return result.val; } +export type SsoEntitlement = "entitled" | "not_entitled" | "unknown"; + +/** + * Whether an org may configure and use SSO / Directory Sync. + * + * `unknown` means billing was configured but unreadable — callers decide: + * read paths show the upsell, mutations refuse, and the directory-sync + * worker throws so the effect is retried rather than silently dropped. + * + * Self-hosted deployments have no billing service, so the plugin's presence + * (plus the kill switch) is the only gate and this returns `entitled`. + * + * Loader errors are swallowed inside the loader for the same reason as + * `getEntitlement`: @unkey/cache passes the loader promise to waitUntil() + * with no .catch(), and returning undefined stops a transient billing + * failure from being cached as an access decision. The SWR read is guarded + * too, so a cache-infra failure resolves to `unknown` rather than rejecting + * into the settings loader and the directory-sync worker. + */ +export async function getSsoEntitlement(organizationId: string): Promise { + if (!client) return "entitled"; + + try { + const result = await platformCache.ssoEntitlement.swr(organizationId, async () => { + try { + const response = await client.currentPlan(organizationId); + if (!response.success) { + recordPlatformFailure("getSsoEntitlement", "no_success"); + return undefined; + } + return response.v3Subscription?.plan?.limits?.hasSso === true; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return undefined; + } + }); + + if (result.err || result.val === undefined) return "unknown"; + + return result.val ? "entitled" : "not_entitled"; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return "unknown"; + } +} + export type PromoCreditsData = { grantedCents: number; remainingCents: number; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 678eeb76033..0eb149f77ed 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -117,7 +117,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.2.0", + "@trigger.dev/platform": "1.3.0", "@trigger.dev/plugins": "workspace:*", "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", diff --git a/apps/webapp/test/directorySyncEffects.server.test.ts b/apps/webapp/test/directorySyncEffects.server.test.ts new file mode 100644 index 00000000000..e8f9bb933cc --- /dev/null +++ b/apps/webapp/test/directorySyncEffects.server.test.ts @@ -0,0 +1,130 @@ +import type { DirectorySyncEffect } from "@trigger.dev/plugins"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); +vi.mock("~/services/platformNotifications.server", () => ({ + createPlatformNotification: vi.fn(), +})); + +const getSsoEntitlement = vi.fn(); +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, getSsoEntitlement: (orgId: string) => getSsoEntitlement(orgId) }; +}); + +const setUserRole = vi.fn(); +vi.mock("~/services/rbac.server", () => ({ + rbac: { setUserRole: (a: unknown) => setUserRole(a) }, +})); + +const ensureOrgMember = vi.fn(); +const ensureUserForDirectory = vi.fn(); +const removeOrgMemberForDirectory = vi.fn(); +vi.mock("~/models/orgMember.server", () => ({ + ensureOrgMember: (a: unknown) => ensureOrgMember(a), + ensureUserForDirectory: (a: unknown) => ensureUserForDirectory(a), + removeOrgMemberForDirectory: (a: unknown) => removeOrgMemberForDirectory(a), +})); + +import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; + +const ENTITLED_ORG = "org_entitled"; +const UNENTITLED_ORG = "org_unentitled"; + +function provision(organizationId: string, email = "someone@acme.com"): DirectorySyncEffect { + return { + kind: "provision", + userId: "user_1", + email, + firstName: null, + lastName: null, + organizationId, + roleId: null, + }; +} + +function deprovision(organizationId: string): DirectorySyncEffect { + return { kind: "deprovision", userId: "user_1", organizationId }; +} + +describe("applyDirectorySyncEffects — SSO entitlement gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + ensureOrgMember.mockResolvedValue(undefined); + removeOrgMemberForDirectory.mockResolvedValue({ removed: true }); + setUserRole.mockResolvedValue({ ok: true }); + }); + + it("applies effects for an entitled org", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG, source: "directory_sync" }) + ); + }); + + it("skips provisioning for an org without the entitlement", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG)]); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + expect(ensureUserForDirectory).not.toHaveBeenCalled(); + }); + + it("skips deprovisioning too, so revocation cannot remove members", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([deprovision(UNENTITLED_ORG)]); + + expect(removeOrgMemberForDirectory).not.toHaveBeenCalled(); + }); + + it("throws on an unreadable entitlement so the worker retries", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await expect(applyDirectorySyncEffects([provision(ENTITLED_ORG)])).rejects.toThrow( + /could not read the SSO entitlement/ + ); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + }); + + it("marks the retry as a warning rather than a pageable error", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]).then( + () => expect.unreachable("should have thrown"), + (error) => expect(error).toMatchObject({ logLevel: "warn" }) + ); + }); + + it("resolves the entitlement once per org across a batch", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([ + provision(ENTITLED_ORG, "a@acme.com"), + provision(ENTITLED_ORG, "b@acme.com"), + provision(ENTITLED_ORG, "c@acme.com"), + ]); + + expect(getSsoEntitlement).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledTimes(3); + }); + + it("gates per org, so one unentitled org does not block another", async () => { + getSsoEntitlement.mockImplementation(async (orgId: string) => + orgId === ENTITLED_ORG ? "entitled" : "not_entitled" + ); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG), provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG }) + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09dc718a6f..12e28056ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,8 +478,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.2.0 - version: 1.2.0(zod@3.25.76) + specifier: 1.3.0 + version: 1.3.0(zod@3.25.76) '@trigger.dev/plugins': specifier: workspace:* version: link:../../packages/plugins @@ -7681,8 +7681,8 @@ packages: react: 18.3.1 react-dom: 18.3.1 - '@trigger.dev/platform@1.2.0': - resolution: {integrity: sha512-Wa/XlMlmo1vhol5DEBYW1gMW4wgUUqr/ClDQb4IgwrRdPeLLmTpIVsgMHmYJXkERqR04xVaX7wBibWl4sW+1Hg==} + '@trigger.dev/platform@1.3.0': + resolution: {integrity: sha512-DIJ5Sy4X5XKSHQdYA3GImkb0jdE9I/HTRRRB3c9CFuXjGzhK3rJkEzjIEbaNRjk6yAev67EVTWY8P+D+khorUw==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -22334,7 +22334,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@trigger.dev/platform@1.2.0(zod@3.25.76)': + '@trigger.dev/platform@1.3.0(zod@3.25.76)': dependencies: zod: 3.25.76