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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions control-plane/src/driver-factory.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, string | undefined> = process.env,
Expand All @@ -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;
}
6 changes: 6 additions & 0 deletions control-plane/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
17 changes: 12 additions & 5 deletions control-plane/src/http-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -48,13 +53,14 @@ export type TenantHttpAppDeps = {
orbWebhookSecret?: string;
};

function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "state" | "amsSchedule" | "orbInstallationId">): Record<string, unknown> {
function safeRecord(record: Pick<TenantRegistryRecord, "tenant" | "product" | "state" | "amsSchedule" | "orbInstallationId" | "secretRef">): Record<string, unknown> {
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 } : {}),
};
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
});
Expand Down
8 changes: 8 additions & 0 deletions control-plane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 19 additions & 9 deletions control-plane/src/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TenantLifecycleState, "active">;
database: DatabaseConnectionDetails;
secretRef?: string;
};

/** Result of a successful deprovision — terminal lifecycle state `"torn down"`. */
Expand Down Expand Up @@ -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,
Expand All @@ -89,26 +94,31 @@ export async function provisionTenant(
): Promise<TenantProvisioningResult> {
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<TenantDeprovisioningResult> {
const request: TenantProvisioningRequest = { tenant, product };
const request: TenantProvisioningRequest = { tenant, product, ...(secretRef !== undefined ? { secretRef } : {}) };
try {
await driver.revokeSecrets(request);
await driver.dropDatabase(request);
Expand Down
Loading