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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/host-cloudflare/DELEGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions apps/host-cloudflare/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
77 changes: 76 additions & 1 deletion apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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");
}),
);
});
44 changes: 44 additions & 0 deletions apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -178,6 +216,12 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {

const verify = (request: Request): Effect.Effect<Principal | null> =>
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");
Expand Down
6 changes: 6 additions & 0 deletions apps/host-cloudflare/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 18 additions & 4 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
};
};
87 changes: 63 additions & 24 deletions apps/host-cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { WorkerEntrypoint } from "cloudflare:workers";

import { makeCloudflareApp } from "./app";
import {
cloudflareAccessConfigErrorMessage,
loadConfig,
missingCloudflareAccessVars,
type CloudflareEnv,
} from "./config";
Expand All @@ -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<Response>;
readonly mcp: (request: Request, env: CloudflareEnv, ctx: ExecutionContext) => Promise<Response>;
}> | 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<Serve> | null = null;
return (env: CloudflareEnv): Promise<Serve> => {
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,
Expand All @@ -41,18 +60,38 @@ const accessConfigErrorResponse = (missingVars: readonly string[]): Response =>
},
});

export default {
fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
const missingAccessVars = missingCloudflareAccessVars(env);
if (missingAccessVars.length > 0) {
return accessConfigErrorResponse(missingAccessVars);
}
const serveRequest = async (
request: Request,
env: CloudflareEnv,
ctx: ExecutionContext,
resolve: (env: CloudflareEnv) => Promise<Serve>,
): Promise<Response> => {
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<CloudflareEnv> {
override fetch(request: Request): Promise<Response> {
return serveRequest(request, this.env, this.ctx, resolveInternal);
}
}

export default {
fetch: (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> =>
serveRequest(request, env, ctx, resolvePublic),
};
Loading