Skip to content

Commit be7f2df

Browse files
committed
Cloud API scopes by the URL org header, with session as fallback
Org-scoped requests carry the active org in an x-executor-organization header (a slug or org_ id); the server resolves it against live WorkOS membership and scopes to it, falling back to the session's own org when absent. Additive and backward-compatible — existing cookie-only callers keep working via the fallback. The header is a selector, not a trust boundary (membership is always re-checked), mirroring the MCP plane. This is the server half of moving off cookie-based 'active org': once the web client sends its URL's org per request, two browser tabs on different orgs scope independently instead of fighting over one shared cookie. Covers the executor data plane (workos-auth-provider) and the account plane (members/api-keys/me). The domains plane stays session-scoped: its HttpApiMiddleware security handler may carry no residual requirement, so it can't reach the per-request UserStoreService a slug needs — noted for a follow-up that converts it to an HttpRouter middleware.
1 parent e48bff7 commit be7f2df

6 files changed

Lines changed: 230 additions & 33 deletions

File tree

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

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Context, Effect, Layer } from "effect";
22

3-
import { AccountProvider } from "@executor-js/api/server";
3+
import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
44
import {
55
AccountError,
66
AccountForbidden,
@@ -12,7 +12,7 @@ import { ApiKeyService } from "../auth/api-keys";
1212
import { UserStoreService } from "../auth/context";
1313
import type { Session } from "../auth/middleware";
1414
import { WorkOSClient } from "../auth/workos";
15-
import { authorizeOrganization } from "../auth/organization";
15+
import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
1616
import { AutumnService } from "../extensions/billing/service";
1717
import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans";
1818

@@ -87,16 +87,20 @@ export const workosAccountProvider: Layer.Layer<
8787
? Effect.succeed(caller.session)
8888
: Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
8989

90-
// Like cloud's `requireSessionOrganization`: an authenticated session that
91-
// currently holds an active membership in its session org. Yields the
92-
// session + resolved org, or AccountNoOrganization.
93-
const requireOrganization = () =>
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.
96+
const requireOrganization = (headers: AccountHeaders) =>
9497
Effect.gen(function* () {
9598
const session = yield* requireSession();
96-
if (!session.organizationId) {
99+
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
100+
if (!selector) {
97101
return yield* new AccountNoOrganization();
98102
}
99-
const org = yield* authorizeOrganization(session.accountId, session.organizationId).pipe(
103+
const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
100104
Effect.provideContext(ctx),
101105
Effect.mapError(() => new AccountNoOrganization()),
102106
);
@@ -158,11 +162,15 @@ export const workosAccountProvider: Layer.Layer<
158162
});
159163

160164
return AccountProvider.of({
161-
me: () =>
165+
me: (headers) =>
162166
Effect.gen(function* () {
163167
const session = yield* requireSession();
164-
const org = session.organizationId
165-
? yield* authorizeOrganization(session.accountId, session.organizationId).pipe(
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;
172+
const org = selector
173+
? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
166174
Effect.provideContext(ctx),
167175
Effect.orElseSucceed(() => null),
168176
)
@@ -178,18 +186,18 @@ export const workosAccountProvider: Layer.Layer<
178186
};
179187
}),
180188

181-
listApiKeys: () =>
189+
listApiKeys: (headers) =>
182190
Effect.gen(function* () {
183-
const { session, org } = yield* requireOrganization();
191+
const { session, org } = yield* requireOrganization(headers);
184192
const keys = yield* apiKeys
185193
.listUserKeys({ accountId: session.accountId, organizationId: org.id })
186194
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
187195
return { apiKeys: keys };
188196
}),
189197

190-
createApiKey: (_headers, name) =>
198+
createApiKey: (headers, name) =>
191199
Effect.gen(function* () {
192-
const { session, org } = yield* requireOrganization();
200+
const { session, org } = yield* requireOrganization(headers);
193201
const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
194202
if (!trimmed) {
195203
return yield* new AccountError({ message: "API key name is required" });
@@ -199,9 +207,9 @@ export const workosAccountProvider: Layer.Layer<
199207
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
200208
}),
201209

202-
revokeApiKey: (_headers, apiKeyId) =>
210+
revokeApiKey: (headers, apiKeyId) =>
203211
Effect.gen(function* () {
204-
const { session, org } = yield* requireOrganization();
212+
const { session, org } = yield* requireOrganization(headers);
205213
const ownedKeys = yield* apiKeys
206214
.listUserKeys({ accountId: session.accountId, organizationId: org.id })
207215
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
@@ -214,9 +222,9 @@ export const workosAccountProvider: Layer.Layer<
214222
return { success: true };
215223
}),
216224

217-
listMembers: () =>
225+
listMembers: (headers) =>
218226
Effect.gen(function* () {
219-
const { session, org } = yield* requireOrganization();
227+
const { session, org } = yield* requireOrganization(headers);
220228

221229
// Seats fall back to safe display defaults on lookup error — never
222230
// blank the page over a transient Autumn/WorkOS hiccup. The real cap
@@ -252,9 +260,9 @@ export const workosAccountProvider: Layer.Layer<
252260
return { members, seats };
253261
}),
254262

255-
listRoles: () =>
263+
listRoles: (headers) =>
256264
Effect.gen(function* () {
257-
const { org } = yield* requireOrganization();
265+
const { org } = yield* requireOrganization(headers);
258266
const result = yield* workos
259267
.listOrgRoles(org.id)
260268
.pipe(Effect.catchTag("WorkOSError", toAccountError));
@@ -263,9 +271,9 @@ export const workosAccountProvider: Layer.Layer<
263271
};
264272
}),
265273

266-
inviteMember: (_headers, body) =>
274+
inviteMember: (headers, body) =>
267275
Effect.gen(function* () {
268-
const { session, org } = yield* requireOrganization();
276+
const { session, org } = yield* requireOrganization(headers);
269277
yield* requireAdmin(session.accountId, org.id);
270278
yield* reserveMemberSlot(org.id);
271279
const invitation = yield* workos
@@ -278,9 +286,9 @@ export const workosAccountProvider: Layer.Layer<
278286
return { id: invitation.id, email: invitation.email };
279287
}),
280288

281-
removeMember: (_headers, membershipId) =>
289+
removeMember: (headers, membershipId) =>
282290
Effect.gen(function* () {
283-
const { session, org } = yield* requireOrganization();
291+
const { session, org } = yield* requireOrganization(headers);
284292
yield* requireAdmin(session.accountId, org.id);
285293
yield* assertMembershipInOrg(org.id, membershipId);
286294
yield* workos
@@ -289,9 +297,9 @@ export const workosAccountProvider: Layer.Layer<
289297
return { success: true };
290298
}),
291299

292-
updateMemberRole: (_headers, membershipId, roleSlug) =>
300+
updateMemberRole: (headers, membershipId, roleSlug) =>
293301
Effect.gen(function* () {
294-
const { session, org } = yield* requireOrganization();
302+
const { session, org } = yield* requireOrganization(headers);
295303
yield* requireAdmin(session.accountId, org.id);
296304
yield* assertMembershipInOrg(org.id, membershipId);
297305
yield* workos
@@ -300,9 +308,9 @@ export const workosAccountProvider: Layer.Layer<
300308
return { success: true };
301309
}),
302310

303-
updateOrgName: (_headers, name) =>
311+
updateOrgName: (headers, name) =>
304312
Effect.gen(function* () {
305-
const { session, org } = yield* requireOrganization();
313+
const { session, org } = yield* requireOrganization(headers);
306314
yield* requireAdmin(session.accountId, org.id);
307315
const updated = yield* workos
308316
.updateOrganization(org.id, name)

apps/cloud/src/api/layers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor
6767
// `getDomainVerificationLink` handler also gates on billing, so
6868
// `AutumnService.Default` is provided HERE (not on the neutral boot core).
6969
// Unlike the member endpoints that used to live here, they need no per-request
70-
// DB scoping.
70+
// DB scoping (and `OrgAuthLive` stays session-scoped — see its note).
7171
export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(
7272
Layer.provide(OrgHandlers),
7373
Layer.provideMerge(OrgAuthLive),

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export const OrgAuthLive = Layer.effect(
8585
return yield* Effect.fail(new NoOrganization());
8686
}
8787

88+
// NOTE: the domains plane stays scoped to the SESSION org, not the URL
89+
// org. Unlike the data + account planes (which resolve the selector
90+
// header), this `HttpApiMiddleware` security handler may carry no
91+
// residual requirement, so it can't reach the per-request
92+
// `UserStoreService` a slug→id resolution needs. The domains surface
93+
// (org-settings → domain verification) is niche; URL-scoping it would
94+
// mean converting this to an HttpRouter middleware — deferred.
8895
const session = sessionFromSealed(result, Redacted.value(credential));
8996
const auth = {
9097
accountId: session.accountId,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
4+
import { ApiKeyService } from "./api-keys";
5+
import { UserStoreService } from "./context";
6+
import { resolveSessionPrincipal } from "./workos-auth-provider";
7+
import { WorkOSClient, type WorkOSClientService } from "./workos";
8+
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.
14+
15+
const createdAt = new Date("2026-01-01T00:00:00.000Z");
16+
17+
// user_session belongs to BOTH orgs; the URL selects which one a request hits.
18+
const MEMBER = "user_session";
19+
const SESSION_ORG = "org_session";
20+
const URL_ORG = "org_url";
21+
const URL_SLUG = "acme";
22+
23+
const stubApiKeys = Layer.succeed(ApiKeyService)({
24+
// No Authorization header in these tests → the api-key path returns null and
25+
// resolution falls through to the session path.
26+
validate: () => Effect.succeed(null),
27+
listUserKeys: () => Effect.succeed([]),
28+
createUserKey: () => Effect.die("not used"),
29+
revokeUserKey: () => Effect.void,
30+
});
31+
32+
const stubWorkOS = Layer.succeed(
33+
WorkOSClient,
34+
new Proxy({} as WorkOSClientService, {
35+
get: (_t, prop) => {
36+
if (prop === "authenticateRequest") {
37+
return () =>
38+
Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG });
39+
}
40+
if (prop === "listUserMemberships") {
41+
return (userId: string) =>
42+
Effect.succeed({
43+
data:
44+
userId === MEMBER
45+
? [
46+
{ userId, organizationId: SESSION_ORG, status: "active" },
47+
{ userId, organizationId: URL_ORG, status: "active" },
48+
]
49+
: [],
50+
});
51+
}
52+
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
53+
},
54+
}),
55+
);
56+
57+
const stubUsers = Layer.succeed(UserStoreService)({
58+
use: (fn) =>
59+
Effect.promise(() =>
60+
fn({
61+
ensureAccount: async (id: string) => ({ id, createdAt }),
62+
getAccount: async (id: string) => ({ id, createdAt }),
63+
upsertOrganization: async (org: { id: string; name: string }) => ({
64+
...org,
65+
slug: null,
66+
createdAt,
67+
}),
68+
getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),
69+
// The URL slug maps to URL_ORG (the member's other org); any other slug
70+
// maps to an org the caller is NOT a member of, so membership rejects it.
71+
getOrganizationBySlug: async (slug: string) => ({
72+
id: slug === URL_SLUG ? URL_ORG : "org_outsider",
73+
name: `Org ${slug}`,
74+
slug,
75+
createdAt,
76+
}),
77+
ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({
78+
...org,
79+
slug: org.slug ?? org.id,
80+
createdAt,
81+
}),
82+
}),
83+
),
84+
});
85+
86+
const run = (headers: Record<string, string>) =>
87+
resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe(
88+
Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)),
89+
);
90+
91+
describe("resolveSessionPrincipal · URL org selector", () => {
92+
it.effect("falls back to the session org when no selector header is sent", () =>
93+
Effect.gen(function* () {
94+
const principal = yield* run({ cookie: "wos-session=x" });
95+
expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
96+
}),
97+
);
98+
99+
it.effect("scopes to the URL org (by slug) over the session org", () =>
100+
Effect.gen(function* () {
101+
const principal = yield* run({
102+
cookie: "wos-session=x",
103+
"x-executor-organization": URL_SLUG,
104+
});
105+
expect(principal.organizationId, "the slug header wins over the session org").toBe(URL_ORG);
106+
}),
107+
);
108+
109+
it.effect("accepts a WorkOS org id as the selector too", () =>
110+
Effect.gen(function* () {
111+
const principal = yield* run({
112+
cookie: "wos-session=x",
113+
"x-executor-organization": URL_ORG,
114+
});
115+
expect(principal.organizationId).toBe(URL_ORG);
116+
}),
117+
);
118+
119+
it.effect("rejects a selector for an org the caller is not a member of", () =>
120+
Effect.gen(function* () {
121+
// The slug resolves to a real org id, but membership is re-checked — a
122+
// slug is a selector, not a trust boundary, so a non-member is rejected.
123+
const error = yield* Effect.flip(
124+
run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }),
125+
);
126+
expect(error).toMatchObject({ _tag: "NoOrganization" });
127+
}),
128+
);
129+
});

apps/cloud/src/auth/organization.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,44 @@ export const authorizeOrganization = (userId: string, organizationId: string) =>
7979

8080
return yield* resolveOrganization(organizationId);
8181
});
82+
83+
// ---------------------------------------------------------------------------
84+
// Org SELECTOR — the URL is the scope authority, not the session.
85+
// ---------------------------------------------------------------------------
86+
//
87+
// Org-scoped requests carry the active org in this header, set by the web
88+
// client from the console URL's slug (the MCP plane carries the same idea in
89+
// its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the
90+
// readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a
91+
// SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks
92+
// live membership, so the worst a forged header does is name an org the caller
93+
// already belongs to.
94+
//
95+
// Why a header and not the session's `org_id`: a browser shares ONE cookie jar
96+
// across tabs, so a single session-pinned org makes "active org" a
97+
// browser-global — two tabs can't be in two orgs at once, and switching in one
98+
// silently re-scopes the other. Scoping per-request from the URL makes each
99+
// tab independent.
100+
101+
export const ORG_SELECTOR_HEADER = "x-executor-organization";
102+
103+
/** The URL-pinned org selector for a request, or `null` to fall back to the session. */
104+
export const orgSelectorFromRequest = (request: Request): string | null =>
105+
request.headers.get(ORG_SELECTOR_HEADER);
106+
107+
/**
108+
* Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the
109+
* caller actively belongs to, or `null`. A slug resolves through the local
110+
* mirror to its id first; ids pass straight through. Either way membership is
111+
* verified live via {@link authorizeOrganization}.
112+
*/
113+
export const authorizeOrganizationSelector = (userId: string, selector: string) =>
114+
Effect.gen(function* () {
115+
if (selector.startsWith("org_")) {
116+
return yield* authorizeOrganization(userId, selector);
117+
}
118+
const users = yield* UserStoreService;
119+
const org = yield* users.use((s) => s.getOrganizationBySlug(selector));
120+
if (!org) return null;
121+
return yield* authorizeOrganization(userId, org.id);
122+
});

0 commit comments

Comments
 (0)