From 060e528844be59889fda5a49c9a910d0678d5d99 Mon Sep 17 00:00:00 2001 From: fforres Date: Thu, 8 Oct 2026 17:52:17 -0700 Subject: [PATCH] feat(host-cloudflare): ExecutorInternal service-binding entrypoint Export a WorkerEntrypoint whose fetch serves the same app and MCP routes as the public fetch for workers in the same account that reach it over a service binding. The binding is the trust boundary, so a request carries no Access credentials: it acts as the configured delegator service, or as the subject named in X-Executor-Subject. The trusted mode lives on a config only the entrypoint builds, never on env or a request, so the public fetch cannot enter it. --- apps/host-cloudflare/DELEGATION.md | 20 +++++ apps/host-cloudflare/src/app.ts | 8 +- .../src/auth/cloudflare-access.test.ts | 77 +++++++++++++++- .../src/auth/cloudflare-access.ts | 44 ++++++++++ apps/host-cloudflare/src/config.test.ts | 6 ++ apps/host-cloudflare/src/config.ts | 22 ++++- apps/host-cloudflare/src/worker.ts | 87 ++++++++++++++----- 7 files changed, 232 insertions(+), 32 deletions(-) diff --git a/apps/host-cloudflare/DELEGATION.md b/apps/host-cloudflare/DELEGATION.md index 90a1279e60..f2b9ba4e0b 100644 --- a/apps/host-cloudflare/DELEGATION.md +++ b/apps/host-cloudflare/DELEGATION.md @@ -98,3 +98,23 @@ worker routing, which is a materially larger delta to carry across upstream releases than one pure function. Worth revisiting if this host ever grows a first-class non-Access auth seam, or if upstream adds MCP OAuth over Access (see the note at the end of `src/mcp/auth.ts`, which anticipates exactly that). + +## The service-binding door + +A worker in the same Cloudflare account can skip Access entirely by binding the +`ExecutorInternal` entrypoint: + +```jsonc +"services": [ + { "binding": "EXECUTOR", "service": "executor-cloudflare", "entrypoint": "ExecutorInternal" } +] +``` + +The binding is the trust boundary: only a worker that declares it can reach this +door, and it has no public route. A request through it sends no Access +credentials. Without delegation headers it acts as the service itself, keyed on +`ACCESS_DELEGATION_COMMON_NAME` so it reaches the same rows that service token +reaches through Access. With `X-Executor-Subject` (and `X-Executor-Subject-Email`) +it acts for that person, as a trusted delegator would. The trusted mode lives on a +config only the entrypoint builds (`loadConfig(env, { internal: true })`), so no +header or variable can switch the public `fetch` into it. diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 8c3eebf789..b5f3b00ade 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -3,7 +3,7 @@ import { HttpEffect, HttpRouter } from "effect/unstable/http"; import { dbProviderLayer, ExecutorApp, textFailureStrategy } from "@executor-js/api/server"; -import { loadConfig, type CloudflareEnv } from "./config"; +import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "./config"; import { makeCloudflarePlugins } from "./plugins"; import { createD1ExecutorDb } from "./db/d1"; import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; @@ -33,8 +33,10 @@ import { preloadQuickJs } from "./quickjs"; // so the providers close over it instead of reading process.env. // =========================================================================== -export const makeCloudflareApp = async (env: CloudflareEnv) => { - const config = loadConfig(env); +export const makeCloudflareApp = async ( + env: CloudflareEnv, + config: CloudflareConfig = loadConfig(env), +) => { const plugins = makeCloudflarePlugins(config.secretKey); // Load the Workers-compatible (WASM-inlined) QuickJS variant before any diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index f92d219a41..c9dbaad5b4 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; import { orgWriteAccessForPrincipal, principalOwns } from "@executor-js/host-mcp"; import type { Principal } from "@executor-js/host-mcp"; import type { CloudflareConfig } from "../config"; -import { applyDelegatedSubject, principalFromAccessClaims } from "./cloudflare-access"; +import { + applyDelegatedSubject, + internalPrincipal, + makeAccessVerifier, + principalFromAccessClaims, +} from "./cloudflare-access"; const config: CloudflareConfig = { accessTeamDomain: "team.cloudflareaccess.com", @@ -156,3 +162,72 @@ describe("applyDelegatedSubject", () => { expect(p).toBeNull(); }); }); + +describe("internalPrincipal", () => { + const internal: CloudflareConfig = { ...delegating, trustedInternal: true }; + + it("acts as the configured service token when no subject is named", () => { + const p = internalPrincipal(internal, { subject: null, email: null }); + expect(p?.accountId).toBe("agent-token.access"); + expect(p?.email).toBe(""); + expect(p?.roles).toEqual(["member"]); + }); + + it("acts as the named person, reaching the rows their browser session reaches", () => { + const browser = principalFromAccessClaims( + { sub: "alice-sub", email: "alice@example.com" }, + internal, + ); + const p = internalPrincipal(internal, { + subject: "Alice@Example.com", + email: "Alice@Example.com", + }); + expect(p?.accountId).toBe("alice@example.com"); + expect(principalOwns(browser, p as Principal)).toBe(true); + }); + + it("mirrors the named person's admin standing", () => { + const p = internalPrincipal(internal, { + subject: "admin@example.com", + email: "admin@example.com", + }); + expect(p?.orgRole).toBe("admin"); + }); + + it("REJECTS an email with no subject", () => { + expect(internalPrincipal(internal, { subject: "", email: "alice@example.com" })).toBeNull(); + }); + + it("has a stable identity when no delegator is configured", () => { + const p = internalPrincipal( + { ...config, trustedInternal: true }, + { subject: null, email: null }, + ); + expect(p?.accountId).toBe("internal-service-binding"); + }); +}); + +describe("makeAccessVerifier doors", () => { + const delegatedRequest = () => + new Request("https://executor.test/mcp", { + headers: { + "X-Executor-Subject": "alice@example.com", + "X-Executor-Subject-Email": "alice@example.com", + }, + }); + + it.effect("the public door rejects a delegation header with no Access assertion", () => + Effect.gen(function* () { + const { verify } = makeAccessVerifier(delegating); + expect(yield* verify(delegatedRequest())).toBeNull(); + }), + ); + + it.effect("the internal door acts as the named person with no Access assertion", () => + Effect.gen(function* () { + const { verify } = makeAccessVerifier({ ...delegating, trustedInternal: true }); + const p = yield* verify(delegatedRequest()); + expect(p?.accountId).toBe("alice@example.com"); + }), + ); +}); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index a67a7471d6..6ace3c650a 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -129,6 +129,15 @@ export const applyDelegatedSubject = ( if (!mayDelegate) return null; if (subject.length === 0) return null; + return bindSubject(principal, config, subject, email); +}; + +const bindSubject = ( + principal: Principal, + config: CloudflareConfig, + subject: string, + email: string, +): Principal => { const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); return { ...principal, @@ -144,6 +153,35 @@ export const applyDelegatedSubject = ( }; }; +/** Fallback identity of the service-binding caller when no delegator is configured. */ +export const INTERNAL_BINDING_COMMON_NAME = "internal-service-binding"; + +/** + * The principal a request through the `ExecutorInternal` service-binding door acts + * as. The binding is the trust boundary: only a worker in the same account that + * declares it can call this door, and Cloudflare Access never sees the request. + * + * Without delegation headers the caller is the service itself, keyed on the + * configured delegator's `common_name` so it reaches exactly the rows that same + * service token reaches through Access. With them, it acts for the named person, + * as a trusted delegator would. A subject-less delegation is rejected, as on the + * public door. + */ +export const internalPrincipal = ( + config: CloudflareConfig, + delegated: DelegatedIdentity, +): Principal | null => { + const service = principalFromAccessClaims( + { common_name: config.accessDelegationCommonName ?? INTERNAL_BINDING_COMMON_NAME }, + config, + ); + const subject = delegated.subject?.trim() ?? ""; + const email = delegated.email?.trim() ?? ""; + if (subject.length === 0 && email.length === 0) return service; + if (subject.length === 0) return null; + return bindSubject(service, config, subject, email); +}; + /** * Resolve a request to its verified `Principal`, or `null` when the Access * assertion is missing/invalid. The single source of truth for "who is this @@ -178,6 +216,12 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { const verify = (request: Request): Effect.Effect => Effect.gen(function* () { + // Only the `ExecutorInternal` entrypoint builds a config with this set. It + // is never read from `env` or a request, so nothing presented to the public + // `fetch` can reach this branch. + if (config.trustedInternal === true) { + return internalPrincipal(config, readDelegatedIdentity(request)); + } if (config.enableDevAuth) return devPrincipal; if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts index f7a2c32b3d..82b81094e9 100644 --- a/apps/host-cloudflare/src/config.test.ts +++ b/apps/host-cloudflare/src/config.test.ts @@ -56,6 +56,12 @@ describe("loadConfig", () => { enableDevAuth: false, }); }); + + it("builds the trusted internal config only when the entrypoint asks for it", () => { + const access = { ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", ACCESS_AUD: "aud-tag" }; + expect(loadConfig(makeEnv(access)).trustedInternal).toBe(false); + expect(loadConfig(makeEnv(access), { internal: true }).trustedInternal).toBe(true); + }); }); describe("Cloudflare deployment configuration", () => { diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index 68b8296399..dc554ed741 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -106,6 +106,12 @@ export interface CloudflareConfig { * static URL — the per-request origin is used instead (see RequestWebOrigin). */ readonly webBaseUrl?: string; readonly enableDevAuth: boolean; + /** + * True ONLY for the config the `ExecutorInternal` service-binding entrypoint + * builds (`loadConfig(env, { internal: true })`). Never derived from `env` or a + * request, so the public `fetch` can never be in this mode. + */ + readonly trustedInternal?: boolean; /** Present only when the gateway is fully configured; absent disables Jev * rather than failing a search, so a missing var degrades ranking instead of * breaking tool discovery. */ @@ -124,9 +130,7 @@ type CloudflareAccessEnv = Pick< // Both ids are required: a gateway URL missing either one resolves to a 404 that // would look like "Jev found nothing" rather than "Jev was never configured". -const resolveJevGateway = ( - env: CloudflareConfigEnv, -): CloudflareConfig["jevGateway"] => { +const resolveJevGateway = (env: CloudflareConfigEnv): CloudflareConfig["jevGateway"] => { const accountId = env.CLOUDFLARE_ACCOUNT_ID?.trim() ?? ""; const gatewayId = env.AI_GATEWAY_ID?.trim() ?? ""; if (accountId.length === 0 || gatewayId.length === 0) { @@ -183,7 +187,16 @@ const resolveOrgSlug = (value: string | undefined): string => { return value; }; -export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { +export interface LoadConfigOptions { + /** Build the trusted config for the service-binding entrypoint (see + * {@link CloudflareConfig.trustedInternal}). */ + readonly internal?: boolean; +} + +export const loadConfig = ( + env: CloudflareConfigEnv, + options: LoadConfigOptions = {}, +): CloudflareConfig => { const secretKey = env.EXECUTOR_SECRET_KEY?.trim(); if (!secretKey || secretKey.length < 16) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key @@ -229,6 +242,7 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { // mirroring self-host (gated on enableDevAuth = local `wrangler dev`). webBaseUrl, enableDevAuth, + trustedInternal: options.internal === true, jevGateway: resolveJevGateway(env), }; }; diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index b9964fac4a..fd0b402f0e 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,6 +1,9 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; + import { makeCloudflareApp } from "./app"; import { cloudflareAccessConfigErrorMessage, + loadConfig, missingCloudflareAccessVars, type CloudflareEnv, } from "./config"; @@ -15,23 +18,39 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // handler. `/mcp` and `/mcp/toolkits/:slug` stay at this edge boundary because // `McpAgent.serve()` needs the Cloudflare `ExecutionContext` to pass // authenticated session props into the hibernatable Durable Object bridge. +// +// Two doors share that machinery: +// - the default `fetch` is the PUBLIC door: every request must carry a verified +// Cloudflare Access assertion. +// - `ExecutorInternal` is the service-binding door: a worker in the same account +// reaches it through a `services` binding with `entrypoint: "ExecutorInternal"`. +// It builds its OWN app from a config marked `trustedInternal`; nothing in +// `env` or in a request can switch the public door into that mode. // --------------------------------------------------------------------------- -let handlerPromise: Promise<{ +interface Serve { readonly app: (request: Request) => Promise; readonly mcp: (request: Request, env: CloudflareEnv, ctx: ExecutionContext) => Promise; -}> | null = null; - -const resolveHandler = (env: CloudflareEnv) => { - if (!handlerPromise) { - handlerPromise = makeCloudflareApp(env).then(({ toWebHandler, mcpAgentHandler }) => ({ - app: toWebHandler().handler, - mcp: mcpAgentHandler, - })); - } - return handlerPromise; +} + +const makeResolver = (internal: boolean) => { + let promise: Promise | null = null; + return (env: CloudflareEnv): Promise => { + if (!promise) { + promise = makeCloudflareApp(env, loadConfig(env, { internal })).then( + ({ toWebHandler, mcpAgentHandler }) => ({ + app: toWebHandler().handler, + mcp: mcpAgentHandler, + }), + ); + } + return promise; + }; }; +const resolvePublic = makeResolver(false); +const resolveInternal = makeResolver(true); + const accessConfigErrorResponse = (missingVars: readonly string[]): Response => new Response(`${cloudflareAccessConfigErrorMessage(missingVars)}\n`, { status: 503, @@ -41,18 +60,38 @@ const accessConfigErrorResponse = (missingVars: readonly string[]): Response => }, }); -export default { - fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { - const missingAccessVars = missingCloudflareAccessVars(env); - if (missingAccessVars.length > 0) { - return accessConfigErrorResponse(missingAccessVars); - } +const serveRequest = async ( + request: Request, + env: CloudflareEnv, + ctx: ExecutionContext, + resolve: (env: CloudflareEnv) => Promise, +): Promise => { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + return accessConfigErrorResponse(missingAccessVars); + } - const serve = await resolveHandler(env); - const resource = mcpResourceFromPath(new URL(request.url).pathname); - if (resource !== null) { - return serve.mcp(request, env, ctx); - } - return serve.app(request); - }, + const serve = await resolve(env); + const resource = mcpResourceFromPath(new URL(request.url).pathname); + if (resource !== null) { + return serve.mcp(request, env, ctx); + } + return serve.app(request); +}; + +/** + * The service-binding door. Reachable only through a `services` binding from a + * worker in the same account; the platform gives it no public route. A request + * acts as the calling service, or as the person named in `X-Executor-Subject` + * (see `internalPrincipal`). + */ +export class ExecutorInternal extends WorkerEntrypoint { + override fetch(request: Request): Promise { + return serveRequest(request, this.env, this.ctx, resolveInternal); + } +} + +export default { + fetch: (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => + serveRequest(request, env, ctx, resolvePublic), };