diff --git a/control-plane/src/env.d.ts b/control-plane/src/env.d.ts index e0b277ff7a..fd3bc8e603 100644 --- a/control-plane/src/env.d.ts +++ b/control-plane/src/env.d.ts @@ -14,6 +14,10 @@ declare global { /** Same routing-key secret name/shape as the main app's own src/env.d.ts (#7667's PagerDuty mirror) -- * grants the ability to trigger a real page, so it's a secret here too, not a plain var. */ PAGERDUTY_ROUTING_KEY?: string; + /** This hosted fleet's OWN GitHub App webhook secret (#7181) -- a SEPARATE value from the main app's + * ORB_GITHUB_WEBHOOK_SECRET (a different physical service). Unset ⇒ POST /v1/orb/webhook fails every + * delivery closed (orb-webhook-router.ts). Genuinely sensitive: whoever holds it can forge a webhook. */ + ORB_WEBHOOK_SECRET?: string; } } diff --git a/control-plane/src/http-app.ts b/control-plane/src/http-app.ts index 33a89a95bb..0e90f58a00 100644 --- a/control-plane/src/http-app.ts +++ b/control-plane/src/http-app.ts @@ -16,8 +16,14 @@ // `POST /v1/tenants` also accepts an optional `schedule` field (#7182), valid only for `product: "ams"`: // configures the new tenant's cron-wake cadence at creation time. ams-wake.ts's `scheduled()`-triggered // handler is what actually reads and acts on it later -- this route only validates and stores it. +// +// `POST /v1/tenants` also accepts an optional `orbInstallationId` field (#7181), valid only for +// `product: "orb"`: the GitHub App installation this tenant's hosted container answers webhooks for. +// `POST /v1/orb/webhook` (below) is the actual routing endpoint that reads it back via the registry's +// installation-ID index -- this route only validates, checks for a conflicting claim, and stores it. import { Hono } from "hono"; import { normalizeSharedSecret, verifyBearer } from "./auth.js"; +import { routeOrbWebhook, type RouterNamespaceLike } from "./orb-webhook-router.js"; import { deprovisionTenant, provisionTenant, @@ -34,10 +40,22 @@ export type TenantHttpAppDeps = { * rather than silently accepting an unauthenticated caller. */ adminToken: string | undefined; pagerDuty?: ProvisioningPagerDutyOptions; + /** ORB's tenant container binding + the hosted fleet's own GitHub App webhook secret (#7181) -- wired into + * `POST /v1/orb/webhook` below via orb-webhook-router.ts. Optional so existing callers (and every test that + * doesn't exercise webhook routing) don't need to supply a binding they never use; `webhookSecret` being + * unset already fails that route closed on its own (see orb-webhook-router.ts), same as `adminToken`. */ + orbWebhookBinding?: RouterNamespaceLike; + orbWebhookSecret?: string; }; -function safeRecord(record: Pick): Record { - return { tenant: record.tenant, product: record.product, state: record.state, ...(record.amsSchedule ? { amsSchedule: record.amsSchedule } : {}) }; +function safeRecord(record: Pick): Record { + return { + tenant: record.tenant, + product: record.product, + state: record.state, + ...(record.amsSchedule ? { amsSchedule: record.amsSchedule } : {}), + ...(record.orbInstallationId !== undefined ? { orbInstallationId: record.orbInstallationId } : {}), + }; } /** The only `command` names #7182's hosted entry point (loopover-miner-hosted) actually dispatches -- @@ -68,6 +86,18 @@ function parseScheduleRequest(value: unknown): AmsCycleSchedule | string | undef return { command, args: Array.isArray(args) ? args : [], intervalMs, nextDueAt: new Date().toISOString() }; } +/** Validated body of `POST /v1/tenants`'s optional `orbInstallationId` field (#7181): a GitHub App + * installation ID, always a positive integer (GitHub's own ID space). `undefined` input (the field omitted) + * is valid -- an ORB tenant with no installation linked yet simply never receives a routed webhook, which is + * a legitimate state during onboarding, not an error. */ +function parseOrbInstallationId(value: unknown): number | string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) { + return "orbInstallationId must be a positive integer"; + } + return value; +} + /** Validated body of `POST /v1/tenants/rollout` (#4898): an explicit tenant-name list (no percentage/canary * selector — no such primitive exists elsewhere in this codebase to build on) plus the version to pin. * `pinnedVersion: null` is an explicit unpin (revert to the release channel's default). Scoped to a single @@ -114,12 +144,17 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { app.post("/v1/tenants", async (c) => { const body: unknown = await c.req.json().catch(() => null); if (body === null || typeof body !== "object") return c.json({ error: "invalid_json" }, 400); - const { name, product, schedule: scheduleInput } = body as Record; + const { name, product, schedule: scheduleInput, orbInstallationId: orbInstallationIdInput } = body as Record; if (typeof name !== "string" || !name.trim()) return c.json({ error: "invalid_request", message: "name is required" }, 400); if (typeof product !== "string" || !product.trim()) return c.json({ error: "invalid_request", message: "product is required" }, 400); const schedule = parseScheduleRequest(scheduleInput); if (typeof schedule === "string") return c.json({ error: "invalid_request", message: schedule }, 400); if (schedule && product !== "ams") return c.json({ error: "invalid_request", message: 'schedule is only valid for product "ams"' }, 400); + const orbInstallationId = parseOrbInstallationId(orbInstallationIdInput); + if (typeof orbInstallationId === "string") return c.json({ error: "invalid_request", message: orbInstallationId }, 400); + if (orbInstallationId !== undefined && product !== "orb") { + return c.json({ error: "invalid_request", message: 'orbInstallationId is only valid for product "orb"' }, 400); + } // Not idempotent by design (tenant-client.ts's own doc comment: "a create is not idempotent, so it must // not be silently re-sent") -- a currently-active tenant of the same name *and product* is a real conflict, @@ -128,9 +163,30 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { const existing = await deps.registry.get(name, product); if (existing && existing.state !== "torn down") return c.json({ error: "tenant_already_exists" }, 409); + // A GitHub installation ID must resolve to exactly one hosted container (#7181's routing depends on this + // being unambiguous) -- reject before ever provisioning anything, same posture as the name+product conflict + // check just above. + if (orbInstallationId !== undefined) { + const conflicting = await deps.registry.getByOrbInstallationId(orbInstallationId); + if (conflicting && conflicting.state !== "torn down") { + return c.json( + { error: "installation_already_claimed", message: `installation ${orbInstallationId} is already claimed by tenant "${conflicting.tenant.name}"` }, + 409, + ); + } + } + const result = await provisionTenant({ name }, product, deps.driver, deps.pagerDuty ?? {}); const now = new Date().toISOString(); - const record: TenantRegistryRecord = { tenant: result.tenant, product: result.product, state: result.state, createdAt: now, updatedAt: now, ...(schedule ? { amsSchedule: schedule } : {}) }; + const record: TenantRegistryRecord = { + tenant: result.tenant, + product: result.product, + state: result.state, + createdAt: now, + updatedAt: now, + ...(schedule ? { amsSchedule: schedule } : {}), + ...(orbInstallationId !== undefined ? { orbInstallationId } : {}), + }; await deps.registry.upsert(record); return c.json(safeRecord(record), 201); }); @@ -192,5 +248,17 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { return c.json(safeRecord(result)); }); + // #7181: routes an incoming GitHub webhook to the hosted ORB tenant it belongs to. Deliberately OUTSIDE the + // `/v1/tenants/*` admin-bearer middleware above -- GitHub authenticates a webhook via its own HMAC signature + // (orb-webhook-router.ts verifies it independently), not this service's admin token. An unset + // `orbWebhookBinding` (a test harness that never exercises this route, or a real deployment mid-rollout) + // fails closed with 503 here, matching `adminToken`'s own "unconfigured ⇒ 503" convention above -- a missing + // binding is a deployment-config gap, not something `routeOrbWebhook` (which assumes it always has one) or + // its 401/502 error shapes should have to represent. + app.post("/v1/orb/webhook", async (c) => { + if (!deps.orbWebhookBinding) return c.json({ error: "service_not_configured" }, 503); + return routeOrbWebhook({ binding: deps.orbWebhookBinding, registry: deps.registry, webhookSecret: deps.orbWebhookSecret }, c.req.raw); + }); + return app; } diff --git a/control-plane/src/index.ts b/control-plane/src/index.ts index 08fd6ad81c..265a01ff50 100644 --- a/control-plane/src/index.ts +++ b/control-plane/src/index.ts @@ -80,3 +80,9 @@ export { type WakeNamespaceLike, type WakeStubLike, } from "./ams-wake.js"; +export { + routeOrbWebhook, + type OrbWebhookRouterConfig, + type RouterNamespaceLike, + type RouterStubLike, +} from "./orb-webhook-router.js"; diff --git a/control-plane/src/orb-webhook-router.ts b/control-plane/src/orb-webhook-router.ts new file mode 100644 index 0000000000..9ededc4e9b --- /dev/null +++ b/control-plane/src/orb-webhook-router.ts @@ -0,0 +1,121 @@ +// Request-time routing of incoming GitHub webhook deliveries to the correct hosted ORB tenant's container +// (#7181, part of #7173's shared control-plane). ORB's central GitHub App delivers every installation's +// webhooks to ONE configured URL -- self-host has exactly one deployment per installation already, so nothing +// routes there today; a hosted, multi-tenant fleet needs this thin layer in front of it to find which +// container an incoming delivery actually belongs to. Verification/parsing/dispatch all happen HERE (this +// service has no D1 of its own, unlike the main app's src/orb/webhook.ts -- see http-app.ts's own header +// comment on why credentials/state live in this Worker's KV registry instead of the main app's database), then +// the verified, unmodified request is proxied straight through to that tenant's own container, which runs the +// SAME self-host webhook-handling code unmodified and re-verifies independently -- this layer changes WHERE a +// webhook gets dispatched, not how it's authenticated (mirrors the main app's own handleOrbWebhook contract). +import type { Product } from "./tenant-provisioning-driver.js"; +import type { TenantRegistry } from "./tenant-registry.js"; + +/** The slice of a real Container DO's RPC surface this module actually calls -- a SEPARATE small local + * interface from container-driver.ts's `ContainerStubLike` (that one starts/stops/tracks provisioning; this + * one only proxies an HTTP request) and ams-wake.ts's `WakeStubLike` (that one starts a one-shot CLI run and + * polls for completion; ORB's container is a persistent HTTP server, so a plain `fetch()` is enough -- + * @cloudflare/containers' `Container.fetch()` already starts the container if it's asleep and waits for its + * `defaultPort` to be ready before resolving, so "wake if asleep" needs no extra code here). Mirrors this + * package's established "local interface, no SDK import" convention. */ +export type RouterStubLike = { + fetch(request: Request): Promise; +}; + +export type RouterNamespaceLike = { + getByName(name: string): RouterStubLike; +}; + +export type OrbWebhookRouterConfig = { + binding: RouterNamespaceLike; + registry: TenantRegistry; + /** The hosted fleet's own GitHub App webhook secret -- a control-plane Worker secret, independent of the + * main app's `ORB_GITHUB_WEBHOOK_SECRET` (a different physical service, same verification shape). Absent/ + * blank ⇒ every delivery fails closed with 401, matching http-app.ts's own `adminToken` convention and the + * main app's own "inert until the secret is injected" comment on this exact check. */ + webhookSecret: string | undefined; +}; + +/** Same `${product}:${name}` composite container-driver.ts's own `instanceNameFor` derives -- duplicated (not + * imported) for the same reason ams-wake.ts's own copy is: this module has no `TenantProvisioningRequest` to + * construct, just a name/product pair already in hand from the registry lookup below. */ +function instanceNameFor(name: string, product: Product): string { + return `${product}:${name}`; +} + +/** Verifies a GitHub webhook's `x-hub-signature-256` HMAC-SHA256 against the raw request body -- the exact + * same algorithm as the main app's `src/utils/crypto.ts#verifyGitHubSignature` (this package can't import + * that file; it's a separate workspace with no dependency on the main app), duplicated locally rather than + * published as a shared package purely for this one call site. Timing-safe comparison so a malformed/invalid + * signature can't leak byte-by-byte match information through response-time differences. */ +async function verifyGitHubSignature(rawBody: string, signatureHeader: string | null, secret: string | undefined): Promise { + if (!signatureHeader?.startsWith("sha256=")) return false; + if (!secret) return false; + + const expectedHex = signatureHeader.slice("sha256=".length); + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody)); + + // `actualBytes` comes straight from our own HMAC computation -- always a well-formed byte array, never in + // need of the malformed-hex handling `hexToBytes` exists for. Only `expectedHex` (attacker-controlled, off + // the request header) can ever be malformed, so it's the only side parsed through that nullable path. + return timingSafeEqualBytes(new Uint8Array(signature), expectedHex); +} + +function timingSafeEqualBytes(actualBytes: Uint8Array, expectedHex: string): boolean { + const expectedBytes = hexToBytes(expectedHex); + if (!expectedBytes || actualBytes.length !== expectedBytes.length) return false; + let result = 0; + for (let index = 0; index < actualBytes.length; index += 1) result |= actualBytes[index]! ^ expectedBytes[index]!; + return result === 0; +} + +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null; + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + return bytes; +} + +/** Routes one incoming webhook `Request` to its owning ORB tenant's container, or answers an error itself if + * it can't. `request` is consumed for signature verification (its raw body text) but a `.clone()` taken + * BEFORE that read is what actually gets forwarded -- the tenant's container re-verifies the same signature + * against the same untouched body, so a body-reconstruction bug here can't silently diverge from what GitHub + * actually signed. */ +export async function routeOrbWebhook(config: OrbWebhookRouterConfig, request: Request): Promise { + const forwardRequest = request.clone(); + const rawBody = await request.text(); + const signature = request.headers.get("x-hub-signature-256"); + + const verified = await verifyGitHubSignature(rawBody, signature, config.webhookSecret); + if (!verified) return Response.json({ error: "invalid_signature" }, { status: 401 }); + + let payload: { installation?: { id?: unknown } }; + try { + payload = JSON.parse(rawBody) as { installation?: { id?: unknown } }; + } catch { + return Response.json({ error: "invalid_json" }, { status: 400 }); + } + + const installationId = payload.installation?.id; + if (typeof installationId !== "number") { + return Response.json({ error: "missing_installation_id" }, { status: 400 }); + } + + const record = await config.registry.getByOrbInstallationId(installationId); + if (!record || record.product !== "orb" || record.state !== "active") { + return Response.json({ error: "unknown_installation" }, { status: 404 }); + } + + try { + const stub = config.binding.getByName(instanceNameFor(record.tenant.name, record.product)); + // Cloudflare's own `Request` generic (carrying edge metadata this module never reads) infers + // differently between Hono's `c.req.raw` and `@cloudflare/containers`' own `Container.fetch` signature + // under `cf:typecheck`'s real workers-types -- a structural, not a runtime, mismatch; the cast is scoped to + // exactly this one boundary, same "local interface, no SDK type import" posture as ContainerStubLike's own + // header comment describes for the rest of this package. + return await stub.fetch(forwardRequest as unknown as Request); + } catch { + return Response.json({ error: "container_unreachable" }, { status: 502 }); + } +} diff --git a/control-plane/src/tenant-registry.ts b/control-plane/src/tenant-registry.ts index 4a766f8515..bef07c175c 100644 --- a/control-plane/src/tenant-registry.ts +++ b/control-plane/src/tenant-registry.ts @@ -33,6 +33,11 @@ export type TenantRegistryRecord = { createdAt: string; updatedAt: string; amsSchedule?: AmsCycleSchedule; + /** The GitHub App installation ID this ORB tenant's hosted container answers webhooks for (#7181) -- ORB + * tenants only, mirroring `amsSchedule`'s own AMS-only shape. Set at creation (see http-app.ts's + * `POST /v1/tenants`); `orb-webhook-router.ts`'s request-time routing looks up a tenant by this ID to know + * which container an incoming webhook belongs to. */ + orbInstallationId?: number; }; export interface TenantRegistry { @@ -46,6 +51,10 @@ export interface TenantRegistry { * terminated instances rather than making them vanish) -- ordered by `tenant.name` then `product` for a * stable listing across products. */ list(): Promise; + /** Lookup an ORB tenant by its `orbInstallationId` (#7181) -- the only way an incoming GitHub webhook (which + * carries an installation ID, never a tenant name) can find the right container to route to. `undefined` + * for an installation ID no tenant currently claims. */ + getByOrbInstallationId(installationId: number): Promise; } /** Same composite key as container-driver.ts's `instanceNameFor` (#8024) — ORB and AMS tenants that share a @@ -60,7 +69,10 @@ function sortRecords(records: TenantRegistryRecord[]): TenantRegistryRecord[] { ); } -/** In-memory fake for tests -- mirrors `createFakeTenantProvisioningDriver`'s own minimal-fake convention. */ +/** In-memory fake for tests -- mirrors `createFakeTenantProvisioningDriver`'s own minimal-fake convention. + * `getByOrbInstallationId` is a plain linear scan -- fine for a fake with, at most, a handful of test + * records; the real KV-backed registry below needs an actual secondary index instead, since KV has no query + * capability at all. */ export function createFakeTenantRegistry(): TenantRegistry { const records = new Map(); return { @@ -73,6 +85,9 @@ export function createFakeTenantRegistry(): TenantRegistry { async list() { return sortRecords([...records.values()]); }, + async getByOrbInstallationId(installationId) { + return [...records.values()].find((record) => record.orbInstallationId === installationId); + }, }; } @@ -82,22 +97,44 @@ export function createFakeTenantRegistry(): TenantRegistry { export type KvNamespaceLike = { get(key: string): Promise; put(key: string, value: string): Promise; + delete(key: string): Promise; list(options?: { prefix?: string; cursor?: string }): Promise<{ keys: Array<{ name: string }>; list_complete: boolean; cursor?: string }>; }; const KEY_PREFIX = "tenant:"; +const INSTALLATION_INDEX_PREFIX = "installation:"; function keyFor(name: string, product: Product): string { return `${KEY_PREFIX}${instanceKeyFor(name, product)}`; } +/** Secondary index key for #7181's webhook routing: an incoming GitHub webhook only carries an installation + * ID, never a tenant name, and KV has no query/scan-by-field capability -- so this points straight at the + * tenant's own primary key, kept in sync by `upsert` below (write-time index maintenance, not a live query). */ +function installationIndexKeyFor(installationId: number): string { + return `${INSTALLATION_INDEX_PREFIX}${installationId}`; +} + /** Real registry backed by Workers KV. `list()` pages through every `tenant:`-prefixed key (KV's own `list()` * caps each call at 1000 keys) rather than assuming a single page covers the whole registry. Keys are * `tenant:${product}:${name}` (#8024). */ export function createKvTenantRegistry(kv: KvNamespaceLike): TenantRegistry { return { async upsert(record) { - await kv.put(keyFor(record.tenant.name, record.product), JSON.stringify(record)); + const primaryKey = keyFor(record.tenant.name, record.product); + // Keep the installation-ID secondary index in sync: if this update changes (or clears) which + // installation the tenant claims, the stale pointer must go, or a re-linked/unlinked installation ID + // would keep resolving to the wrong (or a deleted) tenant. Reading the previous record here is the only + // way to know the OLD installationId -- `upsert` itself only ever receives the new one. + const previousRaw = await kv.get(primaryKey); + const previous = previousRaw ? (JSON.parse(previousRaw) as TenantRegistryRecord) : undefined; + if (previous?.orbInstallationId !== undefined && previous.orbInstallationId !== record.orbInstallationId) { + await kv.delete(installationIndexKeyFor(previous.orbInstallationId)); + } + await kv.put(primaryKey, JSON.stringify(record)); + if (record.orbInstallationId !== undefined) { + await kv.put(installationIndexKeyFor(record.orbInstallationId), primaryKey); + } }, async get(name, product) { const raw = await kv.get(keyFor(name, product)); @@ -117,5 +154,11 @@ export function createKvTenantRegistry(kv: KvNamespaceLike): TenantRegistry { } return sortRecords(records); }, + async getByOrbInstallationId(installationId) { + const primaryKey = await kv.get(installationIndexKeyFor(installationId)); + if (!primaryKey) return undefined; + const raw = await kv.get(primaryKey); + return raw ? (JSON.parse(raw) as TenantRegistryRecord) : undefined; + }, }; } diff --git a/control-plane/src/worker.ts b/control-plane/src/worker.ts index 17da0496f8..01e2fa708a 100644 --- a/control-plane/src/worker.ts +++ b/control-plane/src/worker.ts @@ -34,8 +34,8 @@ class ProvisionedContainer extends Container { /** ORB's tenant container (#7173's ratified one-container-per-tenant-per-product model): runs the SAME root * Dockerfile self-host image unmodified, on the port that image's own PORT env var / HEALTHCHECK already - * use. Webhook routing into this container (#7181) is a separate, not-yet-built piece -- this class only - * stands the container up and tears it down; #7181 is what will actually proxy requests through it. */ + * use. `defaultPort` is what `Container.fetch()` proxies to -- orb-webhook-router.ts (#7181) is the thing + * that actually calls it, waking this container on demand when a webhook for this tenant arrives. */ export class OrbTenantContainer extends ProvisionedContainer { defaultPort = 8787; sleepAfter = "10m"; @@ -65,6 +65,11 @@ export default { // a real-Node-only assumption -- explicitly forwarding the Worker's own bindings here is what makes // paging actually configurable in this deployment, rather than silently reading an empty process.env. pagerDuty: { env: { LOOPOVER_ENABLE_PAGERDUTY: env.LOOPOVER_ENABLE_PAGERDUTY, PAGERDUTY_ROUTING_KEY: env.PAGERDUTY_ROUTING_KEY } }, + // #7181: routes incoming GitHub webhooks to the right hosted ORB tenant's container. A SEPARATE secret + // from the main app's own `ORB_GITHUB_WEBHOOK_SECRET` (a different physical service) -- see + // orb-webhook-router.ts's header comment. + orbWebhookBinding: env.ORB_TENANT_CONTAINER, + orbWebhookSecret: env.ORB_WEBHOOK_SECRET, }); return app.fetch(request, env); }, diff --git a/control-plane/test/http-app.test.ts b/control-plane/test/http-app.test.ts index 85aa1ac7e2..2c6277611f 100644 --- a/control-plane/test/http-app.test.ts +++ b/control-plane/test/http-app.test.ts @@ -3,12 +3,15 @@ // auth branch, every validation branch, the not-idempotent create-conflict rule, delete-of-unknown-tenant, the // onError 500 path, and (explicitly) that a tenant's database connection details never appear on the wire. import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; import { afterEach, test } from "node:test"; import { createFakeTenantProvisioningDriver, createFakeTenantRegistry, createTenantHttpApp, + type RouterNamespaceLike, + type RouterStubLike, type TenantHttpAppDeps, type TenantProvisioningDriver, } from "../dist/index.js"; @@ -219,6 +222,103 @@ test("POST /v1/tenants without a schedule creates an AMS tenant with no amsSched assert.equal((await registry.get("acme", "ams"))?.amsSchedule, undefined); }); +test("POST /v1/tenants accepts an optional orbInstallationId for an ORB tenant and surfaces it back (#7181)", async () => { + const registry = createFakeTenantRegistry(); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "acme", product: "orb", orbInstallationId: 555 }) }), + ); + + assert.equal(res.status, 201); + const payload = (await res.json()) as { orbInstallationId?: number }; + assert.equal(payload.orbInstallationId, 555); + assert.equal((await registry.get("acme", "orb"))?.orbInstallationId, 555); +}); + +test("POST /v1/tenants without orbInstallationId creates an ORB tenant with no installation link at all", async () => { + const registry = createFakeTenantRegistry(); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "acme", product: "orb" }) }), + ); + + assert.equal(res.status, 201); + const payload = (await res.json()) as Record; + assert.equal("orbInstallationId" in payload, false); + assert.equal((await registry.get("acme", "orb"))?.orbInstallationId, undefined); +}); + +test("POST /v1/tenants rejects orbInstallationId on a non-ORB product", async () => { + const app = createTenantHttpApp(baseDeps()); + + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "acme", product: "ams", orbInstallationId: 555 }) }), + ); + + assert.equal(res.status, 400); + assert.deepEqual(await res.json(), { error: "invalid_request", message: 'orbInstallationId is only valid for product "orb"' }); +}); + +test("POST /v1/tenants rejects a malformed orbInstallationId without creating the tenant", async () => { + const registry = createFakeTenantRegistry(); + const app = createTenantHttpApp(baseDeps({ registry })); + + for (const orbInstallationId of ["555", 0, -1, 1.5, true, {}]) { + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "acme", product: "orb", orbInstallationId }) }), + ); + assert.equal(res.status, 400, JSON.stringify(orbInstallationId)); + assert.deepEqual(await res.json(), { error: "invalid_request", message: "orbInstallationId must be a positive integer" }); + } + assert.equal(await registry.get("acme", "orb"), undefined); +}); + +test("POST /v1/tenants rejects an orbInstallationId already claimed by another active tenant (409)", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "existing" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 555 }); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "newcomer", product: "orb", orbInstallationId: 555 }) }), + ); + + assert.equal(res.status, 409); + assert.deepEqual(await res.json(), { error: "installation_already_claimed", message: 'installation 555 is already claimed by tenant "existing"' }); + assert.equal(await registry.get("newcomer", "orb"), undefined); +}); + +test("POST /v1/tenants allows claiming an orbInstallationId that a torn-down tenant previously held", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "old" }, product: "orb", state: "torn down", createdAt: "t0", updatedAt: "t0", orbInstallationId: 555 }); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request( + "/v1/tenants", + authed({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "newcomer", product: "orb", orbInstallationId: 555 }) }), + ); + + assert.equal(res.status, 201); + assert.equal((await registry.get("newcomer", "orb"))?.orbInstallationId, 555); +}); + +test("GET /v1/tenants surfaces an ORB tenant's orbInstallationId when set", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 555 }); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request("/v1/tenants", authed()); + + const payload = (await res.json()) as { tenants: Array<{ orbInstallationId?: number }> }; + assert.equal(payload.tenants[0]?.orbInstallationId, 555); +}); + test("GET /v1/tenants surfaces an AMS tenant's amsSchedule when set", async () => { const registry = createFakeTenantRegistry(); await registry.upsert({ @@ -542,3 +642,42 @@ test("GET /v1/tenants surfaces each tenant's pinnedVersion once one is set (#489 tenants: [{ tenant: { name: "acme", pinnedVersion: "v1.4.2" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" }], }); }); + +// #7181: POST /v1/orb/webhook wiring. routeOrbWebhook's own branches (signature/JSON/lookup/forward/error +// shapes) are exhaustively covered by orb-webhook-router.test.ts -- these only prove http-app.ts plumbs its +// deps into that function correctly, and that the route sits OUTSIDE the /v1/tenants/* admin-bearer wall. + +function fakeOrbStub(response: Response): RouterStubLike { + return { async fetch() { return response; } }; +} + +function fakeOrbNamespace(stubs: Record): RouterNamespaceLike { + return { getByName: (name) => stubs[name] ?? { async fetch() { throw new Error(`no stub for "${name}"`); } } }; +} + +test("POST /v1/orb/webhook is unauthenticated by the admin Bearer wall, and 503s when orbWebhookBinding is unset", async () => { + const app = createTenantHttpApp(baseDeps()); + + const res = await app.request("/v1/orb/webhook", { method: "POST", body: JSON.stringify({ installation: { id: 1 } }) }); + + assert.equal(res.status, 503); + assert.deepEqual(await res.json(), { error: "service_not_configured" }); +}); + +test("POST /v1/orb/webhook routes a verified delivery through to deps.orbWebhookBinding via deps.registry", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 42 }); + const containerResponse = Response.json({ ok: true }, { status: 202 }); + const orbWebhookBinding = fakeOrbNamespace({ "orb:acme": fakeOrbStub(containerResponse) }); + const app = createTenantHttpApp(baseDeps({ registry, orbWebhookBinding, orbWebhookSecret: "whsec" })); + const rawBody = JSON.stringify({ installation: { id: 42 } }); + + const res = await app.request("/v1/orb/webhook", { + method: "POST", + headers: { "x-hub-signature-256": `sha256=${createHmac("sha256", "whsec").update(rawBody).digest("hex")}` }, + body: rawBody, + }); + + assert.equal(res.status, 202); + assert.deepEqual(await res.json(), { ok: true }); +}); diff --git a/control-plane/test/orb-webhook-router.test.ts b/control-plane/test/orb-webhook-router.test.ts new file mode 100644 index 0000000000..08b808693b --- /dev/null +++ b/control-plane/test/orb-webhook-router.test.ts @@ -0,0 +1,217 @@ +// Tests for #7181's request-time webhook routing. No live Cloudflare Containers anywhere here -- +// RouterNamespaceLike/RouterStubLike are hand-rolled fakes, mirroring ams-wake.test.ts's own convention. +// Real GitHub HMAC-SHA256 signatures are computed with node:crypto (a different implementation from +// orb-webhook-router.ts's own Web-Crypto-based verifier) so a passing test proves interop, not just that both +// sides agree with themselves. +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { test } from "node:test"; + +import { createFakeTenantRegistry, routeOrbWebhook, type OrbWebhookRouterConfig, type RouterNamespaceLike, type RouterStubLike, type TenantRegistry } from "../dist/index.js"; + +const WEBHOOK_SECRET = "test-webhook-secret"; + +function githubSignature(rawBody: string, secret: string = WEBHOOK_SECRET): string { + return `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`; +} + +function webhookRequest(body: unknown, options: { signature?: string; secret?: string } = {}): Request { + const rawBody = JSON.stringify(body); + const signature = options.signature ?? githubSignature(rawBody, options.secret); + return new Request("https://control-plane.example/v1/orb/webhook", { + method: "POST", + headers: { "content-type": "application/json", "x-hub-signature-256": signature, "x-github-event": "pull_request" }, + body: rawBody, + }); +} + +function fakeStub(response: Response | (() => Response)): RouterStubLike & { requests: Request[] } { + const requests: Request[] = []; + return { + requests, + async fetch(request) { + requests.push(request); + return typeof response === "function" ? response() : response; + }, + }; +} + +function fakeNamespace(stubs: Record): RouterNamespaceLike & { requestedNames: string[] } { + const requestedNames: string[] = []; + return { + requestedNames, + getByName(name) { + requestedNames.push(name); + const stub = stubs[name]; + if (!stub) throw new Error(`fakeNamespace: no stub registered for "${name}"`); + return stub; + }, + }; +} + +function baseConfig(overrides: Partial & { registry: TenantRegistry }): OrbWebhookRouterConfig { + return { binding: fakeNamespace({}), webhookSecret: WEBHOOK_SECRET, ...overrides }; +} + +test("routeOrbWebhook: a missing x-hub-signature-256 header is rejected (401), no registry lookup happens", async () => { + const registry = createFakeTenantRegistry(); + let lookups = 0; + const spiedRegistry: TenantRegistry = { + ...registry, + getByOrbInstallationId(id) { + lookups += 1; + return registry.getByOrbInstallationId(id); + }, + }; + const request = new Request("https://control-plane.example/v1/orb/webhook", { method: "POST", body: JSON.stringify({ installation: { id: 1 } }) }); + + const response = await routeOrbWebhook(baseConfig({ registry: spiedRegistry }), request); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "invalid_signature" }); + assert.equal(lookups, 0); +}); + +test("routeOrbWebhook: a signature that doesn't match the body is rejected (401)", async () => { + const registry = createFakeTenantRegistry(); + const request = webhookRequest({ installation: { id: 1 } }, { signature: "sha256=" + "0".repeat(64) }); + + const response = await routeOrbWebhook(baseConfig({ registry }), request); + + assert.equal(response.status, 401); +}); + +test("routeOrbWebhook: a signature missing the sha256= prefix is rejected (401)", async () => { + const registry = createFakeTenantRegistry(); + const rawBody = JSON.stringify({ installation: { id: 1 } }); + const request = new Request("https://control-plane.example/v1/orb/webhook", { + method: "POST", + headers: { "x-hub-signature-256": createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex") }, + body: rawBody, + }); + + const response = await routeOrbWebhook(baseConfig({ registry }), request); + + assert.equal(response.status, 401); +}); + +for (const [label, malformedHex] of [ + ["non-hex characters", "not-hex-at-all"], + ["odd length", "abc"], + ["empty", ""], + ["valid hex but the wrong length", "abcd"], +] as const) { + test(`routeOrbWebhook: a signature with ${label} is rejected (401)`, async () => { + const registry = createFakeTenantRegistry(); + const request = webhookRequest({ installation: { id: 1 } }, { signature: `sha256=${malformedHex}` }); + + const response = await routeOrbWebhook(baseConfig({ registry }), request); + + assert.equal(response.status, 401); + }); +} + +test("routeOrbWebhook: an unset webhookSecret fails every delivery closed (401), even with a well-formed signature", async () => { + const registry = createFakeTenantRegistry(); + const request = webhookRequest({ installation: { id: 1 } }); + + const response = await routeOrbWebhook(baseConfig({ registry, webhookSecret: undefined }), request); + + assert.equal(response.status, 401); +}); + +test("routeOrbWebhook: a verified but non-JSON body is rejected (400)", async () => { + const registry = createFakeTenantRegistry(); + const rawBody = "not json"; + const request = new Request("https://control-plane.example/v1/orb/webhook", { + method: "POST", + headers: { "x-hub-signature-256": githubSignature(rawBody) }, + body: rawBody, + }); + + const response = await routeOrbWebhook(baseConfig({ registry }), request); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_json" }); +}); + +test("routeOrbWebhook: a verified payload missing installation.id is rejected (400)", async () => { + const registry = createFakeTenantRegistry(); + const request = webhookRequest({ action: "opened" }); + + const response = await routeOrbWebhook(baseConfig({ registry }), request); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "missing_installation_id" }); +}); + +test("routeOrbWebhook: an installation ID no tenant has claimed is rejected (404), no container is touched", async () => { + const registry = createFakeTenantRegistry(); + const binding = fakeNamespace({}); + const request = webhookRequest({ installation: { id: 42 } }); + + const response = await routeOrbWebhook(baseConfig({ registry, binding }), request); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "unknown_installation" }); + assert.deepEqual(binding.requestedNames, []); +}); + +test("routeOrbWebhook: a torn-down tenant's installation ID is rejected (404) even though it's still indexed", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "torn down", createdAt: "t0", updatedAt: "t0", orbInstallationId: 42 }); + + const response = await routeOrbWebhook(baseConfig({ registry }), webhookRequest({ installation: { id: 42 } })); + + assert.equal(response.status, 404); +}); + +test("routeOrbWebhook: an installation ID somehow indexed against a non-ORB tenant is rejected (404)", async () => { + const registry = createFakeTenantRegistry(); + // Defensive-only case: only ORB tenants are ever meant to carry orbInstallationId, but the registry itself + // doesn't enforce that -- this proves the router doesn't blindly trust the index. + await registry.upsert({ tenant: { name: "acme" }, product: "ams", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 42 }); + + const response = await routeOrbWebhook(baseConfig({ registry }), webhookRequest({ installation: { id: 42 } })); + + assert.equal(response.status, 404); +}); + +test("routeOrbWebhook: forwards a verified webhook to the claiming tenant's container and returns its response unmodified", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 42 }); + const containerResponse = Response.json({ ok: true, deliveryId: "abc", status: "received" }, { status: 202 }); + const stub = fakeStub(containerResponse); + const binding = fakeNamespace({ "orb:acme": stub }); + const rawBody = JSON.stringify({ installation: { id: 42 }, action: "opened" }); + const request = new Request("https://control-plane.example/v1/orb/webhook", { + method: "POST", + headers: { "x-hub-signature-256": githubSignature(rawBody), "x-github-event": "pull_request" }, + body: rawBody, + }); + + const response = await routeOrbWebhook(baseConfig({ registry, binding }), request); + + assert.deepEqual(binding.requestedNames, ["orb:acme"]); + assert.equal(stub.requests.length, 1); + assert.equal(await stub.requests[0]!.text(), rawBody); + assert.equal(stub.requests[0]!.headers.get("x-github-event"), "pull_request"); + assert.equal(response.status, 202); + assert.deepEqual(await response.json(), { ok: true, deliveryId: "abc", status: "received" }); +}); + +test("routeOrbWebhook: a container that throws while waking/fetching is answered with 502, not a crash", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", orbInstallationId: 42 }); + const stub: RouterStubLike = { + async fetch() { + throw new Error("container unreachable"); + }, + }; + const binding = fakeNamespace({ "orb:acme": stub }); + + const response = await routeOrbWebhook(baseConfig({ registry, binding }), webhookRequest({ installation: { id: 42 } })); + + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), { error: "container_unreachable" }); +}); diff --git a/control-plane/test/tenant-registry.test.ts b/control-plane/test/tenant-registry.test.ts index 2ac12b6ebd..6b9ad018fb 100644 --- a/control-plane/test/tenant-registry.test.ts +++ b/control-plane/test/tenant-registry.test.ts @@ -60,6 +60,15 @@ test("createFakeTenantRegistry: state is product-scoped (${product}:${name}), no assert.equal((await registry.get("acme", "ams"))?.state, "active"); }); +test("createFakeTenantRegistry: getByOrbInstallationId finds a tenant by installation ID, undefined for an unclaimed one", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ ...recordFor("acme"), orbInstallationId: 555 }); + await registry.upsert(recordFor("beta")); + + assert.equal((await registry.getByOrbInstallationId(555))?.tenant.name, "acme"); + assert.equal(await registry.getByOrbInstallationId(999), undefined); +}); + function fakeKv(initial: Record = {}): KvNamespaceLike & { store: Map } { const store = new Map(Object.entries(initial)); return { @@ -70,6 +79,9 @@ function fakeKv(initial: Record = {}): KvNamespaceLike & { store async put(key, value) { store.set(key, value); }, + async delete(key) { + store.delete(key); + }, async list({ prefix = "", cursor } = {}) { const keys = [...store.keys()].filter((key) => key.startsWith(prefix)).sort(); const pageSize = 2; @@ -162,3 +174,50 @@ test("a tenant's pinnedVersion (#4898) survives the KV JSON round-trip, and its // A pre-#4898 record (no pinnedVersion key at all) reads back exactly as stored — unpinned. assert.deepEqual((await registry.get("beta", "orb"))?.tenant, { name: "beta" }); }); + +test("createKvTenantRegistry: getByOrbInstallationId resolves through the installation:${id} secondary index (#7181)", async () => { + const kv = fakeKv(); + const registry = createKvTenantRegistry(kv); + + await registry.upsert({ ...recordFor("acme"), orbInstallationId: 555 }); + + assert.equal(kv.store.get("installation:555"), "tenant:orb:acme"); + assert.equal((await registry.getByOrbInstallationId(555))?.tenant.name, "acme"); +}); + +test("createKvTenantRegistry: getByOrbInstallationId returns undefined for an installation ID nothing claims", async () => { + const registry = createKvTenantRegistry(fakeKv()); + + assert.equal(await registry.getByOrbInstallationId(999), undefined); +}); + +test("createKvTenantRegistry: re-linking a tenant to a different installation ID clears the stale index entry", async () => { + const kv = fakeKv(); + const registry = createKvTenantRegistry(kv); + await registry.upsert({ ...recordFor("acme"), orbInstallationId: 555 }); + + await registry.upsert({ ...recordFor("acme"), orbInstallationId: 777 }); + + assert.equal(kv.store.get("installation:555"), undefined); + assert.equal(await registry.getByOrbInstallationId(555), undefined); + assert.equal((await registry.getByOrbInstallationId(777))?.tenant.name, "acme"); +}); + +test("createKvTenantRegistry: getByOrbInstallationId tolerates an index entry whose primary key has since disappeared", async () => { + const kv = fakeKv({ "installation:555": "tenant:orb:acme" }); + + const registry = createKvTenantRegistry(kv); + + assert.equal(await registry.getByOrbInstallationId(555), undefined); +}); + +test("createKvTenantRegistry: unlinking a tenant's installation ID (upsert without it) clears the stale index entry", async () => { + const kv = fakeKv(); + const registry = createKvTenantRegistry(kv); + await registry.upsert({ ...recordFor("acme"), orbInstallationId: 555 }); + + await registry.upsert(recordFor("acme")); + + assert.equal(kv.store.get("installation:555"), undefined); + assert.equal(await registry.getByOrbInstallationId(555), undefined); +}); diff --git a/control-plane/wrangler.jsonc b/control-plane/wrangler.jsonc index 692cd32494..3d5b7e5fb4 100644 --- a/control-plane/wrangler.jsonc +++ b/control-plane/wrangler.jsonc @@ -11,6 +11,10 @@ // npx wrangler secret put ADMIN_TOKEN // npx wrangler secret put NEON_API_KEY (only needed to select the real Neon database driver, #7653 -- // omit to keep createTenantProvisioningDriver on the fake driver) + // npx wrangler secret put ORB_WEBHOOK_SECRET (#7181: this hosted fleet's OWN GitHub App webhook secret -- + // a separate value from the main app's ORB_GITHUB_WEBHOOK_SECRET, + // even though both verify the same HMAC-SHA256 shape. Omit to + // leave POST /v1/orb/webhook fail-closed on every delivery.) // // Both container images below build from the REPO ROOT context (image_build_context: ".."), one level up // from this file -- same npm-workspaces reasoning as packages/discovery-index/wrangler.jsonc's identical