Skip to content

Commit d6f9224

Browse files
committed
feat(webapp): gate SSO on the hasSso entitlement instead of the Enterprise plan
The SSO settings surface compared the org's plan code against the literal string "enterprise", so granting SSO meant moving the org onto the enterprise plan and everything else that implies. Read the hasSso plan entitlement instead, which billing resolves as per-org override ?? plan default. Enterprise keeps SSO by default and any org can now be granted it without a plan change. Directory Sync effects are gated too, so revoking the entitlement stops SCIM provisioning rather than only freezing the settings page. An unreadable entitlement throws so the worker retries — effects are idempotent, and skipping would silently drop a directory change. Entitlement reads go through a new SWR cache namespace, replacing an uncached billing round-trip on every settings load. Requires @trigger.dev/platform 1.3.0: the older Limits schema strips the unknown hasSso key during parsing, so the field is invisible until the dependency is bumped.
1 parent 109e245 commit d6f9224

5 files changed

Lines changed: 89 additions & 11 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 can now be enabled for any organization, rather than only those on an Enterprise plan.

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ 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";
@@ -64,15 +64,15 @@ async function resolveOrg(slug: string) {
6464

6565
function planAllowsSso(plan: unknown): boolean {
6666
if (!plan || typeof plan !== "object") return false;
67-
const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription;
68-
return subscription?.plan?.code === "enterprise";
67+
const subscription = (plan as { v3Subscription?: { plan?: { limits?: { hasSso?: boolean } } } })
68+
.v3Subscription;
69+
return subscription?.plan?.limits?.hasSso === true;
6970
}
7071

7172
// Client-side upsell is cosmetic; gate real IdP mutations server-side.
7273
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 });
74+
if ((await getSsoEntitlement(orgId)) !== "entitled") {
75+
throw new Response("This organization is not entitled to SSO", { status: 403 });
7676
}
7777
}
7878

@@ -142,10 +142,7 @@ export const loader = dashboardLoader(
142142
throw new Response("Not Found", { status: 404 });
143143
}
144144

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)) {
145+
if ((await getSsoEntitlement(orgId)) !== "entitled") {
149146
return typedjson({
150147
status: EMPTY_SSO_STATUS,
151148
orgTitle: context.orgTitle,

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: 45 additions & 0 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,6 +211,7 @@ 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) {
@@ -757,6 +763,45 @@ export async function getEntitlement(
757763
return result.val;
758764
}
759765

766+
export type SsoEntitlement = "entitled" | "not_entitled" | "unknown";
767+
768+
/**
769+
* Whether an org may configure and use SSO / Directory Sync.
770+
*
771+
* `unknown` means billing was configured but unreadable — callers decide:
772+
* read paths show the upsell, mutations refuse, and the directory-sync
773+
* worker throws so the effect is retried rather than silently dropped.
774+
*
775+
* Self-hosted deployments have no billing service, so the plugin's presence
776+
* (plus the kill switch) is the only gate and this returns `entitled`.
777+
*
778+
* Loader errors are swallowed inside the loader for the same reason as
779+
* `getEntitlement`: @unkey/cache passes the loader promise to waitUntil()
780+
* with no .catch(), and returning undefined stops a transient billing
781+
* failure from being cached as an access decision.
782+
*/
783+
export async function getSsoEntitlement(organizationId: string): Promise<SsoEntitlement> {
784+
if (!client) return "entitled";
785+
786+
const result = await platformCache.ssoEntitlement.swr(organizationId, async () => {
787+
try {
788+
const response = await client.currentPlan(organizationId);
789+
if (!response.success) {
790+
recordPlatformFailure("getSsoEntitlement", "no_success");
791+
return undefined;
792+
}
793+
return response.v3Subscription?.plan?.limits?.hasSso === true;
794+
} catch (_e) {
795+
recordPlatformFailure("getSsoEntitlement", "caught");
796+
return undefined;
797+
}
798+
});
799+
800+
if (result.err || result.val === undefined) return "unknown";
801+
802+
return result.val ? "entitled" : "not_entitled";
803+
}
804+
760805
export type PromoCreditsData = {
761806
grantedCents: number;
762807
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:*",

0 commit comments

Comments
 (0)