Skip to content

Commit 269470f

Browse files
authored
feat(webapp): gate SSO on an entitlement instead of the Enterprise plan (#4393)
The SSO & Directory Sync settings page decided access by comparing the organization's plan code against the literal string `"enterprise"`. The webapp now reads a `hasSso` entitlement from plan limits. ## Changes - **`settings.sso` route** — `planAllowsSso` reads `limits.hasSso` rather than the plan code; the loader and the action gate on a shared `getSsoEntitlement` helper. - **`platform.v3.server`** — new `getSsoEntitlement(orgId)` returning `entitled | not_entitled | unknown`, behind a new SWR cache namespace (60s fresh / 120s stale, memory + Redis). This replaces an uncached billing round-trip that previously ran on every settings load, so the page gets cheaper than it was. - **`directorySyncEffects`** — the entitlement is now checked before applying membership effects, per organization and memoised across a batch. - **`@trigger.dev/platform` 1.2.0 → 1.3.0** — required, see below. ## Behaviour worth reviewing **Revocation now stops SCIM.** Previously the plan check existed only on the settings page, so an org that lost access kept receiving directory-sync pushes indefinitely; only the config UI froze. Provision *and* deprovision are gated, so a revoked entitlement can't remove members either. **An unreadable entitlement throws instead of skipping.** Effects are idempotent and the worker retries, so retrying is lossless where dropping would silently lose a directory change. It's raised at `warn` level so a transient billing blip doesn't page anyone. **The login path is deliberately untouched.** A hard entitlement check there turns a billing outage into a login outage. Consequence: an org that loses the entitlement keeps its existing SSO logins working until the connection is removed. Gating sign-in is a separate decision. **Self-hosted is unaffected.** With no billing service configured the helper returns `entitled`, leaving plugin presence and the kill switch as the only gates — a self-hoster who installed the plugin isn't locked out of it. ## The dependency bump is load-bearing The `Limits` schema is a plain `z.object`, so it *strips* unknown keys. On 1.2.0 the `hasSso` field was silently discarded during parsing and read as `undefined` no matter what billing sent — a structural accessor would not have helped. Verified against both builds: ``` 1.2.0 → parsed: true | hasSso survives: false 1.3.0 → parsed: true | hasSso survives: true ``` This PR therefore cannot merge before 1.3.0 is published, which it now is. ## Testing `apps/webapp/test/directorySyncEffects.server.test.ts` — 7 tests over the gate: applies when entitled, skips provision and deprovision when not, throws a warn-level retryable error when unreadable, resolves once per org across a batch, and gates per org so one unentitled org doesn't block another. `pnpm run typecheck --filter webapp` passes (18/18), oxfmt and oxlint clean.
1 parent 72c2b2c commit 269470f

7 files changed

Lines changed: 245 additions & 27 deletions

File tree

.server-changes/sso-entitlement.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
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.

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

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,14 @@ import { prisma } from "~/db.server";
3939
import { useOrganization } from "~/hooks/useOrganizations";
4040
import { rbac } from "~/services/rbac.server";
4141
import { ssoController } from "~/services/sso.server";
42-
import { getCurrentPlan } from "~/services/platform.v3.server";
42+
import { getSsoEntitlement } from "~/services/platform.v3.server";
4343
import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins";
4444
import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server";
4545
import { flag } from "~/v3/featureFlags.server";
4646
import { FEATURE_FLAG } from "~/v3/featureFlags";
4747
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
4848
import { cn } from "~/utils/cn";
4949
import { throwPermissionDenied } from "~/utils/permissionDenied";
50-
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
5150

5251
export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }];
5352

@@ -62,17 +61,10 @@ async function resolveOrg(slug: string) {
6261
});
6362
}
6463

65-
function planAllowsSso(plan: unknown): boolean {
66-
if (!plan || typeof plan !== "object") return false;
67-
const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription;
68-
return subscription?.plan?.code === "enterprise";
69-
}
70-
7164
// Client-side upsell is cosmetic; gate real IdP mutations server-side.
7265
async function requireSsoEntitlement(orgId: string): Promise<void> {
73-
const plan = await getCurrentPlan(orgId);
74-
if (!planAllowsSso(plan)) {
75-
throw new Response("SSO requires an Enterprise plan", { status: 403 });
66+
if ((await getSsoEntitlement(orgId)) !== "entitled") {
67+
throw new Response("This organization is not entitled to SSO", { status: 403 });
7668
}
7769
}
7870

@@ -142,16 +134,14 @@ export const loader = dashboardLoader(
142134
throw new Response("Not Found", { status: 404 });
143135
}
144136

145-
// Not Enterprise: render the upsell for every role, skip role check +
146-
// queries, return empty data.
147-
const plan = await getCurrentPlan(orgId);
148-
if (!planAllowsSso(plan)) {
137+
if ((await getSsoEntitlement(orgId)) !== "entitled") {
149138
return typedjson({
150139
status: EMPTY_SSO_STATUS,
151140
orgTitle: context.orgTitle,
152141
jitRoles: [] as Role[],
153142
directorySync: EMPTY_DIRECTORY_SYNC_STATUS,
154143
hasSso: false,
144+
isEntitled: false,
155145
});
156146
}
157147

@@ -182,6 +172,7 @@ export const loader = dashboardLoader(
182172
jitRoles,
183173
directorySync,
184174
hasSso,
175+
isEntitled: true,
185176
});
186177
}
187178
);
@@ -374,11 +365,10 @@ function useOverrideDraft<T>(serverValue: T): {
374365
}
375366

376367
export default function Page() {
377-
const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData<typeof loader>();
368+
const { status, orgTitle, jitRoles, directorySync, hasSso, isEntitled } =
369+
useTypedLoaderData<typeof loader>();
378370
const organization = useOrganization();
379-
const _plan = useCurrentPlan();
380371

381-
const isEntitled = planAllowsSso(_plan);
382372
const activeConnections = status.connections.filter((c) => c.state === "active");
383373
const hasActive = activeConnections.length > 0;
384374

apps/webapp/app/services/directorySyncEffects.server.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
removeOrgMemberForDirectory,
99
} from "~/models/orgMember.server";
1010
import { createPlatformNotification } from "~/services/platformNotifications.server";
11+
import { getSsoEntitlement, type SsoEntitlement } from "~/services/platform.v3.server";
1112

1213
const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected";
1314

@@ -145,8 +146,37 @@ async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
145146
}
146147
}
147148

149+
/**
150+
* Applies membership effects, skipping any org that isn't entitled to SSO.
151+
*
152+
* An unreadable entitlement throws rather than skipping: effects are
153+
* idempotent and the worker retries, so retrying is lossless where dropping
154+
* would silently lose a directory change.
155+
*/
148156
export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise<void> {
157+
const entitlements = new Map<string, SsoEntitlement>();
158+
149159
for (const effect of effects) {
160+
let entitlement = entitlements.get(effect.organizationId);
161+
if (entitlement === undefined) {
162+
entitlement = await getSsoEntitlement(effect.organizationId);
163+
entitlements.set(effect.organizationId, entitlement);
164+
}
165+
166+
if (entitlement === "unknown") {
167+
throw retryableEffectError(
168+
`directory sync: could not read the SSO entitlement for organization ${effect.organizationId}`
169+
);
170+
}
171+
172+
if (entitlement === "not_entitled") {
173+
logger.warn("Directory Sync: skipping effect for org without the SSO entitlement", {
174+
organizationId: effect.organizationId,
175+
kind: effect.kind,
176+
});
177+
continue;
178+
}
179+
150180
await applyEffect(effect);
151181
}
152182
}

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ function initializePlatformCache() {
196196
fresh: 60_000,
197197
stale: 120_000,
198198
}),
199+
ssoEntitlement: new Namespace<boolean>(ctx, {
200+
stores: [memory, redisCacheStore],
201+
fresh: 60_000,
202+
stale: 120_000,
203+
}),
199204
});
200205

201206
return cache;
@@ -206,12 +211,23 @@ const platformCache = singleton("platformCache", initializePlatformCache);
206211
function invalidateBillingLimitCaches(organizationId: string) {
207212
platformCache.billingLimit.remove(organizationId).catch(() => {});
208213
platformCache.entitlement.remove(organizationId).catch(() => {});
214+
platformCache.ssoEntitlement.remove(organizationId).catch(() => {});
209215
}
210216

211217
export function bustBillingLimitCaches(organizationId: string) {
212218
invalidateBillingLimitCaches(organizationId);
213219
}
214220

221+
/**
222+
* Clears the caches whose value is derived from the org's plan. Call after a
223+
* plan change — a downgrade can revoke SSO, and serving the previous decision
224+
* for the stale TTL would keep a surface open that the new plan doesn't allow.
225+
*/
226+
function invalidatePlanDerivedCaches(organizationId: string) {
227+
platformCache.entitlement.remove(organizationId).catch(() => {});
228+
platformCache.ssoEntitlement.remove(organizationId).catch(() => {});
229+
}
230+
215231
// Clear the cached promo-credits read so a just-granted code shows on the usage
216232
// page immediately rather than after the stale TTL.
217233
export function bustPromoCreditsCache(organizationId: string) {
@@ -537,7 +553,7 @@ export async function setPlan(
537553
case "free_connected": {
538554
// Selecting Free provisions the plan directly, so any free result is a success.
539555
opts?.invalidateBillingCache?.(organization.id);
540-
platformCache.entitlement.remove(organization.id).catch(() => {});
556+
invalidatePlanDerivedCaches(organization.id);
541557
const response = redirect(newProjectPath(organization, "You're on the Free plan."));
542558
await opts?.onFreePlanProvisioned?.(response);
543559
return response;
@@ -548,13 +564,13 @@ export async function setPlan(
548564
case "updated_subscription": {
549565
// Invalidate billing cache since subscription changed
550566
opts?.invalidateBillingCache?.(organization.id);
551-
platformCache.entitlement.remove(organization.id).catch(() => {});
567+
invalidatePlanDerivedCaches(organization.id);
552568
return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully.");
553569
}
554570
case "canceled_subscription": {
555571
// Invalidate billing cache since subscription was canceled
556572
opts?.invalidateBillingCache?.(organization.id);
557-
platformCache.entitlement.remove(organization.id).catch(() => {});
573+
invalidatePlanDerivedCaches(organization.id);
558574
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
559575
}
560576
}
@@ -757,6 +773,52 @@ export async function getEntitlement(
757773
return result.val;
758774
}
759775

776+
export type SsoEntitlement = "entitled" | "not_entitled" | "unknown";
777+
778+
/**
779+
* Whether an org may configure and use SSO / Directory Sync.
780+
*
781+
* `unknown` means billing was configured but unreadable — callers decide:
782+
* read paths show the upsell, mutations refuse, and the directory-sync
783+
* worker throws so the effect is retried rather than silently dropped.
784+
*
785+
* Self-hosted deployments have no billing service, so the plugin's presence
786+
* (plus the kill switch) is the only gate and this returns `entitled`.
787+
*
788+
* Loader errors are swallowed inside the loader for the same reason as
789+
* `getEntitlement`: @unkey/cache passes the loader promise to waitUntil()
790+
* with no .catch(), and returning undefined stops a transient billing
791+
* failure from being cached as an access decision. The SWR read is guarded
792+
* too, so a cache-infra failure resolves to `unknown` rather than rejecting
793+
* into the settings loader and the directory-sync worker.
794+
*/
795+
export async function getSsoEntitlement(organizationId: string): Promise<SsoEntitlement> {
796+
if (!client) return "entitled";
797+
798+
try {
799+
const result = await platformCache.ssoEntitlement.swr(organizationId, async () => {
800+
try {
801+
const response = await client.currentPlan(organizationId);
802+
if (!response.success) {
803+
recordPlatformFailure("getSsoEntitlement", "no_success");
804+
return undefined;
805+
}
806+
return response.v3Subscription?.plan?.limits?.hasSso === true;
807+
} catch (_e) {
808+
recordPlatformFailure("getSsoEntitlement", "caught");
809+
return undefined;
810+
}
811+
});
812+
813+
if (result.err || result.val === undefined) return "unknown";
814+
815+
return result.val ? "entitled" : "not_entitled";
816+
} catch (_e) {
817+
recordPlatformFailure("getSsoEntitlement", "caught");
818+
return "unknown";
819+
}
820+
}
821+
760822
export type PromoCreditsData = {
761823
grantedCents: number;
762824
remainingCents: number;

apps/webapp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
"@trigger.dev/core": "workspace:*",
118118
"@trigger.dev/database": "workspace:*",
119119
"@trigger.dev/otlp-importer": "workspace:*",
120-
"@trigger.dev/platform": "1.2.0",
120+
"@trigger.dev/platform": "1.3.0",
121121
"@trigger.dev/plugins": "workspace:*",
122122
"@trigger.dev/rbac": "workspace:*",
123123
"@trigger.dev/redis-worker": "workspace:*",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { DirectorySyncEffect } from "@trigger.dev/plugins";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
5+
vi.mock("~/services/platformNotifications.server", () => ({
6+
createPlatformNotification: vi.fn(),
7+
}));
8+
9+
const getSsoEntitlement = vi.fn();
10+
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
11+
const actual = (await importOriginal()) as Record<string, unknown>;
12+
return { ...actual, getSsoEntitlement: (orgId: string) => getSsoEntitlement(orgId) };
13+
});
14+
15+
const setUserRole = vi.fn();
16+
vi.mock("~/services/rbac.server", () => ({
17+
rbac: { setUserRole: (a: unknown) => setUserRole(a) },
18+
}));
19+
20+
const ensureOrgMember = vi.fn();
21+
const ensureUserForDirectory = vi.fn();
22+
const removeOrgMemberForDirectory = vi.fn();
23+
vi.mock("~/models/orgMember.server", () => ({
24+
ensureOrgMember: (a: unknown) => ensureOrgMember(a),
25+
ensureUserForDirectory: (a: unknown) => ensureUserForDirectory(a),
26+
removeOrgMemberForDirectory: (a: unknown) => removeOrgMemberForDirectory(a),
27+
}));
28+
29+
import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server";
30+
31+
const ENTITLED_ORG = "org_entitled";
32+
const UNENTITLED_ORG = "org_unentitled";
33+
34+
function provision(organizationId: string, email = "someone@acme.com"): DirectorySyncEffect {
35+
return {
36+
kind: "provision",
37+
userId: "user_1",
38+
email,
39+
firstName: null,
40+
lastName: null,
41+
organizationId,
42+
roleId: null,
43+
};
44+
}
45+
46+
function deprovision(organizationId: string): DirectorySyncEffect {
47+
return { kind: "deprovision", userId: "user_1", organizationId };
48+
}
49+
50+
describe("applyDirectorySyncEffects — SSO entitlement gate", () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
ensureOrgMember.mockResolvedValue(undefined);
54+
removeOrgMemberForDirectory.mockResolvedValue({ removed: true });
55+
setUserRole.mockResolvedValue({ ok: true });
56+
});
57+
58+
it("applies effects for an entitled org", async () => {
59+
getSsoEntitlement.mockResolvedValue("entitled");
60+
61+
await applyDirectorySyncEffects([provision(ENTITLED_ORG)]);
62+
63+
expect(ensureOrgMember).toHaveBeenCalledTimes(1);
64+
expect(ensureOrgMember).toHaveBeenCalledWith(
65+
expect.objectContaining({ organizationId: ENTITLED_ORG, source: "directory_sync" })
66+
);
67+
});
68+
69+
it("skips provisioning for an org without the entitlement", async () => {
70+
getSsoEntitlement.mockResolvedValue("not_entitled");
71+
72+
await applyDirectorySyncEffects([provision(UNENTITLED_ORG)]);
73+
74+
expect(ensureOrgMember).not.toHaveBeenCalled();
75+
expect(ensureUserForDirectory).not.toHaveBeenCalled();
76+
});
77+
78+
it("skips deprovisioning too, so revocation cannot remove members", async () => {
79+
getSsoEntitlement.mockResolvedValue("not_entitled");
80+
81+
await applyDirectorySyncEffects([deprovision(UNENTITLED_ORG)]);
82+
83+
expect(removeOrgMemberForDirectory).not.toHaveBeenCalled();
84+
});
85+
86+
it("throws on an unreadable entitlement so the worker retries", async () => {
87+
getSsoEntitlement.mockResolvedValue("unknown");
88+
89+
await expect(applyDirectorySyncEffects([provision(ENTITLED_ORG)])).rejects.toThrow(
90+
/could not read the SSO entitlement/
91+
);
92+
93+
expect(ensureOrgMember).not.toHaveBeenCalled();
94+
});
95+
96+
it("marks the retry as a warning rather than a pageable error", async () => {
97+
getSsoEntitlement.mockResolvedValue("unknown");
98+
99+
await applyDirectorySyncEffects([provision(ENTITLED_ORG)]).then(
100+
() => expect.unreachable("should have thrown"),
101+
(error) => expect(error).toMatchObject({ logLevel: "warn" })
102+
);
103+
});
104+
105+
it("resolves the entitlement once per org across a batch", async () => {
106+
getSsoEntitlement.mockResolvedValue("entitled");
107+
108+
await applyDirectorySyncEffects([
109+
provision(ENTITLED_ORG, "a@acme.com"),
110+
provision(ENTITLED_ORG, "b@acme.com"),
111+
provision(ENTITLED_ORG, "c@acme.com"),
112+
]);
113+
114+
expect(getSsoEntitlement).toHaveBeenCalledTimes(1);
115+
expect(ensureOrgMember).toHaveBeenCalledTimes(3);
116+
});
117+
118+
it("gates per org, so one unentitled org does not block another", async () => {
119+
getSsoEntitlement.mockImplementation(async (orgId: string) =>
120+
orgId === ENTITLED_ORG ? "entitled" : "not_entitled"
121+
);
122+
123+
await applyDirectorySyncEffects([provision(UNENTITLED_ORG), provision(ENTITLED_ORG)]);
124+
125+
expect(ensureOrgMember).toHaveBeenCalledTimes(1);
126+
expect(ensureOrgMember).toHaveBeenCalledWith(
127+
expect.objectContaining({ organizationId: ENTITLED_ORG })
128+
);
129+
});
130+
});

0 commit comments

Comments
 (0)