Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): gate SSO on an entitlement instead of the Enterprise plan#4393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
d6f9224a4a15d9f6169086818be95bb49daFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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, | ||
| }), | ||
matt-aitken marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }); | ||
| 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(() => {}); | ||
matt-aitken marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| 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<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; | ||
| 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 }) | ||
| ); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.