Skip to content

Commit 9f01e31

Browse files
authored
fix(webapp): plan-gate SSO settings before role gate and remove client session fetch guard (#4045)
SSO settings page: resolve plan before the role check. A non-Enterprise org now renders the upsell state for every role instead of showing a "permission denied" panel to non-Owners for a feature their org can't use yet. manage:sso is only enforced once the org is actually entitled. Extracts EMPTY_SSO_STATUS and uses throwPermissionDenied(). Also removes the client-side SSO session fetch guard. It monkeypatched global window.fetch, which made it the initiator of every request and obfuscated the real call site on any 4xx/5xx. Session revocation is still enforced server-side on every authenticated request and surfaces as a logout redirect on the next navigation/refresh, so the client guard was UX-only and not worth the cross-cutting cost.
1 parent f163c89 commit 9f01e31

7 files changed

Lines changed: 59 additions & 117 deletions

File tree

apps/webapp/app/entry.client.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import { hydrateRoot } from "react-dom/client";
33
import { clientBeforeFirstRender } from "./clientBeforeFirstRender";
44
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
55
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
6-
import { installSsoSessionGuard } from "./utils/ssoSessionGuard";
76

87
clientBeforeFirstRender();
9-
installSsoSessionGuard();
108

119
hydrateRoot(
1210
document,

apps/webapp/app/hooks/useEventSource.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useEffect, useState } from "react";
2-
import { probeSsoSession } from "~/utils/ssoSessionGuard";
32

43
type EventSourceOptions = {
54
init?: EventSourceInit;
@@ -29,21 +28,13 @@ export function useEventSource(
2928

3029
const eventSource = new EventSource(url, init);
3130
eventSource.addEventListener(event ?? "message", handler);
32-
eventSource.addEventListener("error", errorHandler);
3331

3432
function handler(event: MessageEvent) {
3533
setData(event.data || "UNKNOWN_EVENT_DATA");
3634
}
3735

38-
// EventSource can't surface response headers, so on a stream error probe
39-
// an authenticated endpoint; a revoked session redirects via the guard.
40-
function errorHandler() {
41-
probeSsoSession();
42-
}
43-
4436
return () => {
4537
eventSource.removeEventListener(event ?? "message", handler);
46-
eventSource.removeEventListener("error", errorHandler);
4738
eventSource.close();
4839
};
4940
}, [url, event, init, disabled]);

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

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
3030
import { Paragraph } from "~/components/primitives/Paragraph";
3131
import { Select, SelectItem } from "~/components/primitives/Select";
3232
import { Switch } from "~/components/primitives/Switch";
33-
import { $replica } from "~/db.server";
33+
import { prisma } from "~/db.server";
3434
import { useOrganization } from "~/hooks/useOrganizations";
3535
import { rbac } from "~/services/rbac.server";
3636
import { ssoController } from "~/services/sso.server";
3737
import { getCurrentPlan } from "~/services/platform.v3.server";
3838
import type { Role } from "@trigger.dev/plugins";
3939
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
40+
import { throwPermissionDenied } from "~/utils/permissionDenied";
4041
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
4142
import { v3BillingPath } from "~/utils/pathBuilder";
4243

@@ -45,7 +46,10 @@ export const meta: MetaFunction = () => [{ title: "SSO settings | Trigger.dev" }
4546
const Params = z.object({ organizationSlug: z.string() });
4647

4748
async function resolveOrg(slug: string) {
48-
return $replica.organization.findFirst({
49+
// Use primary: this slug→id lookup scopes the org-level RBAC/entitlement
50+
// checks (loader and action), and replica lag could run them against a
51+
// stale or missing org scope.
52+
return prisma.organization.findFirst({
4953
where: { slug },
5054
select: { id: true, title: true },
5155
});
@@ -68,16 +72,41 @@ async function requireSsoEntitlement(orgId: string): Promise<void> {
6872
}
6973
}
7074

75+
const EMPTY_SSO_STATUS = {
76+
hasIdpOrg: false,
77+
enforced: false,
78+
jitProvisioningEnabled: false,
79+
jitDefaultRoleId: null,
80+
idpOrgId: null,
81+
primaryConnectionId: null,
82+
domains: [] as Array<{
83+
domain: string;
84+
verified: boolean;
85+
state: "pending" | "verified" | "failed";
86+
verificationFailedReason: string | null;
87+
}>,
88+
connections: [] as Array<{
89+
id: string;
90+
name: string | null;
91+
connectionType: string;
92+
state: "active" | "inactive";
93+
}>,
94+
};
95+
7196
export const loader = dashboardLoader(
7297
{
7398
params: Params,
7499
context: async (params) => {
75100
const org = await resolveOrg(params.organizationSlug);
76101
return org ? { organizationId: org.id, orgTitle: org.title } : {};
77102
},
78-
authorization: { action: "manage", resource: { type: "sso" } },
103+
// No static `authorization` gate here: SSO is plan-gated *before* it's
104+
// role-gated. A non-Enterprise org must render the upsell for everyone —
105+
// gating on manage:sso at the wrapper would show a non-Owner "Permission
106+
// denied" for a feature their org can't use yet. We resolve the plan in
107+
// the body and only enforce manage:sso once the org is actually entitled.
79108
},
80-
async ({ context, request }) => {
109+
async ({ context, ability }) => {
81110
// True only when SSO_ENABLED is on and a real SSO plugin is loaded.
82111
if (!(await ssoController.isUsingPlugin())) {
83112
throw new Response("Not Found", { status: 404 });
@@ -88,37 +117,31 @@ export const loader = dashboardLoader(
88117
throw new Response("Not Found", { status: 404 });
89118
}
90119

91-
// The page is reachable on every paid + free plan; when the org
92-
// isn't on Enterprise we render the upsell state instead of the
93-
// SSO UI. Plan-tier enforcement lives in the React render so the
94-
// sidebar entry and the page itself stay aligned.
120+
// Plan first. When the org isn't on Enterprise the page renders the
121+
// upsell state for every role, so we skip the role check (and the
122+
// SSO/role queries it would gate) and return empty data.
123+
const plan = await getCurrentPlan(orgId);
124+
if (!planAllowsSso(plan)) {
125+
return typedjson({
126+
status: EMPTY_SSO_STATUS,
127+
orgTitle: context.orgTitle,
128+
jitRoles: [] as Role[],
129+
});
130+
}
131+
132+
// Entitled: the page is now a real config surface, so enforce the role
133+
// gate. A non-Owner without manage:sso gets the permission panel — the
134+
// same 403 the dashboardLoader `authorization` block would have thrown.
135+
if (!ability.can("manage", { type: "sso" })) {
136+
throwPermissionDenied();
137+
}
138+
95139
const [statusResult, allRoles, assignableIds] = await Promise.all([
96140
ssoController.getStatus(orgId),
97141
rbac.allRoles(orgId),
98142
rbac.getAssignableRoleIds(orgId),
99143
]);
100-
const status = statusResult.isOk()
101-
? statusResult.value
102-
: {
103-
hasIdpOrg: false,
104-
enforced: false,
105-
jitProvisioningEnabled: false,
106-
jitDefaultRoleId: null,
107-
idpOrgId: null,
108-
primaryConnectionId: null,
109-
domains: [] as Array<{
110-
domain: string;
111-
verified: boolean;
112-
state: "pending" | "verified" | "failed";
113-
verificationFailedReason: string | null;
114-
}>,
115-
connections: [] as Array<{
116-
id: string;
117-
name: string | null;
118-
connectionType: string;
119-
state: "active" | "inactive";
120-
}>,
121-
};
144+
const status = statusResult.isOk() ? statusResult.value : EMPTY_SSO_STATUS;
122145

123146
// JIT can't promote new users to Owner — that role is reserved for
124147
// the founding member and explicit transfers. Plan-gated roles are

apps/webapp/app/routes/resources.session-check.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import { tryCatch } from "@trigger.dev/core/v3";
33
import { env } from "~/env.server";
44
import { createRedisClient } from "~/redis.server";
55
import { singleton } from "~/utils/singleton";
6-
import {
7-
SSO_SESSION_INVALIDATED_HEADER,
8-
ssoSessionExpiredLogoutPath,
9-
} from "~/utils/ssoSession";
6+
import { ssoSessionExpiredLogoutPath } from "~/utils/ssoSession";
107
import type { AuthUser } from "./authUser";
118
import { logger } from "./logger.server";
129
import { ssoController } from "./sso.server";
@@ -142,9 +139,10 @@ export async function revalidateSsoSession(
142139
userId: authUser.userId,
143140
});
144141

145-
// Navigations get the logout redirect; programmatic/API fetches can't
146-
// follow a 302-to-HTML, so they get a 401 carrying the marker header that
147-
// the client fetch guard turns into the same redirect.
142+
// Navigations (and Remix data requests, which the client follows) get the
143+
// logout redirect. Programmatic/API fetches can't follow a 302-to-HTML, so
144+
// they get a plain 401; the session is re-checked and the user is redirected
145+
// on their next navigation/refresh.
148146
const url = new URL(request.url);
149147
const isRemixDataRequest = url.searchParams.has("_data");
150148
const dest = request.headers.get("sec-fetch-dest");
@@ -154,8 +152,5 @@ export async function revalidateSsoSession(
154152
if (isRemixDataRequest || isDocumentRequest) {
155153
throw redirect(ssoSessionExpiredLogoutPath());
156154
}
157-
throw json(
158-
{ error: "sso_session_invalidated" },
159-
{ status: 401, headers: { [SSO_SESSION_INVALIDATED_HEADER]: "1" } }
160-
);
155+
throw json({ error: "sso_session_invalidated" }, { status: 401 });
161156
}
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
// Shared (server + client) constants for the SSO session-revalidation flow.
22

3-
export const SSO_SESSION_INVALIDATED_HEADER = "x-sso-session-invalidated";
4-
53
export const SSO_SESSION_EXPIRED_REASON = "session_expired";
64

75
// The reason rides as its own `?reason=` param, not `?redirectTo=/login...`,
86
// because the redirect sanitizer rejects /login and would drop it.
97
export function ssoSessionExpiredLogoutPath(): string {
108
return `/logout?reason=${SSO_SESSION_EXPIRED_REASON}`;
119
}
12-
13-
export const SSO_SESSION_CHECK_PATH = "/resources/session-check";

apps/webapp/app/utils/ssoSessionGuard.ts

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)