Skip to content

Commit 5fa1463

Browse files
authored
Fail closed on the org selector for org-scoped reads (#1488)
1 parent b6f68bb commit 5fa1463

12 files changed

Lines changed: 212 additions & 69 deletions

File tree

apps/cloud/src/account/workos-account-service.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,19 @@ export const workosAccountProvider: Layer.Layer<
9191
? Effect.succeed(caller.session)
9292
: Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
9393

94-
// The org scope for an org-scoped request: the console URL's org (sent in
95-
// the selector header) when present, else the session's own org. Membership
96-
// is re-checked live, so the header is a selector, not a trust boundary —
94+
// The org scope for an org-scoped request: the console URL's org, sent in
95+
// the selector header. FAIL CLOSED when it's missing — the session's own
96+
// org is a browser-global pinned to whichever org WorkOS last touched, so
97+
// falling back to it scopes a multi-org user's request to the WRONG org
98+
// (see workos-auth-provider.resolveSessionPrincipal). Membership is
99+
// re-checked live, so the header is a selector, not a trust boundary —
97100
// and two browser tabs on different orgs each send their own header, so
98101
// they stay independent (see organization.ts). Yields the session +
99102
// resolved org, or AccountNoOrganization.
100103
const requireOrganization = (headers: AccountHeaders) =>
101104
Effect.gen(function* () {
102105
const session = yield* requireSession();
103-
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
106+
const selector = headers[ORG_SELECTOR_HEADER];
104107
if (!selector) {
105108
return yield* new AccountNoOrganization();
106109
}
@@ -180,9 +183,15 @@ export const workosAccountProvider: Layer.Layer<
180183
me: (headers) =>
181184
Effect.gen(function* () {
182185
const session = yield* requireSession();
183-
// Same selector precedence as requireOrganization: the URL's org
184-
// (header) drives /account/me so the shell reflects the org the tab
185-
// is viewing, not a session-global active org.
186+
// The ONE sanctioned fallback to the session org. /account/me is
187+
// identity, not tenant data: on a bare URL (no org slug yet, so no
188+
// header) it answers "which org should this browser land in", and
189+
// the session's pinned org is the only candidate. Every org-scoped
190+
// DATA read fails closed instead (requireOrganization above,
191+
// resolveSessionPrincipal) — serving tenant data from this fallback
192+
// is exactly the wrong-org connection-list bug (2026-07). With a
193+
// header (any slugged URL), the URL's org drives the answer so the
194+
// shell reflects the org the tab is viewing.
186195
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
187196
const org = selector
188197
? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(

apps/cloud/src/auth/org-selector-auth.node.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { resolveSessionPrincipal } from "./workos-auth-provider";
77
import { WorkOSClient, type WorkOSClientService } from "./workos";
88

99
// The org a console request resolves to is the URL's org (sent in the
10-
// `x-executor-organization` selector header), not the session's stored org —
11-
// with the session org as the fallback for non-console callers, and live
12-
// membership re-checked either way. This is what makes two browser tabs on
10+
// `x-executor-organization` selector header) — NEVER the session's stored org.
11+
// The sealed cookie's org is a browser-global pinned to whichever org WorkOS
12+
// last touched, so a fallback to it silently scopes a multi-org user's request
13+
// to the wrong org; a header-less request fails closed instead. Live
14+
// membership is re-checked either way. This is what makes two browser tabs on
1315
// different orgs independent.
1416

1517
const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -95,10 +97,15 @@ const run = (headers: Record<string, string>) =>
9597
);
9698

9799
describe("resolveSessionPrincipal · URL org selector", () => {
98-
it.effect("falls back to the session org when no selector header is sent", () =>
100+
it.effect("fails closed when no selector header is sent", () =>
99101
Effect.gen(function* () {
100-
const principal = yield* run({ cookie: "wos-session=x" });
101-
expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
102+
// No fallback to the session org: the cookie's org is browser-global
103+
// and can name a different org than the tab's URL for a multi-org user.
104+
const error = yield* Effect.flip(run({ cookie: "wos-session=x" }));
105+
expect(error, "rejects instead of scoping to the session org").toMatchObject({
106+
_tag: "NoOrganization",
107+
code: "no_organization",
108+
});
102109
}),
103110
);
104111

apps/cloud/src/auth/workos-auth-provider.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
// - invalid api key -> Unauthorized 401 invalid_api_key
1717
// - api-key org not authorized -> NoOrganization 403 no_organization
1818
// - no/invalid session -> NoOrganization 403 no_organization
19+
// - session without org header -> NoOrganization 403 no_organization (fail closed)
1920
// - session org not authorized -> NoOrganization 403 no_organization
2021
// - no auth header -> falls through to the sealed-session path
2122
// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are
@@ -86,6 +87,10 @@ const NO_ORGANIZATION_IN_SESSION = {
8687
code: "no_organization",
8788
message: "No organization in session",
8889
};
90+
const NO_ORGANIZATION_SELECTOR = {
91+
code: "no_organization",
92+
message: "No organization selector in request",
93+
};
8994
const INVALID_ACCESS_TOKEN = {
9095
code: "invalid_access_token",
9196
message: "Invalid or expired access token",
@@ -199,13 +204,22 @@ export const resolveSessionPrincipal = (request: Request) =>
199204
if (!session) {
200205
return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
201206
}
202-
// The console URL's org is the scope authority (sent as a header); the
203-
// session's own org is the fallback for non-console callers. Membership is
204-
// re-checked live either way — the header is a selector, not a trust
205-
// boundary (see organization.ts).
206-
const selector = orgSelectorFromRequest(request) ?? session.organizationId;
207+
// The console URL's org is the scope authority, sent as a header on every
208+
// request. FAIL CLOSED when it's missing: the sealed cookie's org is
209+
// browser-global and pinned to whichever org WorkOS last touched, so
210+
// falling back to it silently serves ANOTHER org's data to a multi-org
211+
// user (the wrong-tenant connection-list bug, 2026-07). A header-less
212+
// session call gets a clear 403 instead. Membership is re-checked live —
213+
// the header is a selector, not a trust boundary (see organization.ts).
214+
// A bare-URL first paint (no org in the path yet) may 403 here; that's
215+
// the safe outcome — OrgSlugGate immediately canonicalizes the URL onto
216+
// an org slug, the org-keyed atom registry remounts, and everything
217+
// refetches with the header. `/account/me` — which deliberately DOES fall
218+
// back to the session org to pick that landing org — lives on the account
219+
// plane, not here.
220+
const selector = orgSelectorFromRequest(request);
207221
if (!selector) {
208-
return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
222+
return yield* new NoOrganization(NO_ORGANIZATION_SELECTOR);
209223
}
210224
const org = yield* authorizeOrganizationSelector(session.userId, selector);
211225
if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);

apps/cloud/src/extensions/billing/route.node.test.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,23 @@ const stubUsers = Layer.succeed(UserStoreService)({
6161
),
6262
});
6363

64-
const run = (headers: Record<string, string>, organizationId: string | null = SESSION_ORG) =>
64+
const run = (headers: Record<string, string>) =>
6565
resolveBillingOrganization(
6666
new Request("https://executor.test/api/billing/customer", { headers }),
67-
{ userId: MEMBER, organizationId },
67+
{ userId: MEMBER },
6868
).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers)));
6969

7070
describe("billing route org selector", () => {
71-
it.effect("falls back to the session org when no selector header is sent", () =>
71+
it.effect("fails closed when no selector header is sent", () =>
7272
Effect.gen(function* () {
73-
const org = yield* run({});
74-
expect(org.id).toBe(SESSION_ORG);
73+
// No fallback to the session org: the cookie's org is browser-global
74+
// and can name a different org than the tab's URL for a multi-org user.
75+
const error = yield* Effect.flip(run({}));
76+
expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 });
7577
}),
7678
);
7779

78-
it.effect("scopes billing to the URL org selector over the session org", () =>
80+
it.effect("scopes billing to the URL org selector", () =>
7981
Effect.gen(function* () {
8082
const org = yield* run({ "x-executor-organization": URL_SLUG });
8183
expect(org.id).toBe(URL_ORG);
@@ -88,11 +90,4 @@ describe("billing route org selector", () => {
8890
expect(error).toMatchObject({ _tag: "HttpResponseError", status: 403 });
8991
}),
9092
);
91-
92-
it.effect("requires either a selector header or a session org", () =>
93-
Effect.gen(function* () {
94-
const error = yield* Effect.flip(run({}, null));
95-
expect(error).toMatchObject({ _tag: "HttpResponseError", status: 401 });
96-
}),
97-
);
9893
});

apps/cloud/src/extensions/billing/route.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import {
1313

1414
type BillingSession = {
1515
readonly userId: string;
16-
readonly organizationId?: string | null;
1716
};
1817

1918
export const resolveBillingOrganization = (request: Request, session: BillingSession) =>
2019
Effect.gen(function* () {
21-
const selector = request.headers.get(ORG_SELECTOR_HEADER) ?? session.organizationId;
20+
// FAIL CLOSED: no header, no org. The AutumnProvider always sends the
21+
// URL-scoped header (see __root.tsx billingHeaders); the sealed cookie's
22+
// org is a browser-global that can name a DIFFERENT org for a multi-org
23+
// user (see workos-auth-provider.resolveSessionPrincipal).
24+
const selector = request.headers.get(ORG_SELECTOR_HEADER);
2225
if (!selector) {
2326
return yield* new HttpResponseError({
2427
status: 401,

apps/cloud/src/org/auth-middleware.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()(
4040
.pipe(Effect.orElseSucceed(() => null));
4141
if (!result) return unauthorized();
4242

43-
const selector = request.headers[ORG_SELECTOR_HEADER] ?? result.organizationId;
43+
// FAIL CLOSED: no header, no org — the sealed cookie's org is a
44+
// browser-global pinned to whichever org WorkOS last touched, so
45+
// falling back to it scopes a multi-org user's request to the WRONG
46+
// org (see workos-auth-provider.resolveSessionPrincipal).
47+
const selector = request.headers[ORG_SELECTOR_HEADER];
4448
if (!selector) return noOrganization();
4549

4650
const org = yield* authorizeOrganizationSelector(result.userId, selector).pipe(

apps/cloud/src/routes/__root.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useParams,
1111
} from "@tanstack/react-router";
1212
import { AutumnProvider } from "autumn-js/react";
13+
import { isValidOrgSlug } from "@executor-js/api";
1314
import posthog from "posthog-js";
1415
import { PostHogProvider } from "posthog-js/react";
1516
import type { FrontendErrorReporter } from "@executor-js/react/api/error-reporting";
@@ -214,6 +215,14 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
214215
// is scoped to it, so `auth.organization` IS this org when the caller is a
215216
// member — and `null` when the URL names an org they can't access.
216217
const urlOrgSlug = (useParams({ strict: false }) as { orgSlug?: string }).orgSlug;
218+
// The same slug derived from the PATHNAME instead of the route params: the
219+
// params resolve asynchronously (a fresh load renders once with no orgSlug,
220+
// then again with it), and anything keyed on them remounts on that flap.
221+
// The pathname is synchronously correct on the very first render, and it is
222+
// exactly what the request header derives from (getActiveOrgSlug), so the
223+
// registry scope below can never disagree with the header scope.
224+
const firstSegment = location.pathname.split("/")[1] ?? "";
225+
const pathnameOrgSlug = isValidOrgSlug(firstSegment) ? firstSegment : null;
217226

218227
// The SSR gate already bounced fresh org-less document requests to
219228
// /create-org; this catches the MID-SESSION transitions (org deleted,
@@ -282,26 +291,42 @@ function AuthGate({ ssrOrigin }: { ssrOrigin: string | null }) {
282291
// /<orgB> while their cookie still points at orgA would briefly render orgA's
283292
// slug in the copyable URL before /account/me (URL-scoped) corrects it. The
284293
// URL slug is the actual request scope and is correct on the very first paint,
285-
// so sourcing it from there removes that flash. Falls back to the session slug
286-
// on a bare URL (which OrgSlugGate is about to canonicalize onto it anyway).
287-
const scopeSlug = urlOrgSlug ?? activeSlug;
294+
// so sourcing it from there removes that flash. VALIDATED (pathnameOrgSlug,
295+
// not the raw route param): the `{-$orgSlug}` param also captures reserved
296+
// console roots ("/integrations" → orgSlug "integrations"), which are not
297+
// org scopes. Falls back to the auth org on a bare/reserved URL (which
298+
// OrgSlugGate canonicalizes onto it below).
299+
const scopeSlug = pathnameOrgSlug ?? activeSlug;
288300
const billingHeaders = scopeSlug ? { [EXECUTOR_ORG_HEADER]: scopeSlug } : undefined;
289301

290302
return (
291303
<AutumnProvider pathPrefix="/api/billing" headers={billingHeaders}>
292304
<Sentry.ErrorBoundary fallback={<ShellErrorFallback />} showDialog={false}>
293-
<ExecutorProvider connection={connection} onHandledError={captureFrontendError}>
305+
{/* scopeKey ties the atom registry to the URL's org: cached query
306+
results can never survive an org change, and the bare → slugged
307+
canonicalization remounts the registry so anything fetched
308+
header-less on first paint (rejected server-side) is refetched
309+
with the org header. */}
310+
<ExecutorProvider
311+
connection={connection}
312+
scopeKey={pathnameOrgSlug}
313+
onHandledError={captureFrontendError}
314+
>
294315
<React.Suspense fallback={<BlankScreen />}>
295316
<ExecutorPluginsProvider plugins={clientPlugins}>
296317
<OrganizationProvider
297318
organizationId={auth.organization.id}
298319
organizationSlug={scopeSlug}
299320
>
300-
{/* The org header scopes every request to the URL's org, so
301-
reaching here means the caller is a member of `activeSlug`
302-
(a foreign slug already 404'd above). The gate only keeps
303-
the URL canonical — bare → /<slug>. */}
304-
<OrgSlugGate activeSlug={activeSlug}>
321+
{/* Canonicalize onto the URL's org, not the auth org: on first
322+
paint `auth.organization` is the SSR hint (the COOKIE's
323+
org), and canonicalizing onto that would rewrite a
324+
multi-org user's /<orgB> URL to /<orgA> during the hint
325+
window. `scopeSlug` prefers the URL slug, so a slugged URL
326+
is already canonical (a foreign slug 404'd above) and only
327+
a bare URL gets rewritten — onto the auth org, the one
328+
thing it can mean. */}
329+
<OrgSlugGate activeSlug={scopeSlug}>
305330
<Shell />
306331
<Toaster />
307332
</OrgSlugGate>

0 commit comments

Comments
 (0)