Skip to content

Commit 639bef6

Browse files
committed
Fix org scope: use URL slug as single source of truth for API requests
Multi-org users viewing an org by its URL slug while their session cookie was pinned to a different org had billing and membership checks scoped to the session's org, not the URL's. An admin on a free-plan session org who opened a team-plan org by slug hit the free 3-seat cap and got a 403 on invite. Make the URL path slug the single source of org truth for the API plane, mirroring the MCP plane: - Add prepareApiOrgScope worker-boundary seam: rewrites /<slug>/api/... to bare /api/... and pins the slug in the internal selector header; strips any client-supplied selector header on bare /api/... paths. - Remove session.organizationId as a request scope in auth, account, and billing readers. A request with no URL slug is org-less (NoOrganization). - Billing proxy resolves the URL selector to a WorkOS org via a live membership check and uses it as the Autumn customerId; non-members 403. - Clients stop sending the x-executor-organization header and instead splice the active slug into the API base URL at request time. Adds unit coverage for the boundary seam and a black-box e2e repro of the multi-org seat bug.
1 parent 49e1675 commit 639bef6

24 files changed

Lines changed: 486 additions & 109 deletions

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,18 @@ export const workosAccountProvider: Layer.Layer<
8787
? Effect.succeed(caller.session)
8888
: Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
8989

90-
// The org scope for an org-scoped request: the console URL's org (sent in
91-
// the selector header) when present, else the session's own org. Membership
92-
// is re-checked live, so the header is a selector, not a trust boundary —
93-
// and two browser tabs on different orgs each send their own header, so
94-
// they stay independent (see organization.ts). Yields the session +
95-
// resolved org, or AccountNoOrganization.
90+
// The org scope for an org-scoped request comes ONLY from the console URL,
91+
// which the worker boundary pins in the selector header (`/<slug>/api/...`
92+
// → ORG_SELECTOR_HEADER). The session identifies the USER, never the org —
93+
// there is no session fallback, so two browser tabs on different orgs each
94+
// resolve their own org from their own URL and stay independent (see
95+
// organization.ts). Membership is re-checked live, so the header is a
96+
// URL-derived selector, not a trust boundary. Yields the session + resolved
97+
// org, or AccountNoOrganization when the request is org-less.
9698
const requireOrganization = (headers: AccountHeaders) =>
9799
Effect.gen(function* () {
98100
const session = yield* requireSession();
99-
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
101+
const selector = headers[ORG_SELECTOR_HEADER];
100102
if (!selector) {
101103
return yield* new AccountNoOrganization();
102104
}
@@ -165,10 +167,12 @@ export const workosAccountProvider: Layer.Layer<
165167
me: (headers) =>
166168
Effect.gen(function* () {
167169
const session = yield* requireSession();
168-
// Same selector precedence as requireOrganization: the URL's org
169-
// (header) drives /account/me so the shell reflects the org the tab
170-
// is viewing, not a session-global active org.
171-
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
170+
// Same org source as requireOrganization: the URL's org (pinned in
171+
// the selector header by the worker boundary) drives /account/me so
172+
// the shell reflects the org the tab is viewing. `me` stays
173+
// null-tolerant — an org-less request (no URL slug, e.g. onboarding)
174+
// resolves organization: null rather than erroring.
175+
const selector = headers[ORG_SELECTOR_HEADER];
172176
const org = selector
173177
? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
174178
Effect.provideContext(ctx),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { ORG_SELECTOR_HEADER } from "../auth/organization";
4+
import { classifyApiOrgScope, isApiPath, prepareApiOrgScope } from "./org-scope";
5+
6+
// The worker-boundary seam that turns the URL's first path segment into the org
7+
// scope for the `/api/*` plane. The wire form is `/<slug>/api/...` (or the
8+
// legacy `/<org_id>/api/...`); the boundary strips the prefix to the bare
9+
// `/api/...` the app handler routes and pins the selector in an internal header.
10+
// Org rides ONLY in the URL — a client-supplied selector header is never trusted.
11+
12+
describe("isApiPath", () => {
13+
it("matches the bare API plane", () => {
14+
expect(isApiPath("/api")).toBe(true);
15+
expect(isApiPath("/api/executions")).toBe(true);
16+
expect(isApiPath("/api/billing/customer")).toBe(true);
17+
});
18+
19+
it("does not match org-scoped, MCP, or console paths", () => {
20+
expect(isApiPath("/acme-corp/api/executions")).toBe(false);
21+
expect(isApiPath("/mcp")).toBe(false);
22+
expect(isApiPath("/settings")).toBe(false);
23+
expect(isApiPath("/apiary")).toBe(false); // not `/api` nor `/api/`
24+
});
25+
});
26+
27+
describe("classifyApiOrgScope", () => {
28+
it("classifies a slug-scoped API path", () => {
29+
expect(classifyApiOrgScope("/acme-corp/api/billing/customer")).toEqual({
30+
selector: "acme-corp",
31+
barePath: "/api/billing/customer",
32+
});
33+
});
34+
35+
it("classifies the legacy org-id-scoped form", () => {
36+
expect(classifyApiOrgScope("/org_01ABCDEF/api/executions")).toEqual({
37+
selector: "org_01ABCDEF",
38+
barePath: "/api/executions",
39+
});
40+
});
41+
42+
it("returns null for a bare API path (no selector segment)", () => {
43+
expect(classifyApiOrgScope("/api/billing/customer")).toBeNull();
44+
expect(classifyApiOrgScope("/api")).toBeNull();
45+
});
46+
47+
it("returns null when the second segment is not `api`", () => {
48+
expect(classifyApiOrgScope("/acme-corp/mcp")).toBeNull();
49+
expect(classifyApiOrgScope("/settings/mcp")).toBeNull();
50+
expect(classifyApiOrgScope("/acme-corp")).toBeNull();
51+
});
52+
});
53+
54+
describe("prepareApiOrgScope", () => {
55+
it("rewrites a slug-scoped request to the bare path and pins the selector", () => {
56+
const out = prepareApiOrgScope(
57+
new Request("https://executor.test/acme-corp/api/billing/customer", { method: "POST" }),
58+
);
59+
expect(new URL(out.url).pathname).toBe("/api/billing/customer");
60+
expect(out.headers.get(ORG_SELECTOR_HEADER)).toBe("acme-corp");
61+
expect(out.method).toBe("POST");
62+
});
63+
64+
it("pins the legacy org-id selector", () => {
65+
const out = prepareApiOrgScope(
66+
new Request("https://executor.test/org_01ABCDEF/api/executions"),
67+
);
68+
expect(new URL(out.url).pathname).toBe("/api/executions");
69+
expect(out.headers.get(ORG_SELECTOR_HEADER)).toBe("org_01ABCDEF");
70+
});
71+
72+
it("strips a client-supplied selector header on a bare API path", () => {
73+
// Org may come ONLY from the URL — a client cannot smuggle a different org
74+
// by setting the internal selector header on a bare `/api/...` request.
75+
const out = prepareApiOrgScope(
76+
new Request("https://executor.test/api/billing/customer", {
77+
headers: { [ORG_SELECTOR_HEADER]: "org_attacker" },
78+
}),
79+
);
80+
expect(new URL(out.url).pathname).toBe("/api/billing/customer");
81+
expect(out.headers.has(ORG_SELECTOR_HEADER)).toBe(false);
82+
});
83+
84+
it("leaves a bare API path without a selector header untouched", () => {
85+
const req = new Request("https://executor.test/api/executions");
86+
expect(prepareApiOrgScope(req)).toBe(req);
87+
});
88+
89+
it("leaves a non-API path untouched", () => {
90+
const req = new Request("https://executor.test/settings");
91+
expect(prepareApiOrgScope(req)).toBe(req);
92+
});
93+
});

apps/cloud/src/api/org-scope.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// ---------------------------------------------------------------------------
2+
// API org scope — the worker-boundary seam that carries the URL's org into the
3+
// `/api/*` plane, mirroring `prepareMcpOrgScope` for `/mcp`.
4+
//
5+
// On the wire the org is the FIRST PATH SEGMENT of every org-scoped API request
6+
// (`/<slug>/api/...`, or the legacy `/<org_id>/api/...`), exactly like the
7+
// console URL it rides alongside — the URL is the single source of org truth,
8+
// and no client header carries org anymore. This boundary classifies that path,
9+
// rewrites it to the bare `/api/...` the app handler actually routes, and pins
10+
// the URL's org in the internal `ORG_SELECTOR_HEADER` the auth/account/billing
11+
// planes read via `orgSelectorFromRequest`. Membership is STILL re-checked per
12+
// request downstream (`authorizeOrganizationSelector`), so the header is a
13+
// selector the worker sets from the URL, never a trust boundary or a value a
14+
// client may supply.
15+
// ---------------------------------------------------------------------------
16+
17+
import { isValidOrgSlug } from "@executor-js/api";
18+
19+
import { ORG_SELECTOR_HEADER } from "../auth/organization";
20+
21+
// Bare API path — what the app handler routes once the boundary has stripped any
22+
// `/<slug>` prefix. `api` is itself a RESERVED slug, so a bare `/api/...` first
23+
// segment can never be mistaken for an org selector.
24+
export const isApiPath = (pathname: string): boolean =>
25+
pathname === "/api" || pathname.startsWith("/api/");
26+
27+
// A leading path segment counts as an org selector when it's the org's URL slug
28+
// (the canonical console form) or has the WorkOS org-id shape (`org_…`, the
29+
// legacy form). The slug grammar reserves `api`, so the bare API plane never
30+
// matches. Same rule as the MCP front's `orgSelectorSegment`.
31+
const orgSelectorSegment = (segment: string | undefined): string | null =>
32+
segment && (segment.startsWith("org_") || isValidOrgSlug(segment)) ? segment : null;
33+
34+
export type ApiOrgScope = {
35+
/** Org selector pinned in the URL (`/<slug>/api/...` slug or the legacy
36+
* `/<org_id>/api/...` id). Resolved to an org id — and re-checked against
37+
* live membership — by `authorizeOrganizationSelector` downstream. */
38+
readonly selector: string;
39+
/** The bare `/api/...` path the app handler routes, with the `/<selector>`
40+
* prefix removed. */
41+
readonly barePath: string;
42+
};
43+
44+
/**
45+
* Classify an org-scoped API path (`/<selector>/api/...`), returning the URL's
46+
* org selector + the bare path the app handler routes, or `null` when the path
47+
* isn't one (a bare `/api/...`, an MCP path, or a console route). Shared by
48+
* `app-paths.ts`'s app-owned gate and `prepareApiOrgScope`.
49+
*/
50+
export const classifyApiOrgScope = (pathname: string): ApiOrgScope | null => {
51+
const segments = pathname.split("/").filter((segment) => segment.length > 0);
52+
if (segments.length < 2) return null;
53+
const selector = orgSelectorSegment(segments[0]);
54+
if (selector === null || segments[1] !== "api") return null;
55+
return { selector, barePath: `/${segments.slice(1).join("/")}` };
56+
};
57+
58+
/**
59+
* Normalize an API request for the app handler, which routes ONLY bare `/api/*`.
60+
* Rewrites `/<selector>/api/...` to its bare path and pins the URL's org in the
61+
* internal `ORG_SELECTOR_HEADER`. A bare `/api/*` is left untouched, except any
62+
* client-supplied selector header is stripped — org may come ONLY from the URL,
63+
* so a header can never smuggle a different org past it (a no-op for non-API
64+
* paths). Called by start.ts's app dispatch alongside `prepareMcpOrgScope`.
65+
*/
66+
export const prepareApiOrgScope = (request: Request): Request => {
67+
const url = new URL(request.url);
68+
const scope = classifyApiOrgScope(url.pathname);
69+
if (scope === null) {
70+
if (isApiPath(url.pathname) && request.headers.has(ORG_SELECTOR_HEADER)) {
71+
const stripped = new Request(url, request);
72+
stripped.headers.delete(ORG_SELECTOR_HEADER);
73+
return stripped;
74+
}
75+
return request;
76+
}
77+
url.pathname = scope.barePath;
78+
const rewritten = new Request(url, request);
79+
rewritten.headers.set(ORG_SELECTOR_HEADER, scope.selector);
80+
return rewritten;
81+
};

apps/cloud/src/api/router.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Layer } from "effect";
22
import { HttpRouter } from "effect/unstable/http";
33

4-
import { RouterConfigLive } from "@executor-js/api/server";
4+
import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server";
55

66
import { UserStoreService } from "../auth/context";
77
import { DbService } from "../db/db";
@@ -36,7 +36,11 @@ export const makeApiLive = (requestScopedLive: Layer.Layer<DbService | UserStore
3636
makeAccountApiLive(requestScopedLive),
3737
CloudDocsLive,
3838
makeProtectedApiLive(requestScopedLive),
39-
AutumnRoutesLive,
39+
// The Autumn billing proxy resolves the URL's org selector to its WorkOS id
40+
// via `authorizeOrganizationSelector`, which yields `UserStoreService` — so
41+
// it needs the SAME per-request DB scoping the other sub-APIs thread in
42+
// (matching `makeCloudExtensionRoutes`, the production composition).
43+
AutumnRoutesLive.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)),
4044
ApiErrorLoggingLive,
4145
).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(BootSharedServices));
4246

apps/cloud/src/app-paths.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ describe("isAppOwnedPath", () => {
2525
"/org_01ABCDEF/mcp",
2626
"/.well-known/oauth-protected-resource/acme-corp/mcp",
2727
"/.well-known/oauth-protected-resource/org_01ABCDEF/mcp",
28+
// Org-scoped API: org rides as the first path segment (`/<slug>/api/...`),
29+
// mirroring the MCP plane. The React app posts billing to
30+
// `/<slug>/api/billing/*` via <AutumnProvider pathPrefix> — that slug-scoped
31+
// form must reach the app handler, not the SPA. Legacy org-id form too.
32+
"/acme-corp/api/billing/customer",
33+
"/acme-corp/api/executions",
34+
"/org_01ABCDEF/api/billing/attach",
2835
];
2936
for (const pathname of appOwned) {
3037
it(`forwards ${pathname} to the app handler`, () => {

apps/cloud/src/app-paths.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { classifyApiOrgScope, isApiPath } from "./api/org-scope";
12
import { classifyMcpPath } from "./mcp/mount";
23

34
// ---------------------------------------------------------------------------
@@ -8,12 +9,13 @@ import { classifyMcpPath } from "./mcp/mount";
89
// `/api/*` — the typed API plus the cloud `extensions.routes` (the Autumn billing
910
// proxy at `/api/billing/*` and Swagger at `/api/docs` both live under `/api`) —
1011
// plus the `/mcp` serving envelope and its `/.well-known/*` OAuth discovery docs.
11-
// The dispatcher forwards those UNMODIFIED; anything else falls through to the
12-
// Start router. Keeping every served route under `/api` (no separate top-level
13-
// namespace) is what keeps this gate a simple two-prefix check.
12+
// Org-scoped API requests arrive slug-first (`/<slug>/api/...`); the dispatcher
13+
// forwards every app-owned path, rewriting the slug-scoped MCP/API forms to the
14+
// bare path the handler routes (see `prepareMcpOrgScope` / `prepareApiOrgScope`).
15+
// Anything else falls through to the Start router.
1416
// ---------------------------------------------------------------------------
1517

16-
export const isApiPath = (pathname: string) => pathname === "/api" || pathname.startsWith("/api/");
17-
1818
export const isAppOwnedPath = (pathname: string) =>
19-
isApiPath(pathname) || classifyMcpPath(pathname) !== null;
19+
isApiPath(pathname) ||
20+
classifyApiOrgScope(pathname) !== null ||
21+
classifyMcpPath(pathname) !== null;

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

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

9-
// 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
13-
// different orgs independent.
9+
// The org a request resolves to comes ONLY from the URL, carried in the
10+
// `x-executor-organization` selector header that the worker boundary derives
11+
// from the `/<slug>/api/...` path. There is no session-org fallback: a request
12+
// with no selector is org-less and fails `NoOrganization`. Live membership is
13+
// re-checked either way. This is what makes two browser tabs on different orgs
14+
// independent — the session's stored org never silently scopes a request.
1415

1516
const createdAt = new Date("2026-01-01T00:00:00.000Z");
1617

@@ -85,10 +86,13 @@ const run = (headers: Record<string, string>) =>
8586
);
8687

8788
describe("resolveSessionPrincipal · URL org selector", () => {
88-
it.effect("falls back to the session org when no selector header is sent", () =>
89+
it.effect("rejects an org-less request when no selector header is sent", () =>
8990
Effect.gen(function* () {
90-
const principal = yield* run({ cookie: "wos-session=x" });
91-
expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
91+
// No `/<slug>/` in the URL → no selector header → no org. The session's
92+
// stored org does NOT silently scope the request anymore; org comes only
93+
// from the URL.
94+
const error = yield* Effect.flip(run({ cookie: "wos-session=x" }));
95+
expect(error).toMatchObject({ _tag: "NoOrganization" });
9296
}),
9397
);
9498

apps/cloud/src/auth/organization.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,27 @@ export const authorizeOrganization = (userId: string, organizationId: string) =>
8282
// Org SELECTOR — the URL is the scope authority, not the session.
8383
// ---------------------------------------------------------------------------
8484
//
85-
// Org-scoped requests carry the active org in this header, set by the web
86-
// client from the console URL's slug (the MCP plane carries the same idea in
87-
// its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the
88-
// readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a
89-
// SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks
90-
// live membership, so the worst a forged header does is name an org the caller
91-
// already belongs to.
85+
// Org-scoped API requests carry the active org as the FIRST PATH SEGMENT of the
86+
// URL (`/<slug>/api/...`). The worker boundary (`api/org-scope.ts`'s
87+
// `prepareApiOrgScope`) strips that segment and re-pins it in this INTERNAL
88+
// header before the request reaches the app handler — the same shape the MCP
89+
// plane uses with its own `x-executor-mcp-organization`. The selector is a slug
90+
// (`acme`, the readable URL form) or a WorkOS id (`org_…`, the legacy/token
91+
// form). It is a SELECTOR, not a trust boundary: clients never set it (the
92+
// boundary strips any client-sent value on a bare `/api/*`), and
93+
// `authorizeOrganizationSelector` re-checks live membership, so the worst a
94+
// value does is name an org the caller already belongs to.
9295
//
93-
// Why a header and not the session's `org_id`: a browser shares ONE cookie jar
96+
// Why the URL and not the session's `org_id`: a browser shares ONE cookie jar
9497
// across tabs, so a single session-pinned org makes "active org" a
9598
// browser-global — two tabs can't be in two orgs at once, and switching in one
96-
// silently re-scopes the other. Scoping per-request from the URL makes each
97-
// tab independent.
99+
// silently re-scopes the other. Scoping per-request from the URL makes each tab
100+
// independent.
98101

99102
export const ORG_SELECTOR_HEADER = "x-executor-organization";
100103

101-
/** The URL-pinned org selector for a request, or `null` to fall back to the session. */
104+
/** The URL-pinned org selector for a request (set by the worker boundary from
105+
* the URL), or `null` when the request is org-less. */
102106
export const orgSelectorFromRequest = (request: Request): string | null =>
103107
request.headers.get(ORG_SELECTOR_HEADER);
104108

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,13 @@ export const resolveSessionPrincipal = (request: Request) =>
115115
if (!session) {
116116
return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
117117
}
118-
// The console URL's org is the scope authority (sent as a header); the
119-
// session's own org is the fallback for non-console callers. Membership is
120-
// re-checked live either way — the header is a selector, not a trust
121-
// boundary (see organization.ts).
122-
const selector = orgSelectorFromRequest(request) ?? session.organizationId;
118+
// The console URL is the ONLY scope authority: the worker boundary pins the
119+
// URL's org in the selector header from `/<slug>/api/...`. The session
120+
// identifies the USER, never the org — a request with no URL-scoped org is
121+
// org-less here (an org-less page must not reach a protected route).
122+
// Membership is still re-checked live — the selector is not a trust boundary
123+
// (see organization.ts).
124+
const selector = orgSelectorFromRequest(request);
123125
if (!selector) {
124126
return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
125127
}

0 commit comments

Comments
 (0)