History

Serve tenant-level product reads to organization API keys

Rhys Sullivan committed 3 months ago

Commit abbf4411 parent 676af1a13 files · +509 −55Browse files

Unified · Split

13 files
  1. apps/cloud/src/auth/org-api-key-auth.node.test.ts +15 −9
  2. apps/cloud/src/auth/workos-auth-provider.ts +33 −17
  3. apps/cloud/src/org/handlers.test.ts +1 −0
  4. apps/cloud/src/org/handlers.ts +5 −0
  5. apps/host-selfhost/src/mcp/auth.ts +7 −2
  6. apps/host-selfhost/src/platform-credential.test.ts +196 −0
  7. apps/host-selfhost/src/testing/test-app.ts +21 −1
  8. packages/core/api/src/server.ts +6 −0
  9. packages/core/api/src/server/execution-stack-middleware.ts +95 −15
  10. packages/core/api/src/server/execution-stack.ts +32 −1
  11. packages/core/api/src/server/executor-app.ts +7 −2
  12. packages/core/api/src/server/fixed-execution-middleware.ts +28 −5
  13. packages/core/api/src/server/identity.ts +63 −3
apps/cloud/src/auth/org-api-key-auth.node.test.ts+15−9
@@ -124,19 +124,25 @@ describe("org-level API keys", () => { · 123 unmodified lines
124 }),124 }),
125 );125 );
126 126
127 it.effect("are REJECTED on the product surface rather than bound to a subject", () =>127 it.effect("resolve on the product surface as a platform principal, never a subject", () =>
128 Effect.gen(function* () {128 Effect.gen(function* () {
129 // THE security property of the api-key half: a product request carrying129 // THE security property of the api-key half, updated for platform reads:
130 // an org key must fail, not silently act as some user.130 // a product request carrying an org key resolves to the NEUTRAL platform
131 const error = yield* Effect.flip(131 // shape — which the shared middleware routes to the subject-less,
132 resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)),132 // GET-only platform executor — and must never carry an accountId a
133 // handler could bind as an acting member.
134 const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(
135 Effect.provide(layers),
133 );136 );
134 137
135 expect(error).toMatchObject({138 expect(principal).toEqual({
136 _tag: "Unauthorized",139 kind: "platform",
137 code: "invalid_api_key",140 organizationId: "org_123",
138 message: "Organization API keys cannot be used on this endpoint",141 organizationName: "Org org_123",
142 organizationSlug: "org-slug-org_123",
143 keyId: "api_key_org",
139 });144 });
145 expect(principal).not.toHaveProperty("accountId");
140 }),146 }),
141 );147 );
142 148
apps/cloud/src/auth/workos-auth-provider.ts+33−17
@@ -38,7 +38,13 @@ import { · 37 unmodified lines
38 Unauthorized,38 Unauthorized,
39 Unavailable,39 Unavailable,
40} from "@executor-js/api/server";40} from "@executor-js/api/server";
41import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";41import type {
42 FailureRenderingStrategy,
43 IdentityFailure,
44 PlatformPrincipal,
45 Principal,
46 ResolvedPrincipal,
47} from "@executor-js/api/server";
42 48
43import { ApiKeyService } from "./api-keys";49import { ApiKeyService } from "./api-keys";
44import { workosApiJwtBearerConfig } from "./api-jwt-bearer";50import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
@@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { · 59 unmodified lines
104 code: "no_organization",110 code: "no_organization",
105 message: "No organization in access token",111 message: "No organization in access token",
106};112};
107// An org-level key resolves to the PLATFORM view, which has no acting member.
108// The product endpoints are bound to one subject, so they reject it outright
109// rather than inventing a subject for it to act as.
110const ORG_KEY_ON_PRODUCT_SURFACE = {
111 code: "invalid_api_key",
112 message: "Organization API keys cannot be used on this endpoint",
113};
114
115// A bearer value with three dot-separated segments is a JWT (a WorkOS access113// A bearer value with three dot-separated segments is a JWT (a WorkOS access
116// token from the CLI device-login); anything else is treated as an API key.114// token from the CLI device-login); anything else is treated as an API key.
117// Same discriminator the MCP plane uses (`mcp/auth.ts`).115// Same discriminator the MCP plane uses (`mcp/auth.ts`).
@@ -257,23 +255,36 @@ export const resolveBearerAuth = ( · 139 unmodified lines
257 });255 });
258 256
259/**257/**
260 * The PRODUCT-view bearer resolver: as {@link resolveBearerAuth}, but an258 * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral
261 * org-level key is REJECTED rather than downgraded. The product endpoints are259 * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared
262 * bound to one acting subject, so there is no honest way to serve them an260 * middleware routes it to the subject-less, read-only platform executor
263 * org key — those belong at the `/admin/*` mount instead. (Kept the historical261 * (refusing non-GET up front). Previously the product plane rejected org keys
264 * name; the re-export and resolver tests reference it.)262 * outright; serving tenant-level READS to them is deliberate: the catalog,
263 * tools, policies, and org-owned connection listings are tenant-shared answers
264 * a machine credential can honestly receive, while everything subject-bound
265 * stays structurally out of reach (a platform executor binds no subject, so no
266 * member's personal rows resolve). (Kept the historical name; the re-export and
267 * resolver tests reference it.)
265 */268 */
266export const resolveApiKeyPrincipal = (269export const resolveApiKeyPrincipal = (
267 request: Request,270 request: Request,
268 jwt: JwtBearerConfig | null = null,271 jwt: JwtBearerConfig | null = null,
269): Effect.Effect<272): Effect.Effect<
270 Principal | null,273 ResolvedPrincipal | null,
271 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,274 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
272 WorkOSClient | ApiKeyService | UserStoreService275 WorkOSClient | ApiKeyService | UserStoreService
273> =>276> =>
274 Effect.gen(function* () {277 Effect.gen(function* () {
275 const auth = yield* resolveBearerAuth(request, jwt);278 const auth = yield* resolveBearerAuth(request, jwt);
276 if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE);279 if (isPlatformAuth(auth)) {
280 return {
281 kind: "platform",
282 organizationId: auth.organizationId,
283 organizationName: auth.organizationName,
284 ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }),
285 keyId: auth.keyId,
286 } satisfies PlatformPrincipal;
287 }
277 return auth;288 return auth;
278 });289 });
279 290
@@ -330,7 +341,7 @@ export const resolveProtectedPrincipal = ( · 50 unmodified lines
330 request: Request,341 request: Request,
331 jwt: JwtBearerConfig | null = null,342 jwt: JwtBearerConfig | null = null,
332): Effect.Effect<343): Effect.Effect<
333 Principal,344 ResolvedPrincipal,
334 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,345 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
335 WorkOSClient | ApiKeyService | UserStoreService346 WorkOSClient | ApiKeyService | UserStoreService
336> =>347> =>
@@ -411,6 +422,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy<IdentityFail · 74 unmodified lines
411 "service_unavailable",422 "service_unavailable",
412 "Service temporarily unavailable",423 "Service temporarily unavailable",
413 ),424 ),
425 ReadOnlyCredential: renderIdentityFailure(
426 403,
427 "read_only_credential",
428 "Organization API keys are read-only",
429 ),
414 }),430 }),
415 ),431 ),
416};432};
apps/cloud/src/org/handlers.test.ts+1−0
@@ -70,6 +70,7 @@ const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => · 69 unmodified lines
70// Mirrors `org/handlers.ts` `requireAdmin`.70// Mirrors `org/handlers.ts` `requireAdmin`.
71const requireAdmin = Effect.gen(function* () {71const requireAdmin = Effect.gen(function* () {
72 const auth = yield* AuthContext;72 const auth = yield* AuthContext;
73 if (auth.accountId === null) return yield* new Forbidden();
73 const workos = yield* WorkOSClient;74 const workos = yield* WorkOSClient;
74 const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);75 const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
75 if (!current || current.role?.slug !== "admin") {76 if (!current || current.role?.slug !== "admin") {
apps/cloud/src/org/handlers.ts+5−0
@@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; · 16 unmodified lines
17 17
18const requireAdmin = Effect.gen(function* () {18const requireAdmin = Effect.gen(function* () {
19 const auth = yield* AuthContext;19 const auth = yield* AuthContext;
20 // This plane is mounted behind the session-only `orgAuthMiddleware`, so the
21 // caller is always a member — but `AuthContext.accountId` is nullable for the
22 // platform credential, and membership of "no member" is not a question worth
23 // asking WorkOS. Refuse rather than assert.
24 if (auth.accountId === null) return yield* new Forbidden();
20 const workos = yield* WorkOSClient;25 const workos = yield* WorkOSClient;
21 const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);26 const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId);
22 if (!currentMembership || currentMembership.role?.slug !== "admin") {27 if (!currentMembership || currentMembership.role?.slug !== "admin") {
apps/host-selfhost/src/mcp/auth.ts+7−2
@@ -1,7 +1,7 @@
1import { Effect, Layer } from "effect";1import { Effect, Layer } from "effect";
2import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";2import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins";
3 3
4import { IdentityProvider } from "@executor-js/api/server";4import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server";
5import {5import {
6 authenticated,6 authenticated,
7 McpAuthProvider,7 McpAuthProvider,
@@ -233,12 +233,17 @@ export const selfHostMcpAuth: Layer.Layer<McpAuthProvider, never, BetterAuth | I · 225 unmodified lines
233 }).pipe(Effect.orElseSucceed(() => null));233 }).pipe(Effect.orElseSucceed(() => null));
234 234
235 /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's235 /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's
236 * api `Principal` shape is byte-identical to host-mcp's `Principal`. */236 * api `Principal` shape is byte-identical to host-mcp's `Principal`. The
237 * neutral seam can also resolve a platform credential, which self-host's
238 * identity never produces — narrowed away rather than asserted, so an MCP
239 * session can never bind to a subject-less credential if that changes. */
237 const authenticateSession = (request: Request): Effect.Effect<Principal | null> =>240 const authenticateSession = (request: Request): Effect.Effect<Principal | null> =>
238 fallback.authenticate(request).pipe(241 fallback.authenticate(request).pipe(
242 Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)),
239 Effect.catchTags({243 Effect.catchTags({
240 Unauthorized: () => Effect.succeed(null),244 Unauthorized: () => Effect.succeed(null),
241 NoOrganization: () => Effect.succeed(null),245 NoOrganization: () => Effect.succeed(null),
246 ReadOnlyCredential: () => Effect.succeed(null),
242 }),247 }),
243 );248 );
244 249
apps/host-selfhost/src/platform-credential.test.ts+196−0
@@ -0,0 +1,196 @@
1import { mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { afterAll, beforeAll, expect, test } from "@effect/vitest";
6
7process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-platform-"));
8
9// ---------------------------------------------------------------------------
10// The shared middleware's PLATFORM branch, end-to-end over the real router: an
11// org-level credential (the shape cloud's org-scoped API key resolves to) gets
12// tenant-level READS on the ordinary product paths, and nothing else.
13//
14// Served through the self-host test app because it runs the SAME
15// `makeExecutionStackMiddleware` cloud's protected plane uses — the branch
16// under test is host-neutral, and this is the harness that can drive it
17// without WorkOS. The `x-test-platform-org` header resolves the platform
18// principal (see testing/test-app.ts).
19//
20// Pinned here:
21// 1. GET /api/integrations answers the tenant catalog — the read that used
22// to require a second, member credential (the customer-reported gap);
23// 2. GET /api/connections answers org-owned rows WITHOUT any member's
24// personal connections (bound reach, no subject);
25// 3. any non-GET is refused 403 before a handler runs;
26// 4. the credential mints NO subject row — an org key must never appear as a
27// user of the workspace it observes.
28// ---------------------------------------------------------------------------
29
30let handler!: (request: Request) => Promise<Response>;
31let dispose: () => Promise<void> = async () => {};
32
33beforeAll(async () => {
34 const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app");
35 const app = await makeSelfHostTestApp({
36 identity: headerIdentityLayer,
37 });
38 handler = app.handler;
39 dispose = app.dispose;
40});
41afterAll(() => dispose());
42
43const ORG = "platform-org";
44const MEMBER = "member-user";
45
46const TINY_SPEC = JSON.stringify({
47 openapi: "3.0.0",
48 info: { title: "Tiny", version: "1.0.0" },
49 servers: [{ url: "https://httpbin.org" }],
50 paths: {
51 "/get": {
52 get: {
53 operationId: "httpGet",
54 summary: "GET",
55 responses: { "200": { description: "ok" } },
56 },
57 },
58 },
59});
60
61const memberHeaders: Record<string, string> = {
62 "x-test-user": MEMBER,
63 "x-test-org": ORG,
64 "content-type": "application/json",
65};
66
67const platformHeaders: Record<string, string> = {
68 "x-test-platform-org": ORG,
69 "content-type": "application/json",
70};
71
72/** Seed as the MEMBER: a catalog row, an org-owned connection, and a personal
73 * one — so the platform reads below have both a hit and a must-miss. */
74beforeAll(async () => {
75 const add = await handler(
76 new Request("http://localhost/api/openapi/specs", {
77 method: "POST",
78 headers: memberHeaders,
79 body: JSON.stringify({ spec: { kind: "blob", value: TINY_SPEC }, slug: "cat", baseUrl: "" }),
80 }),
81 );
82 expect(add.status).toBe(200);
83 const orgConn = await handler(
84 new Request("http://localhost/api/connections", {
85 method: "POST",
86 headers: memberHeaders,
87 body: JSON.stringify({
88 owner: "org",
89 name: "shared",
90 integration: "cat",
91 template: "bearer",
92 value: "org-shared-token",
93 }),
94 }),
95 );
96 expect(orgConn.status).toBe(200);
97 const userConn = await handler(
98 new Request("http://localhost/api/connections", {
99 method: "POST",
100 headers: memberHeaders,
101 body: JSON.stringify({
102 owner: "user",
103 name: "personal",
104 integration: "cat",
105 template: "bearer",
106 value: "member-personal-token",
107 }),
108 }),
109 );
110 expect(userConn.status).toBe(200);
111});
112
113test("a platform credential reads the tenant catalog on the ordinary product path", async () => {
114 const res = await handler(
115 new Request("http://localhost/api/integrations", { headers: platformHeaders }),
116 );
117 expect(res.status).toBe(200);
118 const body = (await res.json()) as ReadonlyArray<{ readonly slug: string }>;
119 expect(
120 body.map((integration) => integration.slug),
121 "the catalog the member configured is readable with the org credential alone",
122 ).toContain("cat");
123});
124
125test("connection reads answer org-owned rows and never a member's personal ones", async () => {
126 const res = await handler(
127 new Request("http://localhost/api/connections", { headers: platformHeaders }),
128 );
129 expect(res.status).toBe(200);
130 const raw = await res.text();
131 // oxlint-disable-next-line executor/no-json-parse -- boundary: test asserts on the raw wire body (for the token sweep) and its parsed form together
132 const body = JSON.parse(raw) as ReadonlyArray<{ readonly name: string }>;
133 expect(
134 body.map((connection) => connection.name),
135 "the org-owned connection is visible",
136 ).toContain("shared");
137 expect(
138 body.map((connection) => connection.name),
139 "a member's personal connection is not — the platform view binds no subject",
140 ).not.toContain("personal");
141 expect(raw, "no credential material either way").not.toContain("token");
142});
143
144test("every non-GET is refused before a handler runs", async () => {
145 const post = await handler(
146 new Request("http://localhost/api/connections", {
147 method: "POST",
148 headers: platformHeaders,
149 body: JSON.stringify({
150 owner: "org",
151 name: "sneaky",
152 integration: "cat",
153 template: "bearer",
154 value: "nope",
155 }),
156 }),
157 );
158 expect(post.status, "a write with a read-only credential is a clear 403").toBe(403);
159 expect(await post.text()).toContain("read-only");
160
161 const del = await handler(
162 new Request("http://localhost/api/integrations/cat", {
163 method: "DELETE",
164 headers: platformHeaders,
165 }),
166 );
167 expect(del.status).toBe(403);
168
169 // The refusal happened before any handler: the connection it tried to write
170 // does not exist.
171 const after = await handler(
172 new Request("http://localhost/api/connections", { headers: platformHeaders }),
173 );
174 const names = ((await after.json()) as ReadonlyArray<{ readonly name: string }>).map(
175 (connection) => connection.name,
176 );
177 expect(names).not.toContain("sneaky");
178});
179
180test("the credential never mints a subject row in the workspace it observes", async () => {
181 // Several platform reads have run by now. If any of them touched the subject
182 // table, the org would report a phantom user; only the seeding member may
183 // appear.
184 const { withQueryContext } = await import("@executor-js/fumadb/query");
185 const { createSelfHostDb } = await import("./db/self-host-db");
186 const { loadConfig } = await import("./config");
187 const db = await createSelfHostDb({ path: loadConfig().dbPath });
188 const rows = await withQueryContext(db.db, { tenant: ORG, subject: null }).findMany("subject", {
189 orderBy: ["external_id", "asc"],
190 });
191 await db.close();
192 expect(
193 rows.map((row) => String(row.external_id)),
194 "only the member has a footprint",
195 ).toEqual([MEMBER]);
196});
apps/host-selfhost/src/testing/test-app.ts+21−1
@@ -5,8 +5,10 @@ import { · 4 unmodified lines
5 composePluginApi,5 composePluginApi,
6 ExecutorApp,6 ExecutorApp,
7 IdentityProvider,7 IdentityProvider,
8 isPlatformPrincipal,
8 PluginsProvider,9 PluginsProvider,
9 type Principal,10 type Principal,
11 type ResolvedPrincipal,
10 textFailureStrategy,12 textFailureStrategy,
11 Unauthorized,13 Unauthorized,
12} from "@executor-js/api/server";14} from "@executor-js/api/server";
@@ -98,6 +100,19 @@ export const headerIdentityLayer: Layer.Layer<IdentityProvider> = Layer.succeed( · 85 unmodified lines
98 IdentityProvider,100 IdentityProvider,
99 IdentityProvider.of({101 IdentityProvider.of({
100 authenticate: (request) => {102 authenticate: (request) => {
103 // `x-test-platform-org` resolves the org-level PLATFORM credential — the
104 // neutral shape cloud's org-scoped API key produces — so the shared
105 // middleware's platform branch is testable against this harness even
106 // though self-host's real identity never mints one.
107 const platformOrg = request.headers.get("x-test-platform-org");
108 if (platformOrg) {
109 return Effect.succeed<ResolvedPrincipal>({
110 kind: "platform",
111 organizationId: platformOrg,
112 organizationName: `Org ${platformOrg}`,
113 keyId: "test_platform_key",
114 });
115 }
101 const userId = request.headers.get("x-test-user");116 const userId = request.headers.get("x-test-user");
102 const organizationId = request.headers.get("x-test-org");117 const organizationId = request.headers.get("x-test-org");
103 if (!userId || !organizationId) return Effect.fail(new Unauthorized());118 if (!userId || !organizationId) return Effect.fail(new Unauthorized());
@@ -143,12 +158,17 @@ const stubMcpAuth: Layer.Layer<McpAuthProvider, never, IdentityProvider> = Layer · 39 unmodified lines
143 resourceMetadataUrl: resourceMetadataUrlFor,158 resourceMetadataUrl: resourceMetadataUrlFor,
144 authenticate: (request: Request): Effect.Effect<AuthOutcome> =>159 authenticate: (request: Request): Effect.Effect<AuthOutcome> =>
145 fallback.authenticate(request).pipe(160 fallback.authenticate(request).pipe(
161 // The test identity never resolves a platform credential; narrow it
162 // away (as the production selfHostMcpAuth does) rather than asserting.
146 Effect.map((principal) =>163 Effect.map((principal) =>
147 principal ? authenticated(principal) : unauthorized(challengeFor(request)),164 principal && !isPlatformPrincipal(principal)
165 ? authenticated(principal)
166 : unauthorized(challengeFor(request)),
148 ),167 ),
149 Effect.catchTags({168 Effect.catchTags({
150 Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))),169 Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))),
151 NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))),170 NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))),
171 ReadOnlyCredential: () => Effect.succeed(unauthorized(challengeFor(request))),
152 }),172 }),
153 ),173 ),
154 };174 };
packages/core/api/src/server.ts+6−0
@@ -48,6 +48,7 @@ export { RouterConfigLive } from "./server/router-config"; · 47 unmodified lines
48export { consoleErrorCapture } from "./server/console-error-capture";48export { consoleErrorCapture } from "./server/console-error-capture";
49export {49export {
50 makeExecutionStack,50 makeExecutionStack,
51 makePlatformExecutionStack,
51 CodeExecutorProvider,52 CodeExecutorProvider,
52 EngineDecorator,53 EngineDecorator,
53 EngineDecoratorNoop,54 EngineDecoratorNoop,
@@ -95,8 +96,13 @@ export { · 41 unmodified lines
95 Unauthorized,96 Unauthorized,
96 NoOrganization,97 NoOrganization,
97 Unavailable,98 Unavailable,
99 ReadOnlyCredential,
98 authContextFromPrincipal,100 authContextFromPrincipal,
101 authContextFromPlatform,
102 isPlatformPrincipal,
99 type Principal,103 type Principal,
104 type PlatformPrincipal,
105 type ResolvedPrincipal,
100 type IdentityProviderShape,106 type IdentityProviderShape,
101 type IdentityFailure,107 type IdentityFailure,
102} from "./server/identity";108} from "./server/identity";
packages/core/api/src/server/execution-stack-middleware.ts+95−15
@@ -36,8 +36,10 @@ · 35 unmodified lines
36 36
37import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";37import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
38import { Context, Effect, Layer } from "effect";38import { Context, Effect, Layer } from "effect";
39import type * as Cause from "effect/Cause";
39 40
40import type { AnyPlugin } from "@executor-js/sdk";41import type { AnyPlugin } from "@executor-js/sdk";
42import type { ExecutionEngine } from "@executor-js/execution";
41 43
42import type { DbProvider } from "./executor-fuma-db";44import type { DbProvider } from "./executor-fuma-db";
43import {45import {
@@ -49,13 +51,17 @@ import { · 5 unmodified lines
49import { ExecutionEngineService, ExecutorService } from "../services";51import { ExecutionEngineService, ExecutorService } from "../services";
50import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";52import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";
51import {53import {
54 authContextFromPlatform,
52 authContextFromPrincipal,55 authContextFromPrincipal,
53 AuthContext,56 AuthContext,
57 isPlatformPrincipal,
58 ReadOnlyCredential,
54 type IdentityFailure,59 type IdentityFailure,
55 type Principal,60 type ResolvedPrincipal,
56} from "./identity";61} from "./identity";
57import {62import {
58 makeExecutionStack,63 makeExecutionStack,
64 makePlatformExecutionStack,
59 type CodeExecutorProvider,65 type CodeExecutorProvider,
60 type EngineDecorator,66 type EngineDecorator,
61} from "./execution-stack";67} from "./execution-stack";
@@ -68,9 +74,9 @@ import { · 6 unmodified lines
68 * residual requirement the strategy adds (always `never` in practice).74 * residual requirement the strategy adds (always `never` in practice).
69 */75 */
70export interface FailureRenderingStrategy<E, RR = never> {76export interface FailureRenderingStrategy<E, RR = never> {
71 readonly renderFailure: <R>(77 readonly renderFailure: <A extends ResolvedPrincipal, R>(
72 effect: Effect.Effect<Principal, E, R>,78 effect: Effect.Effect<A, E, R>,
73 ) => Effect.Effect<Principal | HttpServerResponse.HttpServerResponse, E, R | RR>;79 ) => Effect.Effect<A | HttpServerResponse.HttpServerResponse, E, R | RR>;
74}80}
75 81
76/**82/**
@@ -98,6 +104,15 @@ export const textFailureStrategy: FailureRenderingStrategy<IdentityFailure> = { · 21 unmodified lines
98 status: 503,104 status: 503,
99 }),105 }),
100 ),106 ),
107 // Self-host/local identity never resolves a platform credential, but the
108 // method gate raises this tag on the SHARED channel, so total coverage
109 // requires rendering it (and keeps the strategy honest if that changes).
110 ReadOnlyCredential: () =>
111 Effect.succeed(
112 HttpServerResponse.text("Organization API keys are read-only", {
113 status: 403,
114 }),
115 ),
101 }),116 }),
102 ),117 ),
103};118};
@@ -112,12 +127,14 @@ export interface MakeExecutionStackMiddlewareOptions< · 8 unmodified lines
112 /** The host's plugin tuple — drives the typed extension Services and binding. */127 /** The host's plugin tuple — drives the typed extension Services and binding. */
113 readonly plugins: TPlugins;128 readonly plugins: TPlugins;
114 /**129 /**
115 * Resolve the inbound web `Request` to a neutral `Principal`. Adapter-specific130 * Resolve the inbound web `Request` to a neutral `Principal` — or, for an
116 * credential precedence stays inside this function.131 * org-level credential (cloud's org-scoped API key), a `PlatformPrincipal`
132 * that the middleware routes to the read-only platform branch.
133 * Adapter-specific credential precedence stays inside this function.
117 */134 */
118 readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>;135 readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>;
119 /** Render `authenticate` failures (passthrough for cloud, text for self-host). */136 /** Render `authenticate` failures (passthrough for cloud, text for self-host). */
120 readonly strategy: FailureRenderingStrategy<E, RStrategy>;137 readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>;
121 /** The host's `makeExecutionStack` seam Layer. */138 /** The host's `makeExecutionStack` seam Layer. */
122 readonly stackLayer: Layer.Layer<139 readonly stackLayer: Layer.Layer<
123 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,140 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,
@@ -166,9 +183,52 @@ export const makeExecutionStackMiddleware = < · 42 unmodified lines
166 Effect.gen(function* () {183 Effect.gen(function* () {
167 const request = yield* HttpServerRequest.HttpServerRequest;184 const request = yield* HttpServerRequest.HttpServerRequest;
168 const webRequest = yield* HttpServerRequest.toWeb(request);185 const webRequest = yield* HttpServerRequest.toWeb(request);
169 const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest));186 const resolved = yield* options.strategy.renderFailure(
187 options.authenticate(webRequest).pipe(
188 // The PLATFORM branch's method gate lives here, before any
189 // handler: an org credential is read-only by construction, so a
190 // non-GET request is refused as a typed 403 rather than reaching
191 // a handler that would bind it to a subject or attempt a write
192 // (the storage policy would refuse the write anyway — this makes
193 // the refusal a clear response instead of a 500).
194 Effect.filterOrFail(
195 (principal): principal is ResolvedPrincipal =>
196 !isPlatformPrincipal(principal) || webRequest.method === "GET",
197 () =>
198 new ReadOnlyCredential({
199 code: "read_only_credential",
200 message: "Organization API keys are read-only",
201 }),
202 ),
203 ),
204 );
170 // The strategy recovered the failure into a Response — return it.205 // The strategy recovered the failure into a Response — return it.
171 if (!isPrincipal(resolved)) return resolved;206 if (!isPrincipal(resolved)) return resolved;
207
208 if (isPlatformPrincipal(resolved)) {
209 // Org credential: the subject-less, write-refusing platform stack.
210 // No `touchSubject` runs, so the credential never mints a phantom
211 // user row in the very lists the admin plane serves.
212 const { executor } = yield* makePlatformExecutionStack<TPlugins>(
213 resolved.organizationId,
214 ).pipe(
215 Effect.provide(options.stackLayer),
216 Effect.provideService(RequestWebOrigin, {
217 origin: requestWebOriginFromRequest(webRequest),
218 }),
219 Effect.withSpan("executor.stack.http.resolve_platform"),
220 );
221 return yield* httpEffect.pipe(
222 Effect.provideService(AuthContext, AuthContext.of(authContextFromPlatform(resolved))),
223 Effect.provideService(ExecutorService, executor),
224 // The engine runs code as an acting member; the platform view has
225 // none, and owns no executions. GET readers get the honest empty
226 // answers; the execute/resume paths sit behind the method gate
227 // above and are unreachable.
228 Effect.provideService(ExecutionEngineService, readOnlyExecutionEngine),
229 provideExecutorExtensions(executor),
230 );
231 }
172 const auth = AuthContext.of(authContextFromPrincipal(resolved));232 const auth = AuthContext.of(authContextFromPrincipal(resolved));
173 // The public origin the caller actually hit, so a host with no static233 // The public origin the caller actually hit, so a host with no static
174 // web base URL (a Worker) derives one zero-config. An explicit234 // web base URL (a Worker) derives one zero-config. An explicit
@@ -206,13 +266,33 @@ export const makeExecutionStackMiddleware = < · 31 unmodified lines
206 );266 );
207};267};
208 268
209// `renderFailure` yields either the resolved `Principal` (proceed) or an269// `renderFailure` yields either the resolved principal (proceed) or an
210// already-built `HttpServerResponse` (the strategy recovered the failure). A270// already-built `HttpServerResponse` (the strategy recovered the failure).
211// `Principal` is a plain object with `accountId`; a response is tagged. Discern271// Discern by the marker the response framework brands its values with.
212// by the marker the response framework brands its values with.
213const isPrincipal = (272const isPrincipal = (
214 value: Principal | HttpServerResponse.HttpServerResponse,273 value: ResolvedPrincipal | HttpServerResponse.HttpServerResponse,
215): value is Principal => !HttpServerResponse.isHttpServerResponse(value);274): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value);
275
276/**
277 * The engine the platform branch provides: every read answers "nothing here",
278 * honestly — the platform view owns no executions and pauses none. The
279 * execute/resume members exist only to satisfy the service shape; they sit
280 * behind the middleware's GET-only gate, so reaching one is a wiring bug and
281 * dies as a defect rather than failing with a typed error a client could
282 * mistake for a product answer.
283 */
284const readOnlyExecutionEngine: ExecutionEngine<Cause.YieldableError> = {
285 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
286 execute: () => Effect.die("platform credential: execution engine unavailable"),
287 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
288 executeWithPause: () => Effect.die("platform credential: execution engine unavailable"),
289 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's GET-only gate; reaching it is a wiring bug, not a typed product outcome
290 resume: () => Effect.die("platform credential: execution engine unavailable"),
291 getPausedExecution: () => Effect.succeed(null),
292 pausedExecutionCount: () => Effect.succeed(0),
293 hasPausedExecutions: () => Effect.succeed(false),
294 getDescription: Effect.succeed(""),
295};
216 296
217const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);297const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
218 298
packages/core/api/src/server/execution-stack.ts+32−1
@@ -35,7 +35,12 @@ import { · 34 unmodified lines
35} from "@executor-js/execution";35} from "@executor-js/execution";
36 36
37import { DbProvider } from "./executor-fuma-db";37import { DbProvider } from "./executor-fuma-db";
38import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor";38import {
39 HostConfig,
40 PluginsProvider,
41 makePlatformExecutor,
42 makeScopedExecutor,
43} from "./scoped-executor";
39 44
40// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
41// CodeExecutorProvider seam — the host's code-execution substrate. Typed to the46// CodeExecutorProvider seam — the host's code-execution substrate. Typed to the
@@ -139,3 +144,29 @@ export const makeExecutionStack = < · 97 unmodified lines
139 ).pipe(Effect.withSpan("executor.stack.engine.init"));144 ).pipe(Effect.withSpan("executor.stack.engine.init"));
140 return { executor, engine };145 return { executor, engine };
141 }).pipe(Effect.withSpan("executor.stack.build"));146 }).pipe(Effect.withSpan("executor.stack.build"));
147
148// ---------------------------------------------------------------------------
149// makePlatformExecutionStack — the org-credential sibling: a subject-less,
150// write-refusing executor (`makePlatformExecutor`) and NO engine. An org key is
151// an observer; the execution engine exists to run code as an acting member, so
152// rather than building one that would fail on use, the middleware refuses
153// non-GET requests up front and never provides `ExecutionEngineService` on this
154// branch. Read-only handlers never read the engine, so the platform branch
155// serves them unchanged.
156// ---------------------------------------------------------------------------
157
158export const makePlatformExecutionStack = <
159 const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[],
160>(
161 organizationId: string,
162): Effect.Effect<
163 { readonly executor: Executor<TPlugins> },
164 StorageFailure,
165 DbProvider | PluginsProvider | HostConfig
166> =>
167 makePlatformExecutor(organizationId).pipe(
168 // The platform executor is built against the erased plugin set; re-narrow
169 // via the same phantom cast `makeScopedExecutor` performs for the scoped one.
170 Effect.map((executor) => ({ executor: executor as Executor<TPlugins> })),
171 Effect.withSpan("executor.stack.platform.build"),
172 );
packages/core/api/src/server/executor-app.ts+7−2
@@ -69,7 +69,12 @@ import { · 68 unmodified lines
69 type FailureRenderingStrategy,69 type FailureRenderingStrategy,
70} from "./execution-stack-middleware";70} from "./execution-stack-middleware";
71import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware";71import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware";
72import { IdentityProvider, type IdentityFailure, type Principal } from "./identity";72import {
73 IdentityProvider,
74 type IdentityFailure,
75 type Principal,
76 type ResolvedPrincipal,
77} from "./identity";
73import {78import {
74 makeAccountApiLayer,79 makeAccountApiLayer,
75 makeProtectedApiLayer,80 makeProtectedApiLayer,
@@ -418,7 +423,7 @@ export const make = < · 342 unmodified lines
418 // `Unauthorized | NoOrganization | Unavailable`.423 // `Unauthorized | NoOrganization | Unavailable`.
419 const authenticate = (424 const authenticate = (
420 request: Request,425 request: Request,
421 ): Effect.Effect<Principal, IdentityFailure, IdentityProvider> =>426 ): Effect.Effect<ResolvedPrincipal, IdentityFailure, IdentityProvider> =>
422 Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request));427 Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request));
423 428
424 // The per-request layer combined into the middleware: cloud's `requestScoped`429 // The per-request layer combined into the middleware: cloud's `requestScoped`
packages/core/api/src/server/fixed-execution-middleware.ts+28−5
@@ -35,7 +35,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; · 34 unmodified lines
35 35
36import { ExecutionEngineService, ExecutorService } from "../services";36import { ExecutionEngineService, ExecutorService } from "../services";
37import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";37import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes";
38import { authContextFromPrincipal, AuthContext, type Principal } from "./identity";38import {
39 authContextFromPrincipal,
40 AuthContext,
41 isPlatformPrincipal,
42 ReadOnlyCredential,
43 type Principal,
44 type ResolvedPrincipal,
45} from "./identity";
39import type { FailureRenderingStrategy } from "./execution-stack-middleware";46import type { FailureRenderingStrategy } from "./execution-stack-middleware";
40 47
41/**48/**
@@ -66,11 +73,13 @@ export interface MakeFixedExecutionMiddlewareOptions< · 24 unmodified lines
66 /**73 /**
67 * Resolve the inbound web `Request` to a neutral `Principal`. The credential74 * Resolve the inbound web `Request` to a neutral `Principal`. The credential
68 * shape stays inside this function; local's single-user provider always75 * shape stays inside this function; local's single-user provider always
69 * resolves the one local Principal.76 * resolves the one local Principal. (The seam's type admits a platform
77 * credential, but a fixed-executor host has no platform view — one resolving
78 * here is refused below.)
70 */79 */
71 readonly authenticate: (request: Request) => Effect.Effect<Principal, E, RLong>;80 readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, E, RLong>;
72 /** Render `authenticate` failures (text for local, matching self-host). */81 /** Render `authenticate` failures (text for local, matching self-host). */
73 readonly strategy: FailureRenderingStrategy<E, RStrategy>;82 readonly strategy: FailureRenderingStrategy<E | ReadOnlyCredential, RStrategy>;
74}83}
75 84
76/**85/**
@@ -108,7 +117,21 @@ export const makeFixedExecutionMiddleware = < · 31 unmodified lines
108 Effect.gen(function* () {117 Effect.gen(function* () {
109 const request = yield* HttpServerRequest.HttpServerRequest;118 const request = yield* HttpServerRequest.HttpServerRequest;
110 const webRequest = yield* HttpServerRequest.toWeb(request);119 const webRequest = yield* HttpServerRequest.toWeb(request);
111 const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest));120 const resolved = yield* options.strategy.renderFailure(
121 options.authenticate(webRequest).pipe(
122 // The fixed executor binds ONE subject at boot; there is no
123 // platform view to serve an org credential, so it is refused
124 // rather than silently acting as the boot subject.
125 Effect.filterOrFail(
126 (principal): principal is Principal => !isPlatformPrincipal(principal),
127 () =>
128 new ReadOnlyCredential({
129 code: "read_only_credential",
130 message: "Organization API keys are not accepted on this host",
131 }),
132 ),
133 ),
134 );
112 // The strategy recovered the failure into a Response — return it.135 // The strategy recovered the failure into a Response — return it.
113 if (!isPrincipal(resolved)) return resolved;136 if (!isPrincipal(resolved)) return resolved;
114 const auth = AuthContext.of(authContextFromPrincipal(resolved));137 const auth = AuthContext.of(authContextFromPrincipal(resolved));
packages/core/api/src/server/identity.ts+63−3
@@ -45,15 +45,50 @@ export interface Principal { · 44 unmodified lines
45 readonly roles: readonly string[];45 readonly roles: readonly string[];
46}46}
47 47
48/**
49 * An ORG-level credential (cloud's org-scoped API key), resolved. Deliberately
50 * NOT a `Principal`: a `Principal` names an acting member, and this credential
51 * has none — mirroring the resolution layer's own split (`ApiKeyOwner` keeps
52 * `accountId: string | null` for the same reason). Keeping it a separate shape
53 * means no code path can accidentally bind an org credential to a subject; the
54 * middleware routes it to the subject-less platform executor instead, and
55 * refuses every non-read method before a handler ever runs.
56 *
57 * Self-host and local never produce this: their credentials always name a
58 * member. It exists on the NEUTRAL seam so the shared middleware owns the
59 * platform branch once, instead of each host reinventing it.
60 */
61export interface PlatformPrincipal {
62 readonly kind: "platform";
63 readonly organizationId: string;
64 readonly organizationName: string;
65 /** See {@link Principal.organizationSlug}. */
66 readonly organizationSlug?: string;
67 /** The credential's own id, for audit trails — never an acting member. */
68 readonly keyId: string;
69}
70
71/** What `authenticate` resolves: an acting member, or the org-level platform
72 * credential. */
73export type ResolvedPrincipal = Principal | PlatformPrincipal;
74
75export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal =>
76 "kind" in value && value.kind === "platform";
77
48/**78/**
49 * The single `AuthContext` every executor-API handler reads. The roles-bearing79 * The single `AuthContext` every executor-API handler reads. The roles-bearing
50 * tag from self-host is the model; cloud now provides `roles: []` on it, which80 * tag from self-host is the model; cloud now provides `roles: []` on it, which
51 * is forward-compatible (cloud handlers never read roles today).81 * is forward-compatible (cloud handlers never read roles today).
82 *
83 * `accountId` is `null` for an org-level platform credential — there is no
84 * acting member behind it. Nullable rather than a sentinel so any handler that
85 * needs a member has to say so (and refuse), instead of silently acting as a
86 * user that does not exist.
52 */87 */
53export class AuthContext extends Context.Service<88export class AuthContext extends Context.Service<
54 AuthContext,89 AuthContext,
55 {90 {
56 readonly accountId: string;91 readonly accountId: string | null;
57 readonly organizationId: string;92 readonly organizationId: string;
58 readonly email: string;93 readonly email: string;
59 readonly name: string | null;94 readonly name: string | null;
@@ -72,6 +107,16 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser · 12 unmodified lines
72 roles: principal.roles,107 roles: principal.roles,
73});108});
74 109
110/** The platform credential's `AuthContext`: org identity, no member fields. */
111export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({
112 accountId: null,
113 organizationId: principal.organizationId,
114 email: "",
115 name: null,
116 avatarUrl: null,
117 roles: [],
118});
119
75// Optional per-failure render hints. Self-host produces the bare error (these120// Optional per-failure render hints. Self-host produces the bare error (these
76// stay `undefined`) and its text strategy renders a generic body. Cloud fills121// stay `undefined`) and its text strategy renders a generic body. Cloud fills
77// `code` + `message` so its failure strategy can reproduce the exact122// `code` + `message` so its failure strategy can reproduce the exact
@@ -110,6 +155,21 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 32 unmodified lines
110 { httpApiStatus: 503 },155 { httpApiStatus: 503 },
111) {}156) {}
112 157
158/**
159 * A valid PLATFORM credential (org-scoped API key) attempted something only an
160 * acting member can do: a write on the product plane, or any request on a host
161 * that serves no platform view (local's fixed executor). Renders 403.
162 *
163 * Its own tag rather than a reuse of `NoOrganization`: the caller DID present a
164 * valid credential of a known org — telling them "no organization" would send
165 * them debugging the wrong thing. The message names the actual constraint.
166 */
167export class ReadOnlyCredential extends Schema.TaggedErrorClass<ReadOnlyCredential>()(
168 "ReadOnlyCredential",
169 renderHints,
170 { httpApiStatus: 403 },
171) {}
172
113/**173/**
114 * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud)174 * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud)
115 * and Better Auth (self-host) are interchangeable implementations; nothing175 * and Better Auth (self-host) are interchangeable implementations; nothing
@@ -126,10 +186,10 @@ export class Unavailable extends Schema.TaggedErrorClass<Unavailable>()( · 10 unmodified lines
126 * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d186 * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d
127 * INSIDE the impl so they surface as 500 defects, never as this error channel.187 * INSIDE the impl so they surface as 500 defects, never as this error channel.
128 */188 */
129export type IdentityFailure = Unauthorized | NoOrganization | Unavailable;189export type IdentityFailure = Unauthorized | NoOrganization | Unavailable | ReadOnlyCredential;
130 190
131export interface IdentityProviderShape {191export interface IdentityProviderShape {
132 readonly authenticate: (request: Request) => Effect.Effect<Principal, IdentityFailure>;192 readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, IdentityFailure>;
133}193}
134 194
135export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()(195export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()(