Unified · Split
| @@ -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 carrying | 129 | // 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 | ||
| @@ -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"; |
| 41 | import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server"; | 41 | import type { |
| 42 | FailureRenderingStrategy, | ||
| 43 | IdentityFailure, | ||
| 44 | PlatformPrincipal, | ||
| 45 | Principal, | ||
| 46 | ResolvedPrincipal, | ||
| 47 | } from "@executor-js/api/server"; | ||
| 42 | 48 | ||
| 43 | import { ApiKeyService } from "./api-keys"; | 49 | import { ApiKeyService } from "./api-keys"; |
| 44 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | 50 | import { 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. | ||
| 110 | const 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 access | 113 | // 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 an | 258 | * 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 are | 259 | * 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 an | 260 | * middleware routes it to the subject-less, read-only platform executor |
| 263 | * org key — those belong at the `/admin/*` mount instead. (Kept the historical | 261 | * (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 | */ |
| 266 | export const resolveApiKeyPrincipal = ( | 269 | export 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 | UserStoreService | 275 | 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 | UserStoreService | 346 | 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 | }; |
| @@ -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`. |
| 71 | const requireAdmin = Effect.gen(function* () { | 71 | const 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") { |
| @@ -17,6 +17,11 @@ import { Forbidden, OrgHttpApi } from "./api"; · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | const requireAdmin = Effect.gen(function* () { | 18 | const 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") { |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Effect, Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; | 2 | import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from "better-auth/plugins"; |
| 3 | 3 | ||
| 4 | import { IdentityProvider } from "@executor-js/api/server"; | 4 | import { IdentityProvider, isPlatformPrincipal } from "@executor-js/api/server"; |
| 5 | import { | 5 | import { |
| 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's | 235 | /** (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 | ||
| @@ -0,0 +1,196 @@ | |||
| 1 | import { mkdtempSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | |||
| 5 | import { afterAll, beforeAll, expect, test } from "@effect/vitest"; | ||
| 6 | |||
| 7 | process.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 | |||
| 30 | let handler!: (request: Request) => Promise<Response>; | ||
| 31 | let dispose: () => Promise<void> = async () => {}; | ||
| 32 | |||
| 33 | beforeAll(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 | }); | ||
| 41 | afterAll(() => dispose()); | ||
| 42 | |||
| 43 | const ORG = "platform-org"; | ||
| 44 | const MEMBER = "member-user"; | ||
| 45 | |||
| 46 | const 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 | |||
| 61 | const memberHeaders: Record<string, string> = { | ||
| 62 | "x-test-user": MEMBER, | ||
| 63 | "x-test-org": ORG, | ||
| 64 | "content-type": "application/json", | ||
| 65 | }; | ||
| 66 | |||
| 67 | const 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. */ | ||
| 74 | beforeAll(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 | |||
| 113 | test("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 | |||
| 125 | test("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 | |||
| 144 | test("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 | |||
| 180 | test("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 | }); | ||
| @@ -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 | }; |
| @@ -48,6 +48,7 @@ export { RouterConfigLive } from "./server/router-config"; · 47 unmodified lines | |||
| 48 | export { consoleErrorCapture } from "./server/console-error-capture"; | 48 | export { consoleErrorCapture } from "./server/console-error-capture"; |
| 49 | export { | 49 | export { |
| 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"; |
| @@ -36,8 +36,10 @@ · 35 unmodified lines | |||
| 36 | 36 | ||
| 37 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 37 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 38 | import { Context, Effect, Layer } from "effect"; | 38 | import { Context, Effect, Layer } from "effect"; |
| 39 | import type * as Cause from "effect/Cause"; | ||
| 39 | 40 | ||
| 40 | import type { AnyPlugin } from "@executor-js/sdk"; | 41 | import type { AnyPlugin } from "@executor-js/sdk"; |
| 42 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 41 | 43 | ||
| 42 | import type { DbProvider } from "./executor-fuma-db"; | 44 | import type { DbProvider } from "./executor-fuma-db"; |
| 43 | import { | 45 | import { |
| @@ -49,13 +51,17 @@ import { · 5 unmodified lines | |||
| 49 | import { ExecutionEngineService, ExecutorService } from "../services"; | 51 | import { ExecutionEngineService, ExecutorService } from "../services"; |
| 50 | import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; | 52 | import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; |
| 51 | import { | 53 | import { |
| 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"; |
| 57 | import { | 62 | import { |
| 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 | */ |
| 70 | export interface FailureRenderingStrategy<E, RR = never> { | 76 | export 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-specific | 130 | * 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 static | 233 | // The public origin the caller actually hit, so a host with no static |
| 174 | // web base URL (a Worker) derives one zero-config. An explicit | 234 | // 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 an | 269 | // `renderFailure` yields either the resolved principal (proceed) or an |
| 210 | // already-built `HttpServerResponse` (the strategy recovered the failure). A | 270 | // already-built `HttpServerResponse` (the strategy recovered the failure). |
| 211 | // `Principal` is a plain object with `accountId`; a response is tagged. Discern | 271 | // Discern by the marker the response framework brands its values with. |
| 212 | // by the marker the response framework brands its values with. | ||
| 213 | const isPrincipal = ( | 272 | const 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 | */ | ||
| 284 | const 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 | ||
| 217 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); | 297 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| 218 | 298 | ||
| @@ -35,7 +35,12 @@ import { · 34 unmodified lines | |||
| 35 | } from "@executor-js/execution"; | 35 | } from "@executor-js/execution"; |
| 36 | 36 | ||
| 37 | import { DbProvider } from "./executor-fuma-db"; | 37 | import { DbProvider } from "./executor-fuma-db"; |
| 38 | import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor"; | 38 | import { |
| 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 the | 46 | // 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 | |||
| 158 | export 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 | ); | ||
| @@ -69,7 +69,12 @@ import { · 68 unmodified lines | |||
| 69 | type FailureRenderingStrategy, | 69 | type FailureRenderingStrategy, |
| 70 | } from "./execution-stack-middleware"; | 70 | } from "./execution-stack-middleware"; |
| 71 | import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware"; | 71 | import { makeFixedExecutionMiddleware, FixedExecutionProvider } from "./fixed-execution-middleware"; |
| 72 | import { IdentityProvider, type IdentityFailure, type Principal } from "./identity"; | 72 | import { |
| 73 | IdentityProvider, | ||
| 74 | type IdentityFailure, | ||
| 75 | type Principal, | ||
| 76 | type ResolvedPrincipal, | ||
| 77 | } from "./identity"; | ||
| 73 | import { | 78 | import { |
| 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` |
| @@ -35,7 +35,14 @@ import type { ExecutionEngine } from "@executor-js/execution"; · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | import { ExecutionEngineService, ExecutorService } from "../services"; | 36 | import { ExecutionEngineService, ExecutorService } from "../services"; |
| 37 | import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; | 37 | import { providePluginExtensions, type PluginExtensionServices } from "../plugin-routes"; |
| 38 | import { authContextFromPrincipal, AuthContext, type Principal } from "./identity"; | 38 | import { |
| 39 | authContextFromPrincipal, | ||
| 40 | AuthContext, | ||
| 41 | isPlatformPrincipal, | ||
| 42 | ReadOnlyCredential, | ||
| 43 | type Principal, | ||
| 44 | type ResolvedPrincipal, | ||
| 45 | } from "./identity"; | ||
| 39 | import type { FailureRenderingStrategy } from "./execution-stack-middleware"; | 46 | import 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 credential | 74 | * Resolve the inbound web `Request` to a neutral `Principal`. The credential |
| 68 | * shape stays inside this function; local's single-user provider always | 75 | * 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)); |
| @@ -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 | */ | ||
| 61 | export 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. */ | ||
| 73 | export type ResolvedPrincipal = Principal | PlatformPrincipal; | ||
| 74 | |||
| 75 | export 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-bearing | 79 | * 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, which | 80 | * 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 | */ |
| 53 | export class AuthContext extends Context.Service< | 88 | export 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. */ | ||
| 111 | export 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 (these | 120 | // Optional per-failure render hints. Self-host produces the bare error (these |
| 76 | // stay `undefined`) and its text strategy renders a generic body. Cloud fills | 121 | // stay `undefined`) and its text strategy renders a generic body. Cloud fills |
| 77 | // `code` + `message` so its failure strategy can reproduce the exact | 122 | // `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 | */ | ||
| 167 | export 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; nothing | 175 | * 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`d | 186 | * 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 | */ |
| 129 | export type IdentityFailure = Unauthorized | NoOrganization | Unavailable; | 189 | export type IdentityFailure = Unauthorized | NoOrganization | Unavailable | ReadOnlyCredential; |
| 130 | 190 | ||
| 131 | export interface IdentityProviderShape { | 191 | export interface IdentityProviderShape { |
| 132 | readonly authenticate: (request: Request) => Effect.Effect<Principal, IdentityFailure>; | 192 | readonly authenticate: (request: Request) => Effect.Effect<ResolvedPrincipal, IdentityFailure>; |
| 133 | } | 193 | } |
| 134 | 194 | ||
| 135 | export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()( | 195 | export class IdentityProvider extends Context.Service<IdentityProvider, IdentityProviderShape>()( |