From 6e68cb83c0f928eac56e2769c977586ee3ca5f66 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Fri, 3 Apr 2026 06:25:51 -0500 Subject: [PATCH] feat: restrict customer DB user permissions on provision --- bun.lock | 3 +++ package.json | 1 + src/lib/config.ts | 1 + src/routes/customers.ts | 3 +++ src/services/db.ts | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 45 insertions(+) create mode 100644 src/services/db.ts diff --git a/bun.lock b/bun.lock index 55d92e4..834d1df 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@fastify/static": "^9.0.0", "fastify": "^5.8.4", + "postgres": "^3.4.8", "zod": "^4.3.6", }, "devDependencies": { @@ -121,6 +122,8 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], diff --git a/package.json b/package.json index 96a40cf..fdd665c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@fastify/static": "^9.0.0", "fastify": "^5.8.4", + "postgres": "^3.4.8", "zod": "^4.3.6" } } diff --git a/src/lib/config.ts b/src/lib/config.ts index 52957c0..c2d6699 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -5,6 +5,7 @@ export const config = { gitSshKey: process.env.GIT_SSH_KEY!, gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git", dbUrl: process.env.DATABASE_URL!, + doadminDbUrl: process.env.DOADMIN_DATABASE_URL!, }; for (const [key, val] of Object.entries(config)) { diff --git a/src/routes/customers.ts b/src/routes/customers.ts index 0b695b2..ca7e4fc 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do"; import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer"; import { addCustomerChart, removeCustomerChart } from "../services/git"; +import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db"; const ProvisionSchema = z.object({ name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"), @@ -21,6 +22,7 @@ export async function customerRoutes(app: FastifyInstance) { createDatabaseUser(slug), ]); + await setupCustomerDatabase(slug, user.name); await addCustomerToPool(slug, user.password); addCustomerChart(slug, body.appVersion); @@ -35,6 +37,7 @@ export async function customerRoutes(app: FastifyInstance) { removeCustomerChart(slug); await removeCustomerFromPool(slug); + await teardownCustomerDatabase(slug, slug); await Promise.all([ deleteDatabase(slug), deleteDatabaseUser(slug), diff --git a/src/services/db.ts b/src/services/db.ts new file mode 100644 index 0000000..468be50 --- /dev/null +++ b/src/services/db.ts @@ -0,0 +1,37 @@ +import postgres from "postgres"; +import { config } from "../lib/config"; + +// Runs setup SQL as doadmin against a specific database +export async function setupCustomerDatabase(dbName: string, username: string) { + const sql = postgres(config.doadminDbUrl.replace(/\/\w+(\?|$)/, `/${dbName}$1`), { max: 1 }); + + try { + // Revoke all public access, then grant only to this user + await sql.unsafe(` + REVOKE ALL ON DATABASE "${dbName}" FROM PUBLIC; + GRANT CONNECT ON DATABASE "${dbName}" TO "${username}"; + GRANT ALL PRIVILEGES ON DATABASE "${dbName}" TO "${username}"; + ALTER DATABASE "${dbName}" OWNER TO "${username}"; + `); + + // Set default privileges so any tables the app creates are accessible to itself + await sql.unsafe(` + ALTER DEFAULT PRIVILEGES FOR ROLE "${username}" IN SCHEMA public + GRANT ALL ON TABLES TO "${username}"; + ALTER DEFAULT PRIVILEGES FOR ROLE "${username}" IN SCHEMA public + GRANT ALL ON SEQUENCES TO "${username}"; + `); + } finally { + await sql.end(); + } +} + +export async function teardownCustomerDatabase(dbName: string, username: string) { + // Reassign ownership back to doadmin before dropping + const sql = postgres(config.doadminDbUrl.replace(/\/\w+(\?|$)/, `/${dbName}$1`), { max: 1 }); + try { + await sql.unsafe(`REASSIGN OWNED BY "${username}" TO doadmin;`); + } finally { + await sql.end(); + } +}