diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index cbbb406aa0..6027133349 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -89,6 +89,7 @@ describe("protected API key auth", () => { ); expect(identity).toEqual({ + kind: "member", accountId: "user_123", organizationId: "org_123", organizationName: "Org org_123", diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index 2fa85315e6..2120c9004c 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -110,6 +110,7 @@ describe("protected JWT (device-login) auth", () => { const identity = yield* run(request(token), config); expect(identity).toEqual({ + kind: "member", accountId: "user_123", organizationId: "org_123", organizationName: "Org org_123", diff --git a/apps/cloud/src/auth/org-api-key-auth.node.test.ts b/apps/cloud/src/auth/org-api-key-auth.node.test.ts index ad4b60e088..1d1e00b9ad 100644 --- a/apps/cloud/src/auth/org-api-key-auth.node.test.ts +++ b/apps/cloud/src/auth/org-api-key-auth.node.test.ts @@ -124,19 +124,25 @@ describe("org-level API keys", () => { }), ); - it.effect("are REJECTED on the product surface rather than bound to a subject", () => + it.effect("resolve on the product surface as a platform principal, never a subject", () => Effect.gen(function* () { - // THE security property of the api-key half: a product request carrying - // an org key must fail, not silently act as some user. - const error = yield* Effect.flip( - resolveApiKeyPrincipal(bearer("valid_org_key")).pipe(Effect.provide(layers)), + // THE security property of the api-key half, updated for platform reads: + // a product request carrying an org key resolves to the NEUTRAL platform + // shape — which the shared middleware routes to the subject-less, + // GET-only platform executor — and must never carry an accountId a + // handler could bind as an acting member. + const principal = yield* resolveApiKeyPrincipal(bearer("valid_org_key")).pipe( + Effect.provide(layers), ); - expect(error).toMatchObject({ - _tag: "Unauthorized", - code: "invalid_api_key", - message: "Organization API keys cannot be used on this endpoint", + expect(principal).toEqual({ + kind: "platform", + organizationId: "org_123", + organizationName: "Org org_123", + organizationSlug: "org-slug-org_123", + keyId: "api_key_org", }); + expect(principal).not.toHaveProperty("accountId"); }), ); diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 207608a9ad..95742038f3 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -38,7 +38,13 @@ import { Unauthorized, Unavailable, } from "@executor-js/api/server"; -import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; +import type { + FailureRenderingStrategy, + IdentityFailure, + PlatformPrincipal, + Principal, + ResolvedPrincipal, +} from "@executor-js/api/server"; import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; @@ -104,14 +110,6 @@ const NO_ORGANIZATION_IN_ACCESS_TOKEN = { code: "no_organization", message: "No organization in access token", }; -// An org-level key resolves to the PLATFORM view, which has no acting member. -// The product endpoints are bound to one subject, so they reject it outright -// rather than inventing a subject for it to act as. -const ORG_KEY_ON_PRODUCT_SURFACE = { - code: "invalid_api_key", - message: "Organization API keys cannot be used on this endpoint", -}; - // A bearer value with three dot-separated segments is a JWT (a WorkOS access // token from the CLI device-login); anything else is treated as an API key. // Same discriminator the MCP plane uses (`mcp/auth.ts`). @@ -147,6 +145,7 @@ const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) => if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN); return { + kind: "member", accountId: verified.accountId, organizationId: org.id, organizationName: org.name, @@ -245,6 +244,7 @@ export const resolveBearerAuth = ( if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); return { + kind: "member", accountId: owner.accountId, organizationId: org.id, organizationName: org.name, @@ -257,23 +257,36 @@ export const resolveBearerAuth = ( }); /** - * The PRODUCT-view bearer resolver: as {@link resolveBearerAuth}, but an - * org-level key is REJECTED rather than downgraded. The product endpoints are - * bound to one acting subject, so there is no honest way to serve them an - * org key — those belong at the `/admin/*` mount instead. (Kept the historical - * name; the re-export and resolver tests reference it.) + * The PRODUCT-plane bearer resolver. An org-level key resolves to the neutral + * seam's {@link PlatformPrincipal} — NOT a member `Principal` — and the shared + * middleware routes it to the subject-less, read-only platform executor + * (refusing non-GET up front). Previously the product plane rejected org keys + * outright; serving tenant-level READS to them is deliberate: the catalog, + * tools, policies, and org-owned connection listings are tenant-shared answers + * a machine credential can honestly receive, while everything subject-bound + * stays structurally out of reach (a platform executor binds no subject, so no + * member's personal rows resolve). (Kept the historical name; the re-export and + * resolver tests reference it.) */ export const resolveApiKeyPrincipal = ( request: Request, jwt: JwtBearerConfig | null = null, ): Effect.Effect< - Principal | null, + ResolvedPrincipal | null, Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, WorkOSClient | ApiKeyService | UserStoreService > => Effect.gen(function* () { const auth = yield* resolveBearerAuth(request, jwt); - if (isPlatformAuth(auth)) return yield* new Unauthorized(ORG_KEY_ON_PRODUCT_SURFACE); + if (isPlatformAuth(auth)) { + return { + kind: "platform", + organizationId: auth.organizationId, + organizationName: auth.organizationName, + ...(auth.organizationSlug === undefined ? {} : { organizationSlug: auth.organizationSlug }), + keyId: auth.keyId, + } satisfies PlatformPrincipal; + } return auth; }); @@ -304,6 +317,7 @@ export const resolveSessionPrincipal = (request: Request) => const org = yield* authorizeOrganizationSelector(session.userId, selector); if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); return { + kind: "member", accountId: session.userId, organizationId: org.id, organizationName: org.name, @@ -330,7 +344,7 @@ export const resolveProtectedPrincipal = ( request: Request, jwt: JwtBearerConfig | null = null, ): Effect.Effect< - Principal, + ResolvedPrincipal, Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, WorkOSClient | ApiKeyService | UserStoreService > => @@ -411,6 +425,11 @@ export const cloudIdentityFailureStrategy: FailureRenderingStrategy // Mirrors `org/handlers.ts` `requireAdmin`. const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; + if (auth.accountId === null) return yield* new Forbidden(); const workos = yield* WorkOSClient; const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!current || current.role?.slug !== "admin") { diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 37b26cf6ac..b338eee3f7 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; + // This plane is mounted behind the session-only `orgAuthMiddleware`, so the + // caller is always a member — but `AuthContext.accountId` is nullable for the + // platform credential, and membership of "no member" is not a question worth + // asking WorkOS. Refuse rather than assert. + if (auth.accountId === null) return yield* new Forbidden(); const workos = yield* WorkOSClient; const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!currentMembership || currentMembership.role?.slug !== "admin") { diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 3a206bb3b3..7590245d40 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -38,6 +38,7 @@ export const principalFromAccessClaims = ( const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); return { + kind: "member", accountId: sub || email || commonName, organizationId: config.organizationId, organizationName: config.organizationName, @@ -68,6 +69,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { // fixed admin. Only when explicitly enabled (and the instance is otherwise // unprotected). Mirrors the local app's single-user model. const devPrincipal: Principal = { + kind: "member", accountId: "dev", organizationId: config.organizationId, organizationName: config.organizationName, diff --git a/apps/host-selfhost/src/auth/identity.ts b/apps/host-selfhost/src/auth/identity.ts index 6c86fd9de0..932e90230c 100644 --- a/apps/host-selfhost/src/auth/identity.ts +++ b/apps/host-selfhost/src/auth/identity.ts @@ -67,6 +67,7 @@ export const betterAuthIdentityLayer: Layer.Layer null)); /** (b) The existing cookie / bearer-session / x-api-key path. The fallback's - * api `Principal` shape is byte-identical to host-mcp's `Principal`. */ + * api `Principal` shape is byte-identical to host-mcp's `Principal`. The + * neutral seam can also resolve a platform credential, which self-host's + * identity never produces — narrowed away rather than asserted, so an MCP + * session can never bind to a subject-less credential if that changes. */ const authenticateSession = (request: Request): Effect.Effect => fallback.authenticate(request).pipe( + Effect.map((principal) => (isPlatformPrincipal(principal) ? null : principal)), Effect.catchTags({ Unauthorized: () => Effect.succeed(null), NoOrganization: () => Effect.succeed(null), + ReadOnlyCredential: () => Effect.succeed(null), }), ); diff --git a/apps/host-selfhost/src/platform-credential.test.ts b/apps/host-selfhost/src/platform-credential.test.ts new file mode 100644 index 0000000000..97244cb52a --- /dev/null +++ b/apps/host-selfhost/src/platform-credential.test.ts @@ -0,0 +1,210 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-platform-")); + +// --------------------------------------------------------------------------- +// The shared middleware's PLATFORM branch, end-to-end over the real router: an +// org-level credential (the shape cloud's org-scoped API key resolves to) gets +// tenant-level READS on the ordinary product paths, and nothing else. +// +// Served through the self-host test app because it runs the SAME +// `makeExecutionStackMiddleware` cloud's protected plane uses — the branch +// under test is host-neutral, and this is the harness that can drive it +// without WorkOS. The `x-test-platform-org` header resolves the platform +// principal (see testing/test-app.ts). +// +// Pinned here: +// 1. GET /api/integrations answers the tenant catalog — the read that used +// to require a second, member credential (the customer-reported gap); +// 2. GET /api/connections answers org-owned rows WITHOUT any member's +// personal connections (bound reach, no subject); +// 3. any non-GET is refused 403 before a handler runs; +// 4. the credential mints NO subject row — an org key must never appear as a +// user of the workspace it observes. +// --------------------------------------------------------------------------- + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); + const app = await makeSelfHostTestApp({ + identity: headerIdentityLayer, + }); + handler = app.handler; + dispose = app.dispose; +}); +afterAll(() => dispose()); + +const ORG = "platform-org"; +const MEMBER = "member-user"; + +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const memberHeaders: Record = { + "x-test-user": MEMBER, + "x-test-org": ORG, + "content-type": "application/json", +}; + +const platformHeaders: Record = { + "x-test-platform-org": ORG, + "content-type": "application/json", +}; + +/** Seed as the MEMBER: a catalog row, an org-owned connection, and a personal + * one — so the platform reads below have both a hit and a must-miss. */ +beforeAll(async () => { + const add = await handler( + new Request("http://localhost/api/openapi/specs", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ spec: { kind: "blob", value: TINY_SPEC }, slug: "cat", baseUrl: "" }), + }), + ); + expect(add.status).toBe(200); + const orgConn = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ + owner: "org", + name: "shared", + integration: "cat", + template: "bearer", + value: "org-shared-token", + }), + }), + ); + expect(orgConn.status).toBe(200); + const userConn = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: memberHeaders, + body: JSON.stringify({ + owner: "user", + name: "personal", + integration: "cat", + template: "bearer", + value: "member-personal-token", + }), + }), + ); + expect(userConn.status).toBe(200); +}); + +test("a platform credential reads the tenant catalog on the ordinary product path", async () => { + const res = await handler( + new Request("http://localhost/api/integrations", { headers: platformHeaders }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReadonlyArray<{ readonly slug: string }>; + expect( + body.map((integration) => integration.slug), + "the catalog the member configured is readable with the org credential alone", + ).toContain("cat"); +}); + +test("connection reads answer org-owned rows and never a member's personal ones", async () => { + const res = await handler( + new Request("http://localhost/api/connections", { headers: platformHeaders }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ReadonlyArray<{ readonly name: string }>; + const raw = JSON.stringify(body); + expect( + body.map((connection) => connection.name), + "the org-owned connection is visible", + ).toContain("shared"); + expect( + body.map((connection) => connection.name), + "a member's personal connection is not — the platform view binds no subject", + ).not.toContain("personal"); + expect(raw, "no credential material either way").not.toContain("token"); +}); + +test("the OAuth callback is refused despite being a GET", async () => { + // The one core GET with side effects: completing it would burn an org-owned + // in-flight authorization code in an outbound token exchange before the + // storage policy could refuse the final write. The safe-request gate excludes + // it by name; a platform credential landing here is a clear 403, not a + // half-executed flow. + const res = await handler( + new Request("http://localhost/api/oauth/callback?code=x&state=y", { + headers: platformHeaders, + }), + ); + expect(res.status, "the callback is not a safe read").toBe(403); + expect(await res.text()).toContain("read-only"); +}); + +test("every non-GET is refused before a handler runs", async () => { + const post = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: platformHeaders, + body: JSON.stringify({ + owner: "org", + name: "sneaky", + integration: "cat", + template: "bearer", + value: "nope", + }), + }), + ); + expect(post.status, "a write with a read-only credential is a clear 403").toBe(403); + expect(await post.text()).toContain("read-only"); + + const del = await handler( + new Request("http://localhost/api/integrations/cat", { + method: "DELETE", + headers: platformHeaders, + }), + ); + expect(del.status).toBe(403); + + // The refusal happened before any handler: the connection it tried to write + // does not exist. + const after = await handler( + new Request("http://localhost/api/connections", { headers: platformHeaders }), + ); + const names = ((await after.json()) as ReadonlyArray<{ readonly name: string }>).map( + (connection) => connection.name, + ); + expect(names).not.toContain("sneaky"); +}); + +test("the credential never mints a subject row in the workspace it observes", async () => { + // Several platform reads have run by now. If any of them touched the subject + // table, the org would report a phantom user; only the seeding member may + // appear. + const { withQueryContext } = await import("@executor-js/fumadb/query"); + const { createSelfHostDb } = await import("./db/self-host-db"); + const { loadConfig } = await import("./config"); + const db = await createSelfHostDb({ path: loadConfig().dbPath }); + const rows = await withQueryContext(db.db, { tenant: ORG, subject: null }).findMany("subject", { + orderBy: ["external_id", "asc"], + }); + await db.close(); + expect( + rows.map((row) => String(row.external_id)), + "only the member has a footprint", + ).toEqual([MEMBER]); +}); diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index ddc20bbc80..2261de31c8 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -5,8 +5,10 @@ import { composePluginApi, ExecutorApp, IdentityProvider, + isPlatformPrincipal, PluginsProvider, type Principal, + type ResolvedPrincipal, textFailureStrategy, Unauthorized, } from "@executor-js/api/server"; @@ -79,6 +81,7 @@ export const singleAdminIdentityLayer = ( IdentityProvider.of({ authenticate: () => Effect.succeed({ + kind: "member", accountId: options.userId, organizationId: options.organizationId, organizationName: options.organizationName, @@ -98,10 +101,24 @@ export const headerIdentityLayer: Layer.Layer = Layer.succeed( IdentityProvider, IdentityProvider.of({ authenticate: (request) => { + // `x-test-platform-org` resolves the org-level PLATFORM credential — the + // neutral shape cloud's org-scoped API key produces — so the shared + // middleware's platform branch is testable against this harness even + // though self-host's real identity never mints one. + const platformOrg = request.headers.get("x-test-platform-org"); + if (platformOrg) { + return Effect.succeed({ + kind: "platform", + organizationId: platformOrg, + organizationName: `Org ${platformOrg}`, + keyId: "test_platform_key", + }); + } const userId = request.headers.get("x-test-user"); const organizationId = request.headers.get("x-test-org"); if (!userId || !organizationId) return Effect.fail(new Unauthorized()); return Effect.succeed({ + kind: "member", accountId: userId, organizationId, organizationName: `Org ${organizationId}`, @@ -143,12 +160,17 @@ const stubMcpAuth: Layer.Layer = Layer resourceMetadataUrl: resourceMetadataUrlFor, authenticate: (request: Request): Effect.Effect => fallback.authenticate(request).pipe( + // The test identity never resolves a platform credential; narrow it + // away (as the production selfHostMcpAuth does) rather than asserting. Effect.map((principal) => - principal ? authenticated(principal) : unauthorized(challengeFor(request)), + principal && !isPlatformPrincipal(principal) + ? authenticated(principal) + : unauthorized(challengeFor(request)), ), Effect.catchTags({ Unauthorized: () => Effect.succeed(unauthorized(challengeFor(request))), NoOrganization: () => Effect.succeed(unauthorized(challengeFor(request))), + ReadOnlyCredential: () => Effect.succeed(unauthorized(challengeFor(request))), }), ), }; diff --git a/apps/local/src/identity.ts b/apps/local/src/identity.ts index 562d96dc7a..213f53c3dc 100644 --- a/apps/local/src/identity.ts +++ b/apps/local/src/identity.ts @@ -31,6 +31,7 @@ import { safeEqual } from "./serve-shared"; * cwd-derived (in `app.ts`), independent of these ids. */ export const LOCAL_PRINCIPAL: Principal = { + kind: "member", accountId: "local", organizationId: "local", organizationName: "Local", diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index d252e1a74b..104d841c39 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -48,6 +48,7 @@ export { RouterConfigLive } from "./server/router-config"; export { consoleErrorCapture } from "./server/console-error-capture"; export { makeExecutionStack, + makePlatformExecutionStack, CodeExecutorProvider, EngineDecorator, EngineDecoratorNoop, @@ -95,8 +96,13 @@ export { Unauthorized, NoOrganization, Unavailable, + ReadOnlyCredential, authContextFromPrincipal, + authContextFromPlatform, + isPlatformPrincipal, type Principal, + type PlatformPrincipal, + type ResolvedPrincipal, type IdentityProviderShape, type IdentityFailure, } from "./server/identity"; diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index 66c55866c8..b3e6749b83 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -35,9 +35,11 @@ // --------------------------------------------------------------------------- import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Context, Effect, Layer } from "effect"; +import { Context, Data, Effect, Layer } from "effect"; +import type * as Cause from "effect/Cause"; import type { AnyPlugin } from "@executor-js/sdk"; +import type { ExecutionEngine } from "@executor-js/execution"; import type { DbProvider } from "./executor-fuma-db"; import { @@ -49,13 +51,17 @@ import { import { ExecutionEngineService, ExecutorService } from "../services"; import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; import { + authContextFromPlatform, authContextFromPrincipal, AuthContext, + isPlatformPrincipal, + ReadOnlyCredential, type IdentityFailure, - type Principal, + type ResolvedPrincipal, } from "./identity"; import { makeExecutionStack, + makePlatformExecutionStack, type CodeExecutorProvider, type EngineDecorator, } from "./execution-stack"; @@ -68,9 +74,9 @@ import { * residual requirement the strategy adds (always `never` in practice). */ export interface FailureRenderingStrategy { - readonly renderFailure: ( - effect: Effect.Effect, - ) => Effect.Effect; + readonly renderFailure: ( + effect: Effect.Effect, + ) => Effect.Effect; } /** @@ -98,6 +104,15 @@ export const textFailureStrategy: FailureRenderingStrategy = { status: 503, }), ), + // Self-host/local identity never resolves a platform credential, but the + // method gate raises this tag on the SHARED channel, so total coverage + // requires rendering it (and keeps the strategy honest if that changes). + ReadOnlyCredential: () => + Effect.succeed( + HttpServerResponse.text("Organization API keys are read-only", { + status: 403, + }), + ), }), ), }; @@ -112,12 +127,14 @@ export interface MakeExecutionStackMiddlewareOptions< /** The host's plugin tuple — drives the typed extension Services and binding. */ readonly plugins: TPlugins; /** - * Resolve the inbound web `Request` to a neutral `Principal`. Adapter-specific - * credential precedence stays inside this function. + * Resolve the inbound web `Request` to a neutral `Principal` — or, for an + * org-level credential (cloud's org-scoped API key), a `PlatformPrincipal` + * that the middleware routes to the read-only platform branch. + * Adapter-specific credential precedence stays inside this function. */ - readonly authenticate: (request: Request) => Effect.Effect; + readonly authenticate: (request: Request) => Effect.Effect; /** Render `authenticate` failures (passthrough for cloud, text for self-host). */ - readonly strategy: FailureRenderingStrategy; + readonly strategy: FailureRenderingStrategy; /** The host's `makeExecutionStack` seam Layer. */ readonly stackLayer: Layer.Layer< DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator, @@ -166,9 +183,53 @@ export const makeExecutionStackMiddleware = < Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* HttpServerRequest.toWeb(request); - const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest)); + const resolved = yield* options.strategy.renderFailure( + options.authenticate(webRequest).pipe( + // The PLATFORM branch's gate lives here, before any handler: an + // org credential is read-only by construction, so anything that + // is not a safe read is refused as a typed 403 rather than + // reaching a handler that would bind it to a subject or attempt + // a write (the storage policy would refuse the write anyway — + // this makes the refusal a clear response instead of a 500). + Effect.filterOrFail( + (principal) => + !isPlatformPrincipal(principal) || + isPlatformSafeRequest(webRequest.method, new URL(webRequest.url).pathname), + () => + new ReadOnlyCredential({ + code: "read_only_credential", + message: "Organization API keys are read-only", + }), + ), + ), + ); // The strategy recovered the failure into a Response — return it. if (!isPrincipal(resolved)) return resolved; + + if (isPlatformPrincipal(resolved)) { + // Org credential: the subject-less, write-refusing platform stack. + // No `touchSubject` runs, so the credential never mints a phantom + // user row in the very lists the admin plane serves. Neither + // `RequestWebOrigin` nor `RequestOrgSlug` is provided: both feed + // browser-handoff URL construction, which only interactive member + // flows perform, and `makePlatformExecutor` reads neither. + const { executor } = yield* makePlatformExecutionStack( + resolved.organizationId, + ).pipe( + Effect.provide(options.stackLayer), + Effect.withSpan("executor.stack.http.resolve_platform"), + ); + return yield* httpEffect.pipe( + Effect.provideService(AuthContext, AuthContext.of(authContextFromPlatform(resolved))), + Effect.provideService(ExecutorService, executor), + // The engine runs code as an acting member; the platform view has + // none, and owns no executions. GET readers get the honest empty + // answers; the execute/resume paths sit behind the method gate + // above and are unreachable. + Effect.provideService(ExecutionEngineService, readOnlyExecutionEngine), + provideExecutorExtensions(executor), + ); + } const auth = AuthContext.of(authContextFromPrincipal(resolved)); // The public origin the caller actually hit, so a host with no static // web base URL (a Worker) derives one zero-config. An explicit @@ -206,13 +267,57 @@ export const makeExecutionStackMiddleware = < ); }; -// `renderFailure` yields either the resolved `Principal` (proceed) or an -// already-built `HttpServerResponse` (the strategy recovered the failure). A -// `Principal` is a plain object with `accountId`; a response is tagged. Discern -// by the marker the response framework brands its values with. +// `renderFailure` yields either the resolved principal (proceed) or an +// already-built `HttpServerResponse` (the strategy recovered the failure). +// Discern by the marker the response framework brands its values with. const isPrincipal = ( - value: Principal | HttpServerResponse.HttpServerResponse, -): value is Principal => !HttpServerResponse.isHttpServerResponse(value); + value: ResolvedPrincipal | HttpServerResponse.HttpServerResponse, +): value is ResolvedPrincipal => !HttpServerResponse.isHttpServerResponse(value); + +/** + * Whether a request is a SAFE READ the platform branch may serve. The method + * check (GET, plus HEAD — the router serves HEAD off the GET route) is + * necessary but NOT sufficient: `GET /oauth/callback` is the one core GET with + * side effects — completing it reads an org-owned oauth session, performs an + * outbound authorization-code exchange with the org's client credentials, and + * then attempts the connection write. A read-only credential must be refused + * BEFORE that exchange burns the in-flight code, not after the storage policy + * rejects the final write, so the callback path is excluded by name. It is a + * browser-redirect surface — no legitimate machine-credential caller lands on + * it with a Bearer header. + */ +const isPlatformSafeRequest = (method: string, pathname: string): boolean => + (method === "GET" || method === "HEAD") && !pathname.endsWith("/oauth/callback"); + +/** Reaching an engine member the platform branch cannot honestly serve is a + * wiring bug (the safe-request gate keeps those paths unreachable), so it + * dies as a tagged defect rather than failing with a typed error a client + * could mistake for a product answer. */ +class PlatformEngineUnavailable extends Data.TaggedError("PlatformEngineUnavailable")<{ + readonly member: string; +}> {} + +/** + * The engine the platform branch provides. The one member a safe read can + * actually reach — `getPausedExecution`, via `GET /executions/:id` — answers + * null (a 404), honestly: the platform view owns no executions. The paused + * counters answer the same empty story for any future reader. Everything else + * (execute, resume, the MCP tool description) exists to satisfy the service + * shape, sits behind the middleware's safe-request gate, and dies if reached. + */ +const readOnlyExecutionEngine: ExecutionEngine = { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome + execute: () => Effect.die(new PlatformEngineUnavailable({ member: "execute" })), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome + executeWithPause: () => Effect.die(new PlatformEngineUnavailable({ member: "executeWithPause" })), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: unreachable behind the middleware's safe-request gate; reaching it is a wiring bug, not a typed product outcome + resume: () => Effect.die(new PlatformEngineUnavailable({ member: "resume" })), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: only the MCP tool server reads this, and the MCP plane never serves a platform credential + getDescription: Effect.die(new PlatformEngineUnavailable({ member: "getDescription" })), +}; const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index 1ebebf2cec..eb7756a51d 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -35,7 +35,12 @@ import { } from "@executor-js/execution"; import { DbProvider } from "./executor-fuma-db"; -import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor"; +import { + HostConfig, + PluginsProvider, + makePlatformExecutor, + makeScopedExecutor, +} from "./scoped-executor"; // --------------------------------------------------------------------------- // CodeExecutorProvider seam — the host's code-execution substrate. Typed to the @@ -139,3 +144,30 @@ export const makeExecutionStack = < ).pipe(Effect.withSpan("executor.stack.engine.init")); return { executor, engine }; }).pipe(Effect.withSpan("executor.stack.build")); + +// --------------------------------------------------------------------------- +// makePlatformExecutionStack — the org-credential sibling: a subject-less, +// write-refusing executor (`makePlatformExecutor`) and no REAL engine. An org +// key is an observer; the execution engine exists to run code as an acting +// member, so no engine is built here. The middleware still provides +// `ExecutionEngineService` (handlers' service requirements demand one) — as a +// stub whose reachable reads answer "nothing here" and whose execute/resume +// members sit behind the middleware's safe-request gate (see +// `readOnlyExecutionEngine` in ./execution-stack-middleware.ts). +// --------------------------------------------------------------------------- + +export const makePlatformExecutionStack = < + const TPlugins extends readonly AnyPlugin[] = readonly AnyPlugin[], +>( + organizationId: string, +): Effect.Effect< + { readonly executor: Executor }, + StorageFailure, + DbProvider | PluginsProvider | HostConfig +> => + makePlatformExecutor(organizationId).pipe( + // The platform executor is built against the erased plugin set; re-narrow + // via the same phantom cast `makeScopedExecutor` performs for the scoped one. + Effect.map((executor) => ({ executor: executor as Executor })), + Effect.withSpan("executor.stack.platform.build"), + ); diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 96b8c1f7c9..382e72efe1 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -69,7 +69,12 @@ import { type FailureRenderingStrategy, } from "./execution-stack-middleware"; import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware"; -import { IdentityProvider, type IdentityFailure, type Principal } from "./identity"; +import { + IdentityProvider, + type IdentityFailure, + type Principal, + type ResolvedPrincipal, +} from "./identity"; import { makeAccountApiLayer, makeProtectedApiLayer, @@ -418,7 +423,7 @@ export const make = < // `Unauthorized | NoOrganization | Unavailable`. const authenticate = ( request: Request, - ): Effect.Effect => + ): Effect.Effect => Effect.flatMap(IdentityProvider.asEffect(), (provider) => provider.authenticate(request)); // The per-request layer combined into the middleware: cloud's `requestScoped` diff --git a/packages/core/api/src/server/fixed-execution-middleware.ts b/packages/core/api/src/server/fixed-execution-middleware.ts index f8c92e6a16..5fad065cfd 100644 --- a/packages/core/api/src/server/fixed-execution-middleware.ts +++ b/packages/core/api/src/server/fixed-execution-middleware.ts @@ -35,7 +35,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; import { ExecutionEngineService, ExecutorService } from "../services"; import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; -import { authContextFromPrincipal, AuthContext, type Principal } from "./identity"; +import { + authContextFromPrincipal, + AuthContext, + isPlatformPrincipal, + ReadOnlyCredential, + type Principal, + type ResolvedPrincipal, +} from "./identity"; import type { FailureRenderingStrategy } from "./execution-stack-middleware"; /** @@ -66,11 +73,13 @@ export interface MakeFixedExecutionMiddlewareOptions< /** * Resolve the inbound web `Request` to a neutral `Principal`. The credential * shape stays inside this function; local's single-user provider always - * resolves the one local Principal. + * resolves the one local Principal. (The seam's type admits a platform + * credential, but a fixed-executor host has no platform view — one resolving + * here is refused below.) */ - readonly authenticate: (request: Request) => Effect.Effect; + readonly authenticate: (request: Request) => Effect.Effect; /** Render `authenticate` failures (text for local, matching self-host). */ - readonly strategy: FailureRenderingStrategy; + readonly strategy: FailureRenderingStrategy; } /** @@ -108,7 +117,21 @@ export const makeFixedExecutionMiddleware = < Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* HttpServerRequest.toWeb(request); - const resolved = yield* options.strategy.renderFailure(options.authenticate(webRequest)); + const resolved = yield* options.strategy.renderFailure( + options.authenticate(webRequest).pipe( + // The fixed executor binds ONE subject at boot; there is no + // platform view to serve an org credential, so it is refused + // rather than silently acting as the boot subject. + Effect.filterOrFail( + (principal): principal is Principal => !isPlatformPrincipal(principal), + () => + new ReadOnlyCredential({ + code: "read_only_credential", + message: "Organization API keys are not accepted on this host", + }), + ), + ), + ); // The strategy recovered the failure into a Response — return it. if (!isPrincipal(resolved)) return resolved; const auth = AuthContext.of(authContextFromPrincipal(resolved)); diff --git a/packages/core/api/src/server/identity.ts b/packages/core/api/src/server/identity.ts index 0b12fc6f28..04e4f23101 100644 --- a/packages/core/api/src/server/identity.ts +++ b/packages/core/api/src/server/identity.ts @@ -29,6 +29,11 @@ import { Context, Effect, Schema } from "effect"; * resolver already yielded it) AND `roles` (cloud supplies `[]`). */ export interface Principal { + /** Discriminant of {@link ResolvedPrincipal}: an acting member, as opposed + * to the org-level `"platform"` credential. Required so every construction + * site declares which arm it is, and the union matches on a literal tag + * instead of probing for a property's presence. */ + readonly kind: "member"; readonly accountId: string; readonly organizationId: string; readonly organizationName: string; @@ -45,17 +50,54 @@ export interface Principal { readonly roles: readonly string[]; } +/** + * An ORG-level credential (cloud's org-scoped API key), resolved. Deliberately + * NOT a `Principal`: a `Principal` names an acting member, and this credential + * has none — mirroring the resolution layer's own split (`ApiKeyOwner` keeps + * `accountId: string | null` for the same reason). Keeping it a separate shape + * means no code path can accidentally bind an org credential to a subject; the + * middleware routes it to the subject-less platform executor instead, and + * refuses every non-read method before a handler ever runs. + * + * Self-host and local never produce this: their credentials always name a + * member. It exists on the NEUTRAL seam so the shared middleware owns the + * platform branch once, instead of each host reinventing it. + */ +export interface PlatformPrincipal { + readonly kind: "platform"; + readonly organizationId: string; + readonly organizationName: string; + /** See {@link Principal.organizationSlug}. */ + readonly organizationSlug?: string; + /** The credential's own id, for audit trails — never an acting member. */ + readonly keyId: string; +} + +/** What `authenticate` resolves: an acting member, or the org-level platform + * credential. */ +export type ResolvedPrincipal = Principal | PlatformPrincipal; + +export const isPlatformPrincipal = (value: ResolvedPrincipal): value is PlatformPrincipal => + value.kind === "platform"; + /** * The single `AuthContext` every executor-API handler reads. The roles-bearing * tag from self-host is the model; cloud now provides `roles: []` on it, which * is forward-compatible (cloud handlers never read roles today). + * + * `accountId` and `email` are `null` for an org-level platform credential — + * there is no acting member behind it. Nullable rather than sentinel-valued so + * any handler that needs a member has to say so (and refuse), instead of + * silently acting as a user that does not exist. (`email: ""` on the member + * api-key path is the pre-existing "member with no resolved email" value and + * unrelated to this.) */ export class AuthContext extends Context.Service< AuthContext, { - readonly accountId: string; + readonly accountId: string | null; readonly organizationId: string; - readonly email: string; + readonly email: string | null; readonly name: string | null; readonly avatarUrl: string | null; readonly roles: readonly string[]; @@ -72,6 +114,16 @@ export const authContextFromPrincipal = (principal: Principal): AuthContext["Ser roles: principal.roles, }); +/** The platform credential's `AuthContext`: org identity, no member fields. */ +export const authContextFromPlatform = (principal: PlatformPrincipal): AuthContext["Service"] => ({ + accountId: null, + organizationId: principal.organizationId, + email: null, + name: null, + avatarUrl: null, + roles: [], +}); + // Optional per-failure render hints. Self-host produces the bare error (these // stay `undefined`) and its text strategy renders a generic body. Cloud fills // `code` + `message` so its failure strategy can reproduce the exact @@ -110,6 +162,21 @@ export class Unavailable extends Schema.TaggedErrorClass()( { httpApiStatus: 503 }, ) {} +/** + * A valid PLATFORM credential (org-scoped API key) attempted something only an + * acting member can do: a write on the product plane, or any request on a host + * that serves no platform view (local's fixed executor). Renders 403. + * + * Its own tag rather than a reuse of `NoOrganization`: the caller DID present a + * valid credential of a known org — telling them "no organization" would send + * them debugging the wrong thing. The message names the actual constraint. + */ +export class ReadOnlyCredential extends Schema.TaggedErrorClass()( + "ReadOnlyCredential", + renderHints, + { httpApiStatus: 403 }, +) {} + /** * The swap seam. Resolves an incoming request to a `Principal`. WorkOS (cloud) * and Better Auth (self-host) are interchangeable implementations; nothing @@ -126,10 +193,10 @@ export class Unavailable extends Schema.TaggedErrorClass()( * Adapter infra defects (cloud's WorkOS / user-store failures) are `Effect.die`d * INSIDE the impl so they surface as 500 defects, never as this error channel. */ -export type IdentityFailure = Unauthorized | NoOrganization | Unavailable; +export type IdentityFailure = Unauthorized | NoOrganization | Unavailable | ReadOnlyCredential; export interface IdentityProviderShape { - readonly authenticate: (request: Request) => Effect.Effect; + readonly authenticate: (request: Request) => Effect.Effect; } export class IdentityProvider extends Context.Service()( diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cf35efb438..665a412103 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -3585,6 +3585,13 @@ export const createExecutor = [row.slug, row] as const));