History

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.

Rhys Sullivan committed 5 months ago

Commit be7f2df1 parent e48bff76 files · +230 −33Browse files

Unified · Split

6 files
  1. apps/cloud/src/account/workos-account-service.ts +37 −29
  2. apps/cloud/src/api/layers.ts +1 −1
  3. apps/cloud/src/auth/middleware-live.ts +7 −0
  4. apps/cloud/src/auth/org-selector-auth.node.test.ts +129 −0
  5. apps/cloud/src/auth/organization.ts +41 −0
  6. apps/cloud/src/auth/workos-auth-provider.ts +15 −3
apps/cloud/src/account/workos-account-service.ts+37−29
@@ -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"; · 5 unmodified lines
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< · 68 unmodified lines
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< · 55 unmodified lines
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< · 9 unmodified lines
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< · 3 unmodified lines
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< · 6 unmodified lines
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< · 29 unmodified lines
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< · 2 unmodified lines
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< · 6 unmodified lines
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< · 2 unmodified lines
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< · 2 unmodified lines
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+1−1
@@ -67,7 +67,7 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 66 unmodified lines
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+7−0
@@ -85,6 +85,13 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines
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,
apps/cloud/src/auth/org-selector-auth.node.test.ts+129−0
@@ -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+41−0
@@ -79,3 +79,44 @@ export const authorizeOrganization = (userId: string, organizationId: string) => · 78 unmodified lines
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+ });
apps/cloud/src/auth/workos-auth-provider.ts+15−3
@@ -40,7 +40,11 @@ import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@exec · 39 unmodified lines
4040
4141 import { ApiKeyService } from "./api-keys";
4242 import { BEARER_PREFIX } from "./bearer";
43−import { authorizeOrganization } from "./organization";
43+import {
44+ authorizeOrganization,
45+ authorizeOrganizationSelector,
46+ orgSelectorFromRequest,
47+} from "./organization";
4448 import { UserStoreService } from "./context";
4549 import { sealedSessionDisplayName } from "./middleware";
4650 import type { UserStoreError, WorkOSError } from "./errors";
@@ -108,10 +112,18 @@ export const resolveSessionPrincipal = (request: Request) => · 61 unmodified lines
108112 Effect.gen(function* () {
109113 const workos = yield* WorkOSClient;
110114 const session = yield* workos.authenticateRequest(request);
111− if (!session || !session.organizationId) {
115+ if (!session) {
116+ return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
117+ }
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;
123+ if (!selector) {
112124 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
113125 }
114− const org = yield* authorizeOrganization(session.userId, session.organizationId);
126+ const org = yield* authorizeOrganizationSelector(session.userId, selector);
115127 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
116128 return {
117129 accountId: session.userId,