From 2bdf0a6cd0f54fc2e8c17707c650b4c2b0386d51 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:15:59 -0700 Subject: [PATCH] feat(control-plane): wire real injectSecrets/revokeSecrets against the main app's token broker (#8066) Closes the gap #7852's control-plane wiring left open: provisionTenant already captured provisionDatabase's real connection details (#7653) into its own result, but discarded them before calling injectSecrets. TenantProvisioningRequest now carries an optional `database` field, attached by provisionTenant right after provisionDatabase resolves and before injectSecrets runs. Adds secret-driver.ts: a real injectSecrets/revokeSecrets implementation calling the main app's own /v1/internal/orb/enrollments (issue, #8064's tenant_db_credential secret type) and its new revoke route -- the same two-step HTTP pattern self-hosted containers already use for their own GitHub-token exchange, just from a different physical caller. Stores the WHOLE DatabaseConnectionDetails object (JSON-encoded), not just the bare connectionString. Scope is deliberately narrow: this only stores/revokes custody of the credential in the broker, not delivery into a running container's environment (separate, not-yet-built infrastructure). injectSecrets now returns an opaque `secretRef` (the broker's enrollId) instead of void; provisionTenant threads it into its own result, http-app.ts persists it on the tenant registry record (product-agnostic, unlike amsSchedule/orbInstallationId), and DELETE /v1/tenants/:name reads it back to pass into deprovisionTenant's new optional secretRef parameter, so revokeSecrets knows what to revoke on teardown. Idempotent: a request with no secretRef never calls the broker at all. withRealSecretDriver (driver-factory.ts) composes the real driver in when MAIN_APP_BASE_URL/INTERNAL_JOB_TOKEN are both configured, mirroring withRealDatabaseDriver/withRealContainerDriver's existing composition shape. The GitHub-token type's existing behavior, and every other driver step, are completely unchanged. This is the last piece of #7852 (itself split from #7180's provisioning core) -- #8064 shipped the broker-side secret type + revoke path this calls. Closes #8066 --- control-plane/src/driver-factory.ts | 39 +++++-- control-plane/src/env.d.ts | 6 + control-plane/src/http-app.ts | 17 ++- control-plane/src/index.ts | 8 ++ control-plane/src/provisioning.ts | 28 +++-- control-plane/src/secret-driver.ts | 108 +++++++++++++++++ .../src/tenant-provisioning-driver.ts | 36 ++++-- control-plane/src/tenant-registry.ts | 14 ++- control-plane/src/worker.ts | 4 +- control-plane/test/driver-factory.test.ts | 76 ++++++++++++ control-plane/test/http-app.test.ts | 79 +++++++++++++ control-plane/test/provisioning.test.ts | 61 ++++++++++ control-plane/test/secret-driver.test.ts | 109 ++++++++++++++++++ control-plane/worker-configuration.d.ts | 5 +- control-plane/wrangler.jsonc | 10 +- 15 files changed, 560 insertions(+), 40 deletions(-) create mode 100644 control-plane/src/secret-driver.ts create mode 100644 control-plane/test/secret-driver.test.ts diff --git a/control-plane/src/driver-factory.ts b/control-plane/src/driver-factory.ts index 3dd9eacc6d..d8922254ed 100644 --- a/control-plane/src/driver-factory.ts +++ b/control-plane/src/driver-factory.ts @@ -1,14 +1,14 @@ -// Selects a fake vs. partially-real `TenantProvisioningDriver` (#7653, #7851) -- the "driver factory" +// Selects a fake vs. partially-real `TenantProvisioningDriver` (#7653, #7851, #8066) -- the "driver factory" // mechanism #7653's own issue text assumed but, per a full repo read at the time it was written, did not yet // exist anywhere in `control-plane/`. Composition, not a second full driver implementation: each // `withReal*Driver` helper takes any base driver and swaps in real methods for its own slice of the // interface (`withRealDatabaseDriver` -> provisionDatabase/dropDatabase, `withRealContainerDriver` -> -// createContainer/destroyContainer/containerExists), leaving every other step exactly as the base driver -// already implements it. This is what let #7653 and #7851 ship independently of each other and of #7852 -// (secret injection, still not landed) -- that piece will compose its own real methods in on top later -// without this file changing. +// createContainer/destroyContainer/containerExists, `withRealSecretDriver` -> injectSecrets/revokeSecrets), +// leaving every other step exactly as the base driver already implements it. This is what let #7653, #7851, +// and #8066 each ship independently of one another. import { createContainerDriver, type ContainerDriver, type ContainerDriverConfig } from "./container-driver.js"; import { createNeonDatabaseDriver, type DatabaseDriver, type NeonDatabaseDriverConfig } from "./neon-database-driver.js"; +import { createSecretDriver, type SecretDriver, type SecretDriverConfig } from "./secret-driver.js"; import { createFakeTenantProvisioningDriver, type TenantProvisioningDriver } from "./tenant-provisioning-driver.js"; function nonBlank(value: string | undefined): string | undefined { @@ -41,13 +41,25 @@ export function withRealContainerDriver(base: TenantProvisioningDriver, containe }; } +/** Compose a real secret driver onto an existing `TenantProvisioningDriver`, overriding only + * `injectSecrets`/`revokeSecrets` (#8066) -- every other step is forwarded to `base` unchanged. Same + * composition shape as `withRealDatabaseDriver`/`withRealContainerDriver` -- independently stackable. */ +export function withRealSecretDriver(base: TenantProvisioningDriver, secretDriver: SecretDriver): TenantProvisioningDriver { + return { + ...base, + injectSecrets: (request) => secretDriver.injectSecrets(request), + revokeSecrets: (request) => secretDriver.revokeSecrets(request), + }; +} + /** Selects real drivers piece by piece as their config becomes available, composed onto the fake for * whatever isn't configured yet: the real Neon database driver when `NEON_API_KEY`/`NEON_PROJECT_ID` are - * set (#7653), and the real Cloudflare Containers driver when `containerBindings` is given (#7851). Takes - * `env` as a plain parameter (defaulting to `process.env`) rather than reading it internally, matching this - * package's existing `ProvisioningPagerDutyOptions.env` seam so callers can inject a fake env in tests - * without any real environment-variable mutation. `containerBindings` is a SEPARATE parameter rather than - * folded into `env`: real Durable Object namespace bindings are live objects only available inside a + * set (#7653), the real Cloudflare Containers driver when `containerBindings` is given (#7851), and the real + * secret driver against the main app's token broker when `MAIN_APP_BASE_URL`/`INTERNAL_JOB_TOKEN` are set + * (#8066). Takes `env` as a plain parameter (defaulting to `process.env`) rather than reading it internally, + * matching this package's existing `ProvisioningPagerDutyOptions.env` seam so callers can inject a fake env + * in tests without any real environment-variable mutation. `containerBindings` is a SEPARATE parameter rather + * than folded into `env`: real Durable Object namespace bindings are live objects only available inside a * Workers runtime, not string env vars -- worker.ts passes them explicitly. */ export function createTenantProvisioningDriver( env: Record = process.env, @@ -66,5 +78,12 @@ export function createTenantProvisioningDriver( driver = withRealContainerDriver(driver, createContainerDriver({ bindings: containerBindings })); } + const mainAppBaseUrl = nonBlank(env.MAIN_APP_BASE_URL); + const internalJobToken = nonBlank(env.INTERNAL_JOB_TOKEN); + if (mainAppBaseUrl && internalJobToken) { + const config: SecretDriverConfig = { baseUrl: mainAppBaseUrl, internalJobToken }; + driver = withRealSecretDriver(driver, createSecretDriver(config)); + } + return driver; } diff --git a/control-plane/src/env.d.ts b/control-plane/src/env.d.ts index fd3bc8e603..6fdbce9bcd 100644 --- a/control-plane/src/env.d.ts +++ b/control-plane/src/env.d.ts @@ -18,6 +18,12 @@ declare global { * 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; + /** Bearer-gates the main app's own `/v1/internal/*` routes (#8066) -- the SAME shared secret value as that + * deployment's own INTERNAL_JOB_TOKEN, since this driver is calling those routes as an authenticated + * internal caller. Selects the real secret driver (src/secret-driver.ts) together with MAIN_APP_BASE_URL + * (a plain var, see wrangler.jsonc); createTenantProvisioningDriver falls back to the fake otherwise. + * Genuinely sensitive: whoever holds it can call every internal admin route in the main app. */ + INTERNAL_JOB_TOKEN?: string; } } diff --git a/control-plane/src/http-app.ts b/control-plane/src/http-app.ts index 0e90f58a00..c371ce8643 100644 --- a/control-plane/src/http-app.ts +++ b/control-plane/src/http-app.ts @@ -9,9 +9,10 @@ // Deliberately never echoes a tenant's database connection details (host/user/password/connectionString) in // any response: `provisionTenant`'s result carries them (#7653) so a caller doesn't lose them, but this admin // HTTP surface only returns the safe `{tenant, product, state}` triple (plus `amsSchedule` when set, #7182 -- -// a cron cadence and command name, never a secret). Properly storing/distributing credentials is #7852's job -// (the generalized secret broker) -- until it lands, this transport intentionally does not create a new -// place for them to leak. +// a cron cadence and command name; `orbInstallationId`, #7181; `secretRef`, #8066 -- an opaque broker +// enrollment ID, never a secret value itself). The generalized broker (#7174's src/orb/broker.ts, via #8064's +// stored-secret type and #8066's secret-driver.ts) holds actual custody of a tenant's real credentials -- +// this transport never has them to leak in the first place once a real secret driver is configured. // // `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 @@ -21,6 +22,10 @@ // `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. +// +// `DELETE /v1/tenants/:name` threads the stored `secretRef` (#8066) back into `deprovisionTenant`, so a real +// secret driver's `revokeSecrets` knows which broker enrollment to revoke on teardown -- without it, a torn- +// down tenant's stored credential would stay valid in the broker forever. import { Hono } from "hono"; import { normalizeSharedSecret, verifyBearer } from "./auth.js"; import { routeOrbWebhook, type RouterNamespaceLike } from "./orb-webhook-router.js"; @@ -48,13 +53,14 @@ export type TenantHttpAppDeps = { orbWebhookSecret?: string; }; -function safeRecord(record: Pick): Record { +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 } : {}), + ...(record.secretRef !== undefined ? { secretRef: record.secretRef } : {}), }; } @@ -186,6 +192,7 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { updatedAt: now, ...(schedule ? { amsSchedule: schedule } : {}), ...(orbInstallationId !== undefined ? { orbInstallationId } : {}), + ...(result.secretRef !== undefined ? { secretRef: result.secretRef } : {}), }; await deps.registry.upsert(record); return c.json(safeRecord(record), 201); @@ -243,7 +250,7 @@ export function createTenantHttpApp(deps: TenantHttpAppDeps): Hono { const existing = await deps.registry.get(name, product); if (!existing) return c.json({ error: "tenant_not_found" }, 404); - const result = await deprovisionTenant(existing.tenant, existing.product, deps.driver, deps.pagerDuty ?? {}); + const result = await deprovisionTenant(existing.tenant, existing.product, deps.driver, deps.pagerDuty ?? {}, existing.secretRef); await deps.registry.upsert({ tenant: result.tenant, product: result.product, state: result.state, createdAt: existing.createdAt, updatedAt: new Date().toISOString() }); return c.json(safeRecord(result)); }); diff --git a/control-plane/src/index.ts b/control-plane/src/index.ts index 265a01ff50..6bf134eb57 100644 --- a/control-plane/src/index.ts +++ b/control-plane/src/index.ts @@ -50,7 +50,15 @@ export { createTenantProvisioningDriver, withRealContainerDriver, withRealDatabaseDriver, + withRealSecretDriver, } from "./driver-factory.js"; +export { + createSecretDriver, + injectTenantSecrets, + revokeTenantSecrets, + type SecretDriver, + type SecretDriverConfig, +} from "./secret-driver.js"; export { createContainerDriver, createTenantContainer, diff --git a/control-plane/src/provisioning.ts b/control-plane/src/provisioning.ts index e8f3abfd76..3971de6cfd 100644 --- a/control-plane/src/provisioning.ts +++ b/control-plane/src/provisioning.ts @@ -28,13 +28,16 @@ import type { /** Result of a successful provision — terminal lifecycle state `"active"` (the vocabulary tenant-client.ts * passes through from this API). Carries `database` (#7653) so a freshly created role's connection details -- * often retrievable from the provider only at creation time -- aren't silently discarded by this orchestration - * before any caller gets a chance to persist them. What a caller DOES with them (e.g. routing into - * `injectSecrets` via #7852's secret-injection driver) is that driver's own job, not this orchestration's. */ + * before any caller gets a chance to persist them. Also carries `secretRef` (#8066) when the configured driver + * returned one from `injectSecrets` -- an opaque reference a caller (e.g. http-app.ts's tenant registry) must + * persist so a later `deprovisionTenant` can thread it back in for `revokeSecrets`; absent when the composed + * driver has nothing to track (e.g. the fake, or before a real secret driver is configured). */ export type TenantProvisioningResult = { tenant: Tenant; product: Product; state: Extract; database: DatabaseConnectionDetails; + secretRef?: string; }; /** Result of a successful deprovision — terminal lifecycle state `"torn down"`. */ @@ -79,8 +82,10 @@ function pageAndRethrow( } /** Provision a tenant by running #7180's three steps in order against the injected driver. Product-agnostic: - * `product` is forwarded to every step, never branched on, so ORB and AMS share one call shape. A step failure - * pages (#7667) and always rethrows — provisioning never fails silently. */ + * `product` is forwarded to every step, never branched on, so ORB and AMS share one call shape. `injectSecrets` + * is called with `database` already attached to the request (#8066) -- a real secret driver needs the + * connection details to actually store, not just the tenant identity every other step operates on. A step + * failure pages (#7667) and always rethrows — provisioning never fails silently. */ export async function provisionTenant( tenant: Tenant, product: Product, @@ -89,26 +94,31 @@ export async function provisionTenant( ): Promise { const request: TenantProvisioningRequest = { tenant, product }; let database: DatabaseConnectionDetails; + let secretRef: string | undefined; try { await driver.createContainer(request); database = await driver.provisionDatabase(request); - await driver.injectSecrets(request); + ({ secretRef } = await driver.injectSecrets({ ...request, database })); } catch (error) { pageAndRethrow(tenant, product, "provision", error, pagerDuty); } - return { tenant, product, state: "active", database }; + return { tenant, product, state: "active", database, ...(secretRef !== undefined ? { secretRef } : {}) }; } /** Deprovision a tenant by tearing #7180's three steps down in REVERSE order. Same product-agnostic call shape - * as provisionTenant. Idempotent by driver contract: deprovisioning a tenant that was never provisioned is a - * safe no-op, never a throw. A step failure pages (#7667) and always rethrows. */ + * as provisionTenant. `secretRef` (#8066, optional -- a caller with no real secret driver configured, or a + * tenant provisioned before one was, has none to pass) is attached to the request so `revokeSecrets` knows + * what to revoke; omitted entirely, it's the same as the pre-#8066 behavior. Idempotent by driver contract: + * deprovisioning a tenant that was never provisioned is a safe no-op, never a throw. A step failure pages + * (#7667) and always rethrows. */ export async function deprovisionTenant( tenant: Tenant, product: Product, driver: TenantProvisioningDriver, pagerDuty: ProvisioningPagerDutyOptions = {}, + secretRef?: string, ): Promise { - const request: TenantProvisioningRequest = { tenant, product }; + const request: TenantProvisioningRequest = { tenant, product, ...(secretRef !== undefined ? { secretRef } : {}) }; try { await driver.revokeSecrets(request); await driver.dropDatabase(request); diff --git a/control-plane/src/secret-driver.ts b/control-plane/src/secret-driver.ts new file mode 100644 index 0000000000..70fda7bc2e --- /dev/null +++ b/control-plane/src/secret-driver.ts @@ -0,0 +1,108 @@ +// Real `injectSecrets`/`revokeSecrets` implementation against the main app's token broker (#8066 -- the last +// remaining piece of #7852/#7180's provisioning core; #8064 already shipped the broker-side stored-secret type +// + revoke path this calls). +// +// control-plane's own Worker has no D1 binding of its own (src/orb/broker.ts is 100% D1-bound) and there is no +// Worker-to-Worker service-binding precedent anywhere in this repo (zero `services:` entries in any +// wrangler.jsonc) -- the established pattern self-hosted containers already use instead: `POST +// /v1/internal/orb/enrollments` to mint/store an enrollment, `POST /v1/orb/token` to exchange it. This driver +// calls the SAME two routes, just to STORE a tenant's DB credential rather than mint a GitHub token (#8064's +// `tenant_db_credential` secret type), plus a third route (#8064) to revoke it on teardown. +// +// Scope, deliberately narrow: this ONLY stores/revokes custody of the credential in the broker. It does NOT +// deliver the secret into a running container's environment -- that's separate, not-yet-built infrastructure +// (a container's own bootstrap would need to independently exchange its own enrollment secret, the same way a +// self-hosted container already does against `/v1/orb/token` today). #8066's own boundary excludes it. +// +// Deliberately does NOT implement the full `TenantProvisioningDriver` interface -- only injectSecrets/ +// revokeSecrets (see `SecretDriver` below). `withRealSecretDriver` (driver-factory.ts) composes this onto an +// otherwise fake/real-mixed driver, same shape as `withRealDatabaseDriver`/`withRealContainerDriver`. +import type { TenantProvisioningRequest } from "./tenant-provisioning-driver.js"; + +const DEFAULT_TIMEOUT_MS = 10_000; + +// #8064's own `ORB_SECRET_TYPE_TENANT_DB_CREDENTIAL` constant, duplicated here rather than imported -- this +// package has no dependency on the main app's `src/` (a separate npm workspace, its own package.json/tsconfig), +// matching this package's established "no cross-package import" convention (see http-app.ts's +// `HOSTED_CYCLE_COMMANDS` for the identical reasoning against packages/loopover-miner). +const SECRET_TYPE_TENANT_DB_CREDENTIAL = "tenant_db_credential"; + +export type SecretDriverConfig = { + /** The main app's own base URL (e.g. `https://api.loopover.ai`) -- the same host self-hosted containers + * already call to exchange their own enrollment secret for a GitHub token (src/orb/broker-client.ts's own + * default). */ + baseUrl: string; + /** `INTERNAL_JOB_TOKEN` -- Bearer-gates every `/v1/internal/*` route in the main app, including the + * enrollment issuance/revoke routes this driver calls. */ + internalJobToken: string; + /** Override for tests only -- production always uses the real `fetch`. */ + fetchImpl?: typeof fetch; +}; + +/** The secret-only slice of `TenantProvisioningDriver` this module actually implements. Composed onto a full + * driver by `withRealSecretDriver` (driver-factory.ts), never used standalone against `provisionTenant`. */ +export type SecretDriver = { + injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string }>; + revokeSecrets(request: TenantProvisioningRequest): Promise; +}; + +class MainAppApiError extends Error { + constructor(method: string, path: string, status: number, body: string) { + super(`Main app API ${method} ${path} failed (${status}): ${body.slice(0, 500)}`); + this.name = "MainAppApiError"; + } +} + +async function mainAppFetch(config: SecretDriverConfig, method: string, path: string, body?: unknown): Promise { + const fetchImpl = config.fetchImpl ?? fetch; + const response = await fetchImpl(`${config.baseUrl}${path}`, { + method, + headers: { authorization: `Bearer ${config.internalJobToken}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + const text = await response.text(); + if (!response.ok) throw new MainAppApiError(method, path, response.status, text); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** Stores a tenant's already-provisioned database connection details (`request.database`, #7653) in the main + * app's token broker as a #8064 `tenant_db_credential` enrollment. The WHOLE `DatabaseConnectionDetails` + * object is stored (JSON-encoded), not just the bare `connectionString` -- a later reader gets every field + * back, not just what it can re-parse out of a URI, mirroring that type's own "kept alongside the parts" + * rationale. Returns the enrollment's `enrollId` as this driver's `secretRef` -- the caller (`provisionTenant`, + * via its own result) must persist this to revoke it later; the one-time exchange `secret` is intentionally + * discarded here, since this driver's job ends at custody, not consumption (see this file's header comment). */ +export async function injectTenantSecrets(config: SecretDriverConfig, request: TenantProvisioningRequest): Promise<{ secretRef?: string }> { + if (!request.database) { + throw new Error(`injectTenantSecrets: no database connection details on the request for tenant "${request.tenant.name}"`); + } + // The route's own error responses (#8064: secret_value_required/encryption_unavailable) always pair an + // `{error}` body with a non-2xx status -- mainAppFetch already throws on those, so there's no in-band + // `{error}`-at-200 shape for this driver to check for separately. + const result = await mainAppFetch<{ enrollId: string; secret: string }>( + config, + "POST", + "/v1/internal/orb/enrollments", + { secretType: SECRET_TYPE_TENANT_DB_CREDENTIAL, secretValue: JSON.stringify(request.database) }, + ); + return { secretRef: result.enrollId }; +} + +/** Idempotent teardown: a request with no `secretRef` (never provisioned with a real secret driver, or already + * revoked and cleared from the tenant registry) is a safe no-op that never calls the broker at all -- there is + * nothing to revoke and no `enrollId` to address a call with, matching every other driver's teardown contract + * in this codebase. */ +export async function revokeTenantSecrets(config: SecretDriverConfig, request: TenantProvisioningRequest): Promise { + if (!request.secretRef) return; + await mainAppFetch(config, "POST", `/v1/internal/orb/enrollments/${request.secretRef}/revoke`, undefined); +} + +/** Bundles {@link injectTenantSecrets}/{@link revokeTenantSecrets} as a {@link SecretDriver} closed over one + * config -- the shape `withRealSecretDriver` composes onto a full `TenantProvisioningDriver`. */ +export function createSecretDriver(config: SecretDriverConfig): SecretDriver { + return { + injectSecrets: (request) => injectTenantSecrets(config, request), + revokeSecrets: (request) => revokeTenantSecrets(config, request), + }; +} diff --git a/control-plane/src/tenant-provisioning-driver.ts b/control-plane/src/tenant-provisioning-driver.ts index 3931174168..2f0c12d225 100644 --- a/control-plane/src/tenant-provisioning-driver.ts +++ b/control-plane/src/tenant-provisioning-driver.ts @@ -3,11 +3,12 @@ // contract plus a minimal in-memory fake, with the orchestration (provisionTenant/deprovisionTenant) living in // a sibling module. Implementations MAY perform real IO; this file defines only the contract and the fake. // -// The interface names the three provisioning steps #7180's provisioning API is specified around: -// create-container, provision-DB, inject-secrets. Real drivers are OUT OF SCOPE for #7524 (blocked on an -// unmade Postgres-provider decision): a real create-container would call the Cloudflare Containers API, a real -// provision-DB the chosen Postgres provider, and a real inject-secrets would delegate to #7174's generalized -// secret broker (src/orb/broker.ts). NONE of those live paths are imported here — only the fake is. +// The interface names the three provisioning steps #7180's provisioning API is specified around: create- +// container, provision-DB, inject-secrets. Real drivers were out of scope for #7524 itself but have since +// landed as separate, independently-composable pieces (container-driver.ts/#7851, neon-database-driver.ts/ +// #7653, secret-driver.ts/#8066 delegating to #7174's generalized broker via #8064's stored-secret type) -- +// see driver-factory.ts for how they compose onto this file's fake. NONE of those live paths are imported here +// — only the fake is. /** Product a tenant belongs to (e.g. `"orb"` / `"ams"`). Opaque to the orchestration and forwarded verbatim to * every driver step — an ORB tenant and an AMS tenant take the identical call shape (#7524's product-agnostic @@ -37,6 +38,15 @@ export type TenantLifecycleState = export type TenantProvisioningRequest = { tenant: Tenant; product: Product; + /** The tenant's already-provisioned database connection details (#7653) -- populated ONLY for the + * `injectSecrets` call, by `provisionTenant`'s own orchestration right after `provisionDatabase` resolves + * (#8066). Every other step (createContainer, destroyContainer, etc.) never sees this field. */ + database?: DatabaseConnectionDetails; + /** An opaque, driver-specific reference to a previously injected secret (#8066) -- whatever `injectSecrets` + * returned as `secretRef`, threaded back in by `deprovisionTenant` so `revokeSecrets` knows what to revoke. + * Absent when a tenant was never provisioned with a real secret driver configured (idempotent revoke of an + * unconfigured tenant, matching every other driver's teardown contract). */ + secretRef?: string; }; /** What `provisionDatabase` hands back (#7653): everything a caller needs to actually reach the tenant's @@ -63,15 +73,20 @@ export interface TenantProvisioningDriver { * must capture this return value rather than re-deriving it later. Real driver → the chosen Postgres * provider (Neon + Hyperdrive, decided on #7180; see neon-database-driver.ts). */ provisionDatabase(request: TenantProvisioningRequest): Promise; - /** Step 3 (#7180): inject the tenant's secrets. A real driver delegates to #7174's generalized broker - * (src/orb/broker.ts); the fake only records the call. No real secrets path is imported by this package. */ - injectSecrets(request: TenantProvisioningRequest): Promise; + /** Step 3 (#7180): inject the tenant's secrets, given its database connection details (`request.database`, + * #8066). Returns an opaque `secretRef` the caller must persist and thread back into a later `revokeSecrets` + * call via `request.secretRef` -- `undefined` when the driver has nothing to track (e.g. the fake). A real + * driver delegates to #7174's generalized broker (src/orb/broker.ts, via #8064's stored-secret type); the + * fake only records the call. */ + injectSecrets(request: TenantProvisioningRequest): Promise<{ secretRef?: string }>; /** Teardown inverse of createContainer. MUST be idempotent — safe to call when the container was never * created — so deprovisioning a nonexistent tenant is a no-op, never a throw. */ destroyContainer(request: TenantProvisioningRequest): Promise; /** Teardown inverse of provisionDatabase. Idempotent, like destroyContainer. */ dropDatabase(request: TenantProvisioningRequest): Promise; - /** Teardown inverse of injectSecrets. Idempotent, like destroyContainer. */ + /** Teardown inverse of injectSecrets. `request.secretRef` (set by the caller from whatever `injectSecrets` + * previously returned) tells the driver what to revoke; absent/unrecognized is a safe no-op, matching this + * driver contract's existing idempotent-teardown convention. */ revokeSecrets(request: TenantProvisioningRequest): Promise; /** Reachability probe: is the tenant's container currently provisioned? A real driver health-checks the * container; the fake checks its in-memory map. Lets callers/tests assert "exists" after provision and @@ -166,6 +181,9 @@ export function createFakeTenantProvisioningDriver(): FakeTenantProvisioningDriv async injectSecrets(request) { record("injectSecrets", request); injectedSecrets.add(instanceKeyFor(request)); + // The fake tracks "injected" via its own `injectedSecrets` set (keyed by tenant), not by a real opaque + // reference -- it has nothing for a caller to persist and thread back into revokeSecrets later. + return {}; }, async destroyContainer(request) { record("destroyContainer", request); diff --git a/control-plane/src/tenant-registry.ts b/control-plane/src/tenant-registry.ts index bef07c175c..f3e7ff9d1f 100644 --- a/control-plane/src/tenant-registry.ts +++ b/control-plane/src/tenant-registry.ts @@ -1,9 +1,10 @@ // Tenant registry for control-plane's real HTTP transport (#7654). `TenantProvisioningDriver` has no // enumeration concept by design (create/destroy/exists are all per-tenant) -- `GET /v1/tenants` needs a // distinct, durable list of every tenant this service has been asked to create, independent of whatever a -// given driver internally tracks. Deliberately stores ONLY name/product/lifecycle state/timestamps, never a -// tenant's database connection details or any other secret -- this is an admin-visible inventory, not a -// credential store (that's #7852's job, via the generalized broker). +// given driver internally tracks. Deliberately stores ONLY name/product/lifecycle state/timestamps (plus an +// opaque broker `secretRef`, #8066) -- never a tenant's actual database connection details or any other +// secret VALUE. This is an admin-visible inventory, not a credential store; the generalized broker (#7174's +// src/orb/broker.ts, via #8064/#8066) holds custody of the real secret, addressed by this opaque reference. import type { Product, Tenant, TenantLifecycleState } from "./tenant-provisioning-driver.js"; /** One AMS tenant's cron-wake configuration (#7182) -- ORB tenants never have this (they're woken by @@ -38,6 +39,13 @@ export type TenantRegistryRecord = { * `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; + /** Opaque broker reference to this tenant's injected secrets (#8066) -- whatever `provisionTenant`'s own + * result carried back as `secretRef` from `injectSecrets`. Product-agnostic (unlike `amsSchedule`/ + * `orbInstallationId`): either product can have a real secret driver configured. Persisted here so a later + * `DELETE /v1/tenants/:name` can thread it back into `deprovisionTenant`'s `revokeSecrets` call -- without + * it, a torn-down tenant's stored broker secret would never be revoked. Not itself sensitive (an enrollment + * ID, not a credential), so it's safe to surface via `GET /v1/tenants` like every other field here. */ + secretRef?: string; }; export interface TenantRegistry { diff --git a/control-plane/src/worker.ts b/control-plane/src/worker.ts index 01e2fa708a..b1df384122 100644 --- a/control-plane/src/worker.ts +++ b/control-plane/src/worker.ts @@ -54,7 +54,9 @@ export class AmsTenantContainer extends ProvisionedContainer { export default { async fetch(request: Request, env: Env): Promise { const driver = createTenantProvisioningDriver( - { NEON_API_KEY: env.NEON_API_KEY, NEON_PROJECT_ID: env.NEON_PROJECT_ID }, + // #8066: MAIN_APP_BASE_URL/INTERNAL_JOB_TOKEN select the real secret driver (against the main app's + // token broker) once both are configured, same opt-in-per-piece shape as NEON_API_KEY/NEON_PROJECT_ID. + { NEON_API_KEY: env.NEON_API_KEY, NEON_PROJECT_ID: env.NEON_PROJECT_ID, MAIN_APP_BASE_URL: env.MAIN_APP_BASE_URL, INTERNAL_JOB_TOKEN: env.INTERNAL_JOB_TOKEN }, { orb: env.ORB_TENANT_CONTAINER, ams: env.AMS_TENANT_CONTAINER }, ); const app = createTenantHttpApp({ diff --git a/control-plane/test/driver-factory.test.ts b/control-plane/test/driver-factory.test.ts index 0241d49db1..a91c93fc83 100644 --- a/control-plane/test/driver-factory.test.ts +++ b/control-plane/test/driver-factory.test.ts @@ -8,10 +8,12 @@ import { createTenantProvisioningDriver, withRealContainerDriver, withRealDatabaseDriver, + withRealSecretDriver, type ContainerDriver, type ContainerNamespaceLike, type ContainerStubLike, type DatabaseDriver, + type SecretDriver, type TenantProvisioningRequest, } from "../dist/index.js"; @@ -119,6 +121,36 @@ test("withRealContainerDriver: overrides createContainer/destroyContainer/contai assert.ok(base.injectedSecrets.has("orb:acme")); }); +test("withRealSecretDriver: overrides injectSecrets/revokeSecrets, forwards every other step to base", async () => { + const base = createFakeTenantProvisioningDriver(); + const calls: string[] = []; + const secretDriver: SecretDriver = { + injectSecrets: async () => { + calls.push("real-inject"); + return { secretRef: "real-secret-ref" }; + }, + revokeSecrets: async () => { + calls.push("real-revoke"); + }, + }; + + const composed = withRealSecretDriver(base, secretDriver); + + const injected = await composed.injectSecrets(REQUEST); + assert.deepEqual(injected, { secretRef: "real-secret-ref" }); + await composed.revokeSecrets({ ...REQUEST, secretRef: injected.secretRef }); + assert.deepEqual(calls, ["real-inject", "real-revoke"]); + // The fake's own injectSecrets never ran -- its `injectedSecrets` set stays empty even though the composed + // driver's own calls all resolved successfully. + assert.equal(base.injectedSecrets.has("orb:acme"), false); + + // Every non-secret step still runs against `base` exactly as before composition. + await composed.createContainer(REQUEST); + assert.ok(base.containers.has("orb:acme")); + const details = await composed.provisionDatabase(REQUEST); + assert.equal(details.host, "fake-acme.control-plane.invalid"); +}); + test("createTenantProvisioningDriver: falls back to the fake container behavior when containerBindings is omitted or empty", async () => { const noBindings = createTenantProvisioningDriver({}, undefined); const emptyBindings = createTenantProvisioningDriver({}, {}); @@ -197,6 +229,50 @@ test("createTenantProvisioningDriver: selects the real Neon-backed driver when b assert.ok(calls.every((url) => url.includes("real-project"))); }); +test("createTenantProvisioningDriver: falls back to the fake secret behavior when MAIN_APP_BASE_URL/INTERNAL_JOB_TOKEN are unset", async () => { + const neither = createTenantProvisioningDriver({}); + const urlOnly = createTenantProvisioningDriver({ MAIN_APP_BASE_URL: "https://api.loopover.test" }); + const tokenOnly = createTenantProvisioningDriver({ INTERNAL_JOB_TOKEN: "internal-test-token" }); + + // The fake's own injectSecrets never calls fetch -- if the real secret driver were selected in any of these, + // this would attempt a real network call and reject instead of resolving. + await neither.injectSecrets(REQUEST); + await urlOnly.injectSecrets(REQUEST); + await tokenOnly.injectSecrets(REQUEST); +}); + +test("createTenantProvisioningDriver: selects the real secret driver when both MAIN_APP_BASE_URL and INTERNAL_JOB_TOKEN are configured", async () => { + const calls: string[] = []; + globalThis.fetch = (async (url: string) => { + calls.push(url); + return new Response(JSON.stringify({ enrollId: "orbenr_abc", secret: "orbsec_xyz" }), { status: 200 }); + }) as unknown as typeof fetch; + + const driver = createTenantProvisioningDriver({ MAIN_APP_BASE_URL: "https://api.loopover.test", INTERNAL_JOB_TOKEN: "internal-test-token" }); + + const result = await driver.injectSecrets({ ...REQUEST, database: { host: "h", port: 5432, database: "d", user: "u", password: "p", connectionString: "postgres://u:p@h:5432/d" } }); + assert.deepEqual(result, { secretRef: "orbenr_abc" }); + assert.ok(calls.some((url) => url.includes("api.loopover.test"))); +}); + +test("createTenantProvisioningDriver: composes the real database, container, AND secret drivers all together", async () => { + globalThis.fetch = (async (url: string) => { + if (url.includes("api.loopover.test")) return new Response(JSON.stringify({ enrollId: "orbenr_abc", secret: "orbsec_xyz" }), { status: 200 }); + return new Response(JSON.stringify({ branches: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + const driver = createTenantProvisioningDriver( + { NEON_API_KEY: "real-key", NEON_PROJECT_ID: "real-project", MAIN_APP_BASE_URL: "https://api.loopover.test", INTERNAL_JOB_TOKEN: "internal-test-token" }, + { orb: fakeContainerNamespace() }, + ); + + await driver.createContainer(REQUEST); + assert.equal(await driver.containerExists(REQUEST), true); + await assert.rejects(driver.provisionDatabase(REQUEST)); + const injected = await driver.injectSecrets({ ...REQUEST, database: { host: "h", port: 5432, database: "d", user: "u", password: "p", connectionString: "postgres://u:p@h:5432/d" } }); + assert.deepEqual(injected, { secretRef: "orbenr_abc" }); +}); + test("createTenantProvisioningDriver: defaults env to process.env when no override is passed", async () => { const hadKey = Object.prototype.hasOwnProperty.call(process.env, "NEON_API_KEY"); const previousKey = process.env.NEON_API_KEY; diff --git a/control-plane/test/http-app.test.ts b/control-plane/test/http-app.test.ts index 2c6277611f..cb37681870 100644 --- a/control-plane/test/http-app.test.ts +++ b/control-plane/test/http-app.test.ts @@ -319,6 +319,85 @@ test("GET /v1/tenants surfaces an ORB tenant's orbInstallationId when set", asyn assert.equal(payload.tenants[0]?.orbInstallationId, 555); }); +test("#8066: POST /v1/tenants persists and surfaces the secretRef a real secret driver returns from injectSecrets", async () => { + const registry = createFakeTenantRegistry(); + const fake = createFakeTenantProvisioningDriver(); + const driver: TenantProvisioningDriver = { ...fake, injectSecrets: async () => ({ secretRef: "orbenr_abc" }) }; + const app = createTenantHttpApp(baseDeps({ registry, driver })); + + 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 { secretRef?: string }; + assert.equal(payload.secretRef, "orbenr_abc"); + assert.equal((await registry.get("acme", "orb"))?.secretRef, "orbenr_abc"); +}); + +test("#8066: POST /v1/tenants against the plain fake driver creates a tenant with no secretRef 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" }) }), + ); + + const payload = (await res.json()) as Record; + assert.equal("secretRef" in payload, false); + assert.equal((await registry.get("acme", "orb"))?.secretRef, undefined); +}); + +test("#8066: GET /v1/tenants surfaces a tenant's secretRef when set", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", secretRef: "orbenr_abc" }); + const app = createTenantHttpApp(baseDeps({ registry })); + + const res = await app.request("/v1/tenants", authed()); + + const payload = (await res.json()) as { tenants: Array<{ secretRef?: string }> }; + assert.equal(payload.tenants[0]?.secretRef, "orbenr_abc"); +}); + +test("#8066: DELETE /v1/tenants/:name threads the stored secretRef into deprovisionTenant's revokeSecrets call", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0", secretRef: "orbenr_abc" }); + const fake = createFakeTenantProvisioningDriver(); + let seenSecretRef: string | undefined; + const driver: TenantProvisioningDriver = { + ...fake, + revokeSecrets: async (request) => { + seenSecretRef = request.secretRef; + }, + }; + const app = createTenantHttpApp(baseDeps({ registry, driver })); + + const res = await app.request("/v1/tenants/acme?product=orb", authed({ method: "DELETE" })); + + assert.equal(res.status, 200); + assert.equal(seenSecretRef, "orbenr_abc"); +}); + +test("#8066: DELETE /v1/tenants/:name on a tenant with no secretRef threads undefined through (no stale value leaks in)", async () => { + const registry = createFakeTenantRegistry(); + await registry.upsert({ tenant: { name: "acme" }, product: "orb", state: "active", createdAt: "t0", updatedAt: "t0" }); + const fake = createFakeTenantProvisioningDriver(); + let seenSecretRef: string | undefined | "unset" = "unset"; + const driver: TenantProvisioningDriver = { + ...fake, + revokeSecrets: async (request) => { + seenSecretRef = request.secretRef; + }, + }; + const app = createTenantHttpApp(baseDeps({ registry, driver })); + + await app.request("/v1/tenants/acme?product=orb", authed({ method: "DELETE" })); + + assert.equal(seenSecretRef, undefined); +}); + test("GET /v1/tenants surfaces an AMS tenant's amsSchedule when set", async () => { const registry = createFakeTenantRegistry(); await registry.upsert({ diff --git a/control-plane/test/provisioning.test.ts b/control-plane/test/provisioning.test.ts index fd93ff015c..090d51bbed 100644 --- a/control-plane/test/provisioning.test.ts +++ b/control-plane/test/provisioning.test.ts @@ -9,6 +9,8 @@ import { deprovisionTenant, provisionTenant, type Tenant, + type TenantProvisioningDriver, + type TenantProvisioningRequest, } from "../dist/index.js"; test("provisionTenant runs the three #7180 steps in order and reports the tenant active", async () => { @@ -86,6 +88,65 @@ test("deprovisionTenant on a never-provisioned tenant is a safe no-op that still assert.equal(driver.containers.has("ams:ghost"), false); }); +test("#8066: provisionTenant attaches the freshly provisioned database to the injectSecrets request", async () => { + const fake = createFakeTenantProvisioningDriver(); + let seenRequest: TenantProvisioningRequest | undefined; + const driver: TenantProvisioningDriver = { + ...fake, + injectSecrets: async (request) => { + seenRequest = request; + return { secretRef: "orbenr_abc" }; + }, + }; + const tenant: Tenant = { name: "acme" }; + + const result = await provisionTenant(tenant, "orb", driver); + + assert.deepEqual(seenRequest?.database, result.database); + assert.equal(result.secretRef, "orbenr_abc"); +}); + +test("#8066: provisionTenant's result omits secretRef entirely when the driver returns none (the fake's own behavior)", async () => { + const driver = createFakeTenantProvisioningDriver(); + const tenant: Tenant = { name: "acme" }; + + const result = await provisionTenant(tenant, "orb", driver); + + assert.equal("secretRef" in result, false); +}); + +test("#8066: deprovisionTenant threads secretRef into the revokeSecrets request", async () => { + const fake = createFakeTenantProvisioningDriver(); + let seenRequest: TenantProvisioningRequest | undefined; + const driver: TenantProvisioningDriver = { + ...fake, + revokeSecrets: async (request) => { + seenRequest = request; + }, + }; + const tenant: Tenant = { name: "acme" }; + + await deprovisionTenant(tenant, "orb", driver, {}, "orbenr_abc"); + + assert.equal(seenRequest?.secretRef, "orbenr_abc"); +}); + +test("#8066: deprovisionTenant omits secretRef from the request entirely when none is passed", async () => { + const fake = createFakeTenantProvisioningDriver(); + let seenRequest: TenantProvisioningRequest | undefined; + const driver: TenantProvisioningDriver = { + ...fake, + revokeSecrets: async (request) => { + seenRequest = request; + }, + }; + const tenant: Tenant = { name: "acme" }; + + await deprovisionTenant(tenant, "orb", driver); + + assert.equal("secretRef" in (seenRequest ?? {}), false); +}); + test("the call shape is identical for an ORB tenant and an AMS tenant (product-agnostic)", async () => { const orb = createFakeTenantProvisioningDriver(); const ams = createFakeTenantProvisioningDriver(); diff --git a/control-plane/test/secret-driver.test.ts b/control-plane/test/secret-driver.test.ts new file mode 100644 index 0000000000..ba047ac81d --- /dev/null +++ b/control-plane/test/secret-driver.test.ts @@ -0,0 +1,109 @@ +// Tests for the real secret driver against the main app's token broker (#8066). No live main-app deployment or +// live broker calls anywhere here -- `fetchImpl` is an injected stub (SecretDriverConfig's own test-only seam), +// mirroring ams-wake.ts's own fake-binding convention rather than neon-database-driver.test.ts's globalThis-stub +// one, since this driver's config already has a dedicated override point. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createSecretDriver, injectTenantSecrets, revokeTenantSecrets, type DatabaseConnectionDetails, type SecretDriverConfig, type TenantProvisioningRequest } from "../dist/index.js"; + +const DATABASE: DatabaseConnectionDetails = { + host: "fake-acme.control-plane.invalid", + port: 5432, + database: "acme", + user: "acme", + password: "fake-password-acme", + connectionString: "postgres://acme:fake-password-acme@fake-acme.control-plane.invalid:5432/acme", +}; + +const REQUEST: TenantProvisioningRequest = { tenant: { name: "acme" }, product: "orb", database: DATABASE }; + +function fakeFetch(handler: (url: string, init: RequestInit) => Response): { fetchImpl: typeof fetch; calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl = (async (url: string | URL, init: RequestInit = {}) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +function config(fetchImpl: typeof fetch): SecretDriverConfig { + return { baseUrl: "https://api.loopover.test", internalJobToken: "internal-test-token", fetchImpl }; +} + +test("injectTenantSecrets: stores the WHOLE database connection details JSON-encoded, returns enrollId as secretRef", async () => { + const { fetchImpl, calls } = fakeFetch(() => Response.json({ enrollId: "orbenr_abc", secret: "orbsec_xyz" }, { status: 200 })); + + const result = await injectTenantSecrets(config(fetchImpl), REQUEST); + + assert.deepEqual(result, { secretRef: "orbenr_abc" }); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.url, "https://api.loopover.test/v1/internal/orb/enrollments"); + assert.equal(calls[0]!.init.method, "POST"); + assert.equal((calls[0]!.init.headers as Record).authorization, "Bearer internal-test-token"); + const body = JSON.parse(calls[0]!.init.body as string) as { secretType: string; secretValue: string }; + assert.equal(body.secretType, "tenant_db_credential"); + assert.deepEqual(JSON.parse(body.secretValue), DATABASE); +}); + +test("injectTenantSecrets: throws when the request has no database connection details attached", async () => { + const { fetchImpl, calls } = fakeFetch(() => Response.json({ enrollId: "x", secret: "y" })); + + await assert.rejects( + injectTenantSecrets(config(fetchImpl), { tenant: { name: "acme" }, product: "orb" }), + /no database connection details/, + ); + assert.equal(calls.length, 0); +}); + +test("injectTenantSecrets: surfaces a broker-side error (e.g. no encryption key configured) as a thrown error", async () => { + const { fetchImpl } = fakeFetch(() => Response.json({ error: "encryption_unavailable" }, { status: 503 })); + + await assert.rejects(injectTenantSecrets(config(fetchImpl), REQUEST), /Main app API POST \/v1\/internal\/orb\/enrollments failed \(503\)/); +}); + +test("revokeTenantSecrets: no-ops without ever calling the broker when the request has no secretRef", async () => { + const { fetchImpl, calls } = fakeFetch(() => Response.json({ revoked: true })); + + await revokeTenantSecrets(config(fetchImpl), { tenant: { name: "acme" }, product: "orb" }); + + assert.equal(calls.length, 0); +}); + +test("revokeTenantSecrets: calls the revoke route for the given secretRef", async () => { + const { fetchImpl, calls } = fakeFetch(() => Response.json({ revoked: true })); + + await revokeTenantSecrets(config(fetchImpl), { tenant: { name: "acme" }, product: "orb", secretRef: "orbenr_abc" }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]!.url, "https://api.loopover.test/v1/internal/orb/enrollments/orbenr_abc/revoke"); + assert.equal(calls[0]!.init.method, "POST"); + assert.equal((calls[0]!.init.headers as Record).authorization, "Bearer internal-test-token"); +}); + +test("revokeTenantSecrets: tolerates an empty-body success response (e.g. a real 204 No Content)", async () => { + const { fetchImpl } = fakeFetch(() => new Response("", { status: 200 })); + + await revokeTenantSecrets(config(fetchImpl), { tenant: { name: "acme" }, product: "orb", secretRef: "orbenr_abc" }); +}); + +test("revokeTenantSecrets: a broker-side error (e.g. unknown enrollment) surfaces as a thrown error, not a silent success", async () => { + const { fetchImpl } = fakeFetch(() => Response.json({ error: "enrollment_not_found" }, { status: 404 })); + + await assert.rejects( + revokeTenantSecrets(config(fetchImpl), { tenant: { name: "acme" }, product: "orb", secretRef: "orbenr_bogus" }), + /Main app API POST \/v1\/internal\/orb\/enrollments\/orbenr_bogus\/revoke failed \(404\)/, + ); +}); + +test("createSecretDriver: bundles injectTenantSecrets/revokeTenantSecrets closed over one config", async () => { + const { fetchImpl, calls } = fakeFetch((url) => (url.endsWith("/revoke") ? Response.json({ revoked: true }) : Response.json({ enrollId: "orbenr_abc", secret: "orbsec_xyz" }))); + const driver = createSecretDriver(config(fetchImpl)); + + const injected = await driver.injectSecrets(REQUEST); + assert.deepEqual(injected, { secretRef: "orbenr_abc" }); + + await driver.revokeSecrets({ ...REQUEST, secretRef: injected.secretRef }); + assert.equal(calls.length, 2); + assert.ok(calls[1]!.url.endsWith("/v1/internal/orb/enrollments/orbenr_abc/revoke")); +}); diff --git a/control-plane/worker-configuration.d.ts b/control-plane/worker-configuration.d.ts index 3261d0c5c8..93c870d9c5 100644 --- a/control-plane/worker-configuration.d.ts +++ b/control-plane/worker-configuration.d.ts @@ -1,10 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 134906cbf7a4991b8da13d634b78c112) +// Generated by Wrangler by running `wrangler types` (hash: d9359f4c08e948749b4c64d41526a5db) // Runtime types generated with workerd@1.20260721.1 2026-07-21 nodejs_compat interface __BaseEnv_Env { TENANT_REGISTRY: KVNamespace; NEON_PROJECT_ID: "REPLACE_WITH_REAL_NEON_PROJECT_ID"; LOOPOVER_ENABLE_PAGERDUTY: "false"; + MAIN_APP_BASE_URL: "https://api.loopover.ai"; ORB_TENANT_CONTAINER: DurableObjectNamespace; AMS_TENANT_CONTAINER: DurableObjectNamespace; } @@ -20,7 +21,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/control-plane/wrangler.jsonc b/control-plane/wrangler.jsonc index 3d5b7e5fb4..4e4d2547c2 100644 --- a/control-plane/wrangler.jsonc +++ b/control-plane/wrangler.jsonc @@ -15,6 +15,10 @@ // 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.) + // npx wrangler secret put INTERNAL_JOB_TOKEN (#8066: the SAME shared Bearer secret the main app's own + // /v1/internal/* middleware expects -- selects the real secret + // driver (src/secret-driver.ts) once MAIN_APP_BASE_URL below is + // also set. Omit to keep injectSecrets/revokeSecrets on the fake.) // // 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 @@ -38,7 +42,11 @@ // database driver; grants no access on its own. See src/driver-factory.ts's own env-selection logic. "NEON_PROJECT_ID": "REPLACE_WITH_REAL_NEON_PROJECT_ID", // Same flag name/shape as the main app's own PagerDuty wiring (#7667) -- off by default. - "LOOPOVER_ENABLE_PAGERDUTY": "false" + "LOOPOVER_ENABLE_PAGERDUTY": "false", + // Non-sensitive main-app base URL -- pairs with the INTERNAL_JOB_TOKEN secret above to select the real + // secret driver (#8066); the same host self-hosted containers already call for /v1/orb/token exchanges + // (src/orb/broker-client.ts's own default). Grants no access on its own. + "MAIN_APP_BASE_URL": "https://api.loopover.ai" }, "kv_namespaces": [ {