Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/sso-entitlement.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,14 @@ 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";
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" }];

Expand All @@ -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<void> {
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 });
}
}

Expand Down Expand Up @@ -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,
Comment thread
matt-aitken marked this conversation as resolved.
jitRoles: [] as Role[],
directorySync: EMPTY_DIRECTORY_SYNC_STATUS,
hasSso: false,
isEntitled: false,
});
}

Expand Down Expand Up @@ -182,6 +172,7 @@ export const loader = dashboardLoader(
jitRoles,
directorySync,
hasSso,
isEntitled: true,
});
}
);
Expand Down Expand Up @@ -374,11 +365,10 @@ function useOverrideDraft<T>(serverValue: T): {
}

export default function Page() {
const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData<typeof loader>();
const { status, orgTitle, jitRoles, directorySync, hasSso, isEntitled } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const _plan = useCurrentPlan();

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

Expand Down
30 changes: 30 additions & 0 deletions apps/webapp/app/services/directorySyncEffects.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -145,8 +146,37 @@ async function applyEffect(effect: DirectorySyncEffect): Promise<void> {
}
}

/**
* 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<void> {
const entitlements = new Map<string, SsoEntitlement>();

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);
}
}
68 changes: 65 additions & 3 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ function initializePlatformCache() {
fresh: 60_000,
stale: 120_000,
}),
ssoEntitlement: new Namespace<boolean>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000,
stale: 120_000,
}),
Comment thread
matt-aitken marked this conversation as resolved.
});

return cache;
Expand All @@ -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(() => {});
Comment thread
matt-aitken marked this conversation as resolved.
}

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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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.");
}
}
Expand Down Expand Up @@ -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<SsoEntitlement> {
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;
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
130 changes: 130 additions & 0 deletions apps/webapp/test/directorySyncEffects.server.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 })
);
});
});
Loading