Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 37 additions & 29 deletions apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect";

import { AccountProvider } from "@executor-js/api/server";
import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
import {
AccountError,
AccountForbidden,
Expand All @@ -12,7 +12,7 @@ import { ApiKeyService } from "../auth/api-keys";
import { UserStoreService } from "../auth/context";
import type { Session } from "../auth/middleware";
import { WorkOSClient } from "../auth/workos";
import { authorizeOrganization } from "../auth/organization";
import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
import { AutumnService } from "../extensions/billing/service";
import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans";

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

// Like cloud's `requireSessionOrganization`: an authenticated session that
// currently holds an active membership in its session org. Yields the
// session + resolved org, or AccountNoOrganization.
const requireOrganization = () =>
// The org scope for an org-scoped request: the console URL's org (sent in
// the selector header) when present, else the session's own org. Membership
// is re-checked live, so the header is a selector, not a trust boundary —
// and two browser tabs on different orgs each send their own header, so
// they stay independent (see organization.ts). Yields the session +
// resolved org, or AccountNoOrganization.
const requireOrganization = (headers: AccountHeaders) =>
Effect.gen(function* () {
const session = yield* requireSession();
if (!session.organizationId) {
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
if (!selector) {
return yield* new AccountNoOrganization();
}
const org = yield* authorizeOrganization(session.accountId, session.organizationId).pipe(
const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
Effect.provideContext(ctx),
Effect.mapError(() => new AccountNoOrganization()),
);
Expand Down Expand Up @@ -158,11 +162,15 @@ export const workosAccountProvider: Layer.Layer<
});

return AccountProvider.of({
me: () =>
me: (headers) =>
Effect.gen(function* () {
const session = yield* requireSession();
const org = session.organizationId
? yield* authorizeOrganization(session.accountId, session.organizationId).pipe(
// Same selector precedence as requireOrganization: the URL's org
// (header) drives /account/me so the shell reflects the org the tab
// is viewing, not a session-global active org.
const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;
const org = selector
? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(
Effect.provideContext(ctx),
Effect.orElseSucceed(() => null),
)
Expand All @@ -178,18 +186,18 @@ export const workosAccountProvider: Layer.Layer<
};
}),

listApiKeys: () =>
listApiKeys: (headers) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
const keys = yield* apiKeys
.listUserKeys({ accountId: session.accountId, organizationId: org.id })
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
return { apiKeys: keys };
}),

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

revokeApiKey: (_headers, apiKeyId) =>
revokeApiKey: (headers, apiKeyId) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
const ownedKeys = yield* apiKeys
.listUserKeys({ accountId: session.accountId, organizationId: org.id })
.pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));
Expand All @@ -214,9 +222,9 @@ export const workosAccountProvider: Layer.Layer<
return { success: true };
}),

listMembers: () =>
listMembers: (headers) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);

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

listRoles: () =>
listRoles: (headers) =>
Effect.gen(function* () {
const { org } = yield* requireOrganization();
const { org } = yield* requireOrganization(headers);
const result = yield* workos
.listOrgRoles(org.id)
.pipe(Effect.catchTag("WorkOSError", toAccountError));
Expand All @@ -263,9 +271,9 @@ export const workosAccountProvider: Layer.Layer<
};
}),

inviteMember: (_headers, body) =>
inviteMember: (headers, body) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
yield* reserveMemberSlot(org.id);
const invitation = yield* workos
Expand All @@ -278,9 +286,9 @@ export const workosAccountProvider: Layer.Layer<
return { id: invitation.id, email: invitation.email };
}),

removeMember: (_headers, membershipId) =>
removeMember: (headers, membershipId) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
yield* assertMembershipInOrg(org.id, membershipId);
yield* workos
Expand All @@ -289,9 +297,9 @@ export const workosAccountProvider: Layer.Layer<
return { success: true };
}),

updateMemberRole: (_headers, membershipId, roleSlug) =>
updateMemberRole: (headers, membershipId, roleSlug) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
yield* assertMembershipInOrg(org.id, membershipId);
yield* workos
Expand All @@ -300,9 +308,9 @@ export const workosAccountProvider: Layer.Layer<
return { success: true };
}),

updateOrgName: (_headers, name) =>
updateOrgName: (headers, name) =>
Effect.gen(function* () {
const { session, org } = yield* requireOrganization();
const { session, org } = yield* requireOrganization(headers);
yield* requireAdmin(session.accountId, org.id);
const updated = yield* workos
.updateOrganization(org.id, name)
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor
// `getDomainVerificationLink` handler also gates on billing, so
// `AutumnService.Default` is provided HERE (not on the neutral boot core).
// Unlike the member endpoints that used to live here, they need no per-request
// DB scoping.
// DB scoping (and `OrgAuthLive` stays session-scoped — see its note).
export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe(
Layer.provide(OrgHandlers),
Layer.provideMerge(OrgAuthLive),
Expand Down
7 changes: 7 additions & 0 deletions apps/cloud/src/auth/middleware-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export const OrgAuthLive = Layer.effect(
return yield* Effect.fail(new NoOrganization());
}

// NOTE: the domains plane stays scoped to the SESSION org, not the URL
// org. Unlike the data + account planes (which resolve the selector
// header), this `HttpApiMiddleware` security handler may carry no
// residual requirement, so it can't reach the per-request
// `UserStoreService` a slug→id resolution needs. The domains surface
// (org-settings → domain verification) is niche; URL-scoping it would
// mean converting this to an HttpRouter middleware — deferred.
const session = sessionFromSealed(result, Redacted.value(credential));
const auth = {
accountId: session.accountId,
Expand Down
129 changes: 129 additions & 0 deletions apps/cloud/src/auth/org-selector-auth.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";

import { ApiKeyService } from "./api-keys";
import { UserStoreService } from "./context";
import { resolveSessionPrincipal } from "./workos-auth-provider";
import { WorkOSClient, type WorkOSClientService } from "./workos";

// The org a console request resolves to is the URL's org (sent in the
// `x-executor-organization` selector header), not the session's stored org —
// with the session org as the fallback for non-console callers, and live
// membership re-checked either way. This is what makes two browser tabs on
// different orgs independent.

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

// user_session belongs to BOTH orgs; the URL selects which one a request hits.
const MEMBER = "user_session";
const SESSION_ORG = "org_session";
const URL_ORG = "org_url";
const URL_SLUG = "acme";

const stubApiKeys = Layer.succeed(ApiKeyService)({
// No Authorization header in these tests → the api-key path returns null and
// resolution falls through to the session path.
validate: () => Effect.succeed(null),
listUserKeys: () => Effect.succeed([]),
createUserKey: () => Effect.die("not used"),
revokeUserKey: () => Effect.void,
});

const stubWorkOS = Layer.succeed(
WorkOSClient,
new Proxy({} as WorkOSClientService, {
get: (_t, prop) => {
if (prop === "authenticateRequest") {
return () =>
Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG });
}
if (prop === "listUserMemberships") {
return (userId: string) =>
Effect.succeed({
data:
userId === MEMBER
? [
{ userId, organizationId: SESSION_ORG, status: "active" },
{ userId, organizationId: URL_ORG, status: "active" },
]
: [],
});
}
return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
},
}),
);

const stubUsers = Layer.succeed(UserStoreService)({
use: (fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
getAccount: async (id: string) => ({ id, createdAt }),
upsertOrganization: async (org: { id: string; name: string }) => ({
...org,
slug: null,
createdAt,
}),
getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),
// The URL slug maps to URL_ORG (the member's other org); any other slug
// maps to an org the caller is NOT a member of, so membership rejects it.
getOrganizationBySlug: async (slug: string) => ({
id: slug === URL_SLUG ? URL_ORG : "org_outsider",
name: `Org ${slug}`,
slug,
createdAt,
}),
ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({
...org,
slug: org.slug ?? org.id,
createdAt,
}),
}),
),
});

const run = (headers: Record<string, string>) =>
resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe(
Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)),
);

describe("resolveSessionPrincipal · URL org selector", () => {
it.effect("falls back to the session org when no selector header is sent", () =>
Effect.gen(function* () {
const principal = yield* run({ cookie: "wos-session=x" });
expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG);
}),
);

it.effect("scopes to the URL org (by slug) over the session org", () =>
Effect.gen(function* () {
const principal = yield* run({
cookie: "wos-session=x",
"x-executor-organization": URL_SLUG,
});
expect(principal.organizationId, "the slug header wins over the session org").toBe(URL_ORG);
}),
);

it.effect("accepts a WorkOS org id as the selector too", () =>
Effect.gen(function* () {
const principal = yield* run({
cookie: "wos-session=x",
"x-executor-organization": URL_ORG,
});
expect(principal.organizationId).toBe(URL_ORG);
}),
);

it.effect("rejects a selector for an org the caller is not a member of", () =>
Effect.gen(function* () {
// The slug resolves to a real org id, but membership is re-checked — a
// slug is a selector, not a trust boundary, so a non-member is rejected.
const error = yield* Effect.flip(
run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }),
);
expect(error).toMatchObject({ _tag: "NoOrganization" });
}),
);
});
41 changes: 41 additions & 0 deletions apps/cloud/src/auth/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,44 @@ export const authorizeOrganization = (userId: string, organizationId: string) =>

return yield* resolveOrganization(organizationId);
});

// ---------------------------------------------------------------------------
// Org SELECTOR — the URL is the scope authority, not the session.
// ---------------------------------------------------------------------------
//
// Org-scoped requests carry the active org in this header, set by the web
// client from the console URL's slug (the MCP plane carries the same idea in
// its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the
// readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a
// SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks
// live membership, so the worst a forged header does is name an org the caller
// already belongs to.
//
// Why a header and not the session's `org_id`: a browser shares ONE cookie jar
// across tabs, so a single session-pinned org makes "active org" a
// browser-global — two tabs can't be in two orgs at once, and switching in one
// silently re-scopes the other. Scoping per-request from the URL makes each
// tab independent.

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

/** The URL-pinned org selector for a request, or `null` to fall back to the session. */
export const orgSelectorFromRequest = (request: Request): string | null =>
request.headers.get(ORG_SELECTOR_HEADER);

/**
* Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the
* caller actively belongs to, or `null`. A slug resolves through the local
* mirror to its id first; ids pass straight through. Either way membership is
* verified live via {@link authorizeOrganization}.
*/
export const authorizeOrganizationSelector = (userId: string, selector: string) =>
Effect.gen(function* () {
if (selector.startsWith("org_")) {
return yield* authorizeOrganization(userId, selector);
}
const users = yield* UserStoreService;
const org = yield* users.use((s) => s.getOrganizationBySlug(selector));
if (!org) return null;
return yield* authorizeOrganization(userId, org.id);
});
Loading
Loading