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 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2 2
3import { AccountProvider } from "@executor-js/api/server";3import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
4import {4import {
5 AccountError,5 AccountError,
6 AccountForbidden,6 AccountForbidden,
@@ -12,7 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines
12import { UserStoreService } from "../auth/context";12import { UserStoreService } from "../auth/context";
13import type { Session } from "../auth/middleware";13import type { Session } from "../auth/middleware";
14import { WorkOSClient } from "../auth/workos";14import { WorkOSClient } from "../auth/workos";
15import { authorizeOrganization } from "../auth/organization";15import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
16import { AutumnService } from "../extensions/billing/service";16import { AutumnService } from "../extensions/billing/service";
17import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans";17import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans";
18 18
@@ -87,16 +87,20 @@ export const workosAccountProvider: Layer.Layer< · 68 unmodified lines
87 ? Effect.succeed(caller.session)87 ? Effect.succeed(caller.session)
88 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());88 : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());
89 89
90 // Like cloud's `requireSessionOrganization`: an authenticated session that90 // The org scope for an org-scoped request: the console URL's org (sent in
91 // currently holds an active membership in its session org. Yields the91 // the selector header) when present, else the session's own org. Membership
92 // session + resolved org, or AccountNoOrganization.92 // is re-checked live, so the header is a selector, not a trust boundary —
93 const requireOrganization = () =>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) =>
94 Effect.gen(function* () {97 Effect.gen(function* () {
95 const session = yield* requireSession();98 const session = yield* requireSession();
96 if (!session.organizationId) {99 const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
100 if (!selector) {
97 return yield* new AccountNoOrganization();101 return yield* new AccountNoOrganization();
98 }102 }
99 const org = yield* authorizeOrganization(session.accountId, session.organizationId).pipe(103 const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
100 Effect.provideContext(ctx),104 Effect.provideContext(ctx),
101 Effect.mapError(() => new AccountNoOrganization()),105 Effect.mapError(() => new AccountNoOrganization()),
102 );106 );
@@ -158,11 +162,15 @@ export const workosAccountProvider: Layer.Layer< · 55 unmodified lines
158 });162 });
159 163
160 return AccountProvider.of({164 return AccountProvider.of({
161 me: () =>165 me: (headers) =>
162 Effect.gen(function* () {166 Effect.gen(function* () {
163 const session = yield* requireSession();167 const session = yield* requireSession();
164 const org = session.organizationId168 // Same selector precedence as requireOrganization: the URL's org
165 ? yield* authorizeOrganization(session.accountId, session.organizationId).pipe(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(
166 Effect.provideContext(ctx),174 Effect.provideContext(ctx),
167 Effect.orElseSucceed(() => null),175 Effect.orElseSucceed(() => null),
168 )176 )
@@ -178,18 +186,18 @@ export const workosAccountProvider: Layer.Layer< · 9 unmodified lines
178 };186 };
179 }),187 }),
180 188
181 listApiKeys: () =>189 listApiKeys: (headers) =>
182 Effect.gen(function* () {190 Effect.gen(function* () {
183 const { session, org } = yield* requireOrganization();191 const { session, org } = yield* requireOrganization(headers);
184 const keys = yield* apiKeys192 const keys = yield* apiKeys
185 .listUserKeys({ accountId: session.accountId, organizationId: org.id })193 .listUserKeys({ accountId: session.accountId, organizationId: org.id })
186 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));194 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
187 return { apiKeys: keys };195 return { apiKeys: keys };
188 }),196 }),
189 197
190 createApiKey: (_headers, name) =>198 createApiKey: (headers, name) =>
191 Effect.gen(function* () {199 Effect.gen(function* () {
192 const { session, org } = yield* requireOrganization();200 const { session, org } = yield* requireOrganization(headers);
193 const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);201 const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);
194 if (!trimmed) {202 if (!trimmed) {
195 return yield* new AccountError({ message: "API key name is required" });203 return yield* new AccountError({ message: "API key name is required" });
@@ -199,9 +207,9 @@ export const workosAccountProvider: Layer.Layer< · 3 unmodified lines
199 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));207 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
200 }),208 }),
201 209
202 revokeApiKey: (_headers, apiKeyId) =>210 revokeApiKey: (headers, apiKeyId) =>
203 Effect.gen(function* () {211 Effect.gen(function* () {
204 const { session, org } = yield* requireOrganization();212 const { session, org } = yield* requireOrganization(headers);
205 const ownedKeys = yield* apiKeys213 const ownedKeys = yield* apiKeys
206 .listUserKeys({ accountId: session.accountId, organizationId: org.id })214 .listUserKeys({ accountId: session.accountId, organizationId: org.id })
207 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));215 .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
@@ -214,9 +222,9 @@ export const workosAccountProvider: Layer.Layer< · 6 unmodified lines
214 return { success: true };222 return { success: true };
215 }),223 }),
216 224
217 listMembers: () =>225 listMembers: (headers) =>
218 Effect.gen(function* () {226 Effect.gen(function* () {
219 const { session, org } = yield* requireOrganization();227 const { session, org } = yield* requireOrganization(headers);
220 228
221 // Seats fall back to safe display defaults on lookup error — never229 // Seats fall back to safe display defaults on lookup error — never
222 // blank the page over a transient Autumn/WorkOS hiccup. The real cap230 // blank the page over a transient Autumn/WorkOS hiccup. The real cap
@@ -252,9 +260,9 @@ export const workosAccountProvider: Layer.Layer< · 29 unmodified lines
252 return { members, seats };260 return { members, seats };
253 }),261 }),
254 262
255 listRoles: () =>263 listRoles: (headers) =>
256 Effect.gen(function* () {264 Effect.gen(function* () {
257 const { org } = yield* requireOrganization();265 const { org } = yield* requireOrganization(headers);
258 const result = yield* workos266 const result = yield* workos
259 .listOrgRoles(org.id)267 .listOrgRoles(org.id)
260 .pipe(Effect.catchTag("WorkOSError", toAccountError));268 .pipe(Effect.catchTag("WorkOSError", toAccountError));
@@ -263,9 +271,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
263 };271 };
264 }),272 }),
265 273
266 inviteMember: (_headers, body) =>274 inviteMember: (headers, body) =>
267 Effect.gen(function* () {275 Effect.gen(function* () {
268 const { session, org } = yield* requireOrganization();276 const { session, org } = yield* requireOrganization(headers);
269 yield* requireAdmin(session.accountId, org.id);277 yield* requireAdmin(session.accountId, org.id);
270 yield* reserveMemberSlot(org.id);278 yield* reserveMemberSlot(org.id);
271 const invitation = yield* workos279 const invitation = yield* workos
@@ -278,9 +286,9 @@ export const workosAccountProvider: Layer.Layer< · 6 unmodified lines
278 return { id: invitation.id, email: invitation.email };286 return { id: invitation.id, email: invitation.email };
279 }),287 }),
280 288
281 removeMember: (_headers, membershipId) =>289 removeMember: (headers, membershipId) =>
282 Effect.gen(function* () {290 Effect.gen(function* () {
283 const { session, org } = yield* requireOrganization();291 const { session, org } = yield* requireOrganization(headers);
284 yield* requireAdmin(session.accountId, org.id);292 yield* requireAdmin(session.accountId, org.id);
285 yield* assertMembershipInOrg(org.id, membershipId);293 yield* assertMembershipInOrg(org.id, membershipId);
286 yield* workos294 yield* workos
@@ -289,9 +297,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
289 return { success: true };297 return { success: true };
290 }),298 }),
291 299
292 updateMemberRole: (_headers, membershipId, roleSlug) =>300 updateMemberRole: (headers, membershipId, roleSlug) =>
293 Effect.gen(function* () {301 Effect.gen(function* () {
294 const { session, org } = yield* requireOrganization();302 const { session, org } = yield* requireOrganization(headers);
295 yield* requireAdmin(session.accountId, org.id);303 yield* requireAdmin(session.accountId, org.id);
296 yield* assertMembershipInOrg(org.id, membershipId);304 yield* assertMembershipInOrg(org.id, membershipId);
297 yield* workos305 yield* workos
@@ -300,9 +308,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
300 return { success: true };308 return { success: true };
301 }),309 }),
302 310
303 updateOrgName: (_headers, name) =>311 updateOrgName: (headers, name) =>
304 Effect.gen(function* () {312 Effect.gen(function* () {
305 const { session, org } = yield* requireOrganization();313 const { session, org } = yield* requireOrganization(headers);
306 yield* requireAdmin(session.accountId, org.id);314 yield* requireAdmin(session.accountId, org.id);
307 const updated = yield* workos315 const updated = yield* workos
308 .updateOrganization(org.id, name)316 .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
67// `getDomainVerificationLink` handler also gates on billing, so67// `getDomainVerificationLink` handler also gates on billing, so
68// `AutumnService.Default` is provided HERE (not on the neutral boot core).68// `AutumnService.Default` is provided HERE (not on the neutral boot core).
69// Unlike the member endpoints that used to live here, they need no per-request69// 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).
71export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(71export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(
72 Layer.provide(OrgHandlers),72 Layer.provide(OrgHandlers),
73 Layer.provideMerge(OrgAuthLive),73 Layer.provideMerge(OrgAuthLive),
apps/cloud/src/auth/middleware-live.ts+7−0
@@ -85,6 +85,13 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines
85 return yield* Effect.fail(new NoOrganization());85 return yield* Effect.fail(new NoOrganization());
86 }86 }
87 87
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.
88 const session = sessionFromSealed(result, Redacted.value(credential));95 const session = sessionFromSealed(result, Redacted.value(credential));
89 const auth = {96 const auth = {
90 accountId: session.accountId,97 accountId: session.accountId,
apps/cloud/src/auth/org-selector-auth.node.test.ts+129−0
@@ -0,0 +1,129 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { ApiKeyService } from "./api-keys";
5import { UserStoreService } from "./context";
6import { resolveSessionPrincipal } from "./workos-auth-provider";
7import { 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
15const 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.
18const MEMBER = "user_session";
19const SESSION_ORG = "org_session";
20const URL_ORG = "org_url";
21const URL_SLUG = "acme";
22
23const 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
32const 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
57const 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
86const 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
91describe("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
79 79
80 return yield* resolveOrganization(organizationId);80 return yield* resolveOrganization(organizationId);
81 });81 });
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
101export 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. */
104export 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 */
113export 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
40 40
41import { ApiKeyService } from "./api-keys";41import { ApiKeyService } from "./api-keys";
42import { BEARER_PREFIX } from "./bearer";42import { BEARER_PREFIX } from "./bearer";
43import { authorizeOrganization } from "./organization";43import {
44 authorizeOrganization,
45 authorizeOrganizationSelector,
46 orgSelectorFromRequest,
47} from "./organization";
44import { UserStoreService } from "./context";48import { UserStoreService } from "./context";
45import { sealedSessionDisplayName } from "./middleware";49import { sealedSessionDisplayName } from "./middleware";
46import type { UserStoreError, WorkOSError } from "./errors";50import type { UserStoreError, WorkOSError } from "./errors";
@@ -108,10 +112,18 @@ export const resolveSessionPrincipal = (request: Request) => · 61 unmodified lines
108 Effect.gen(function* () {112 Effect.gen(function* () {
109 const workos = yield* WorkOSClient;113 const workos = yield* WorkOSClient;
110 const session = yield* workos.authenticateRequest(request);114 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) {
112 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);124 return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
113 }125 }
114 const org = yield* authorizeOrganization(session.userId, session.organizationId);126 const org = yield* authorizeOrganizationSelector(session.userId, selector);
115 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);127 if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION);
116 return {128 return {
117 accountId: session.userId,129 accountId: session.userId,