diff --git a/control-plane/src/provision.ts b/control-plane/src/provision.ts index e0c1b72..fec3b0a 100644 --- a/control-plane/src/provision.ts +++ b/control-plane/src/provision.ts @@ -25,6 +25,23 @@ import { defaultOrgId } from "./orgs"; const SLUG = /^[a-z][a-z0-9_]{1,40}$/; +/** + * Per-tenant resource governance (ADR 0007, Phase 0) — bound one tenant's blast + * radius on the shared cluster. Applied to the authenticator role on every + * provision, so re-provisioning also backfills existing projects. + * - connection limit: a ceiling far above real peak (~7) yet well under the + * cluster's max_connections (100), so one runaway tenant can't drain the pool. + * - statement_timeout: kill a runaway query instead of pinning a backend. + * - idle_in_transaction_session_timeout: reap a leaked open transaction that + * would otherwise hold a connection (and its locks) open indefinitely. + * GoTrue connects as `postgres` (not the authenticator), so these bound the data + * plane without touching auth; migrations run as superuser, so statement_timeout + * never blocks a schema apply. + */ +const TENANT_CONNECTION_LIMIT = 20; +const TENANT_STATEMENT_TIMEOUT = "60s"; +const TENANT_IDLE_TX_TIMEOUT = "60s"; + export type Project = { name: string; database: string; @@ -103,6 +120,18 @@ export async function provisionDatabase(name: string): Promise { await admin.query(`alter role ${ident(role)} password ${lit(password)}`); } + // Per-tenant resource governance (ADR 0007, Phase 0). Idempotent; runs on + // every provision so existing roles get backfilled too. + await admin.query( + `alter role ${ident(role)} connection limit ${TENANT_CONNECTION_LIMIT}`, + ); + await admin.query( + `alter role ${ident(role)} set statement_timeout = ${lit(TENANT_STATEMENT_TIMEOUT)}`, + ); + await admin.query( + `alter role ${ident(role)} set idle_in_transaction_session_timeout = ${lit(TENANT_IDLE_TX_TIMEOUT)}`, + ); + const d = await admin.query( "select 1 from pg_database where datname = $1", [database],