From f0133fcd7f3829bece3c0a04583d99d0fcd3b7f0 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 21 Apr 2026 12:57:31 +0100 Subject: [PATCH] chore: database cleanup & roles --- scripts/db/set-critical-role-timeouts.ts | 113 ++++++++++++++++++ server/src/db/redactDatabaseUrl.ts | 43 +++++++ server/src/init.ts | 3 + .../tests/unit/db/redactDatabaseUrl.test.ts | 24 ++++ 4 files changed, 183 insertions(+) create mode 100644 scripts/db/set-critical-role-timeouts.ts create mode 100644 server/src/db/redactDatabaseUrl.ts create mode 100644 server/tests/unit/db/redactDatabaseUrl.test.ts diff --git a/scripts/db/set-critical-role-timeouts.ts b/scripts/db/set-critical-role-timeouts.ts new file mode 100644 index 000000000..2fc053c70 --- /dev/null +++ b/scripts/db/set-critical-role-timeouts.ts @@ -0,0 +1,113 @@ +import { Pool } from "pg"; + +const DEFAULT_ENV = "dev"; +const DEFAULT_CRITICAL_ROLE = "autumn_critical"; +const ROLE_SETTINGS = { + statement_timeout: "2s", + lock_timeout: "1s", + idle_in_transaction_session_timeout: "10s", +}; + +const quoteIdent = (value: string) => `"${value.replaceAll('"', '""')}"`; +const quoteLiteral = (value: string) => `'${value.replaceAll("'", "''")}'`; + +const getArg = (name: string) => { + const prefix = `${name}=`; + const inlineArg = process.argv.find((arg) => arg.startsWith(prefix)); + if (inlineArg) return inlineArg.slice(prefix.length); + + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +}; + +type InfisicalSecret = { + key?: string; + value?: string; + secretKey?: string; + secretValue?: string; +}; + +const setEnvFromInfisicalExport = (value: unknown) => { + if (Array.isArray(value)) { + for (const secret of value as InfisicalSecret[]) { + const key = secret.key ?? secret.secretKey; + const secretValue = secret.value ?? secret.secretValue; + if (key && secretValue) { + process.env[key] = secretValue; + } + } + return; + } + + for (const [key, secretValue] of Object.entries(value as Record)) { + process.env[key] = secretValue; + } +}; + +const loadInfisicalEnv = (env: string) => { + const result = Bun.spawnSync([ + "infisical", + "secrets", + "--env", + env, + "--output", + "json", + "--recursive", + "--silent", + ]); + + if (!result.success) { + throw new Error( + `Failed to load Infisical env "${env}": ${result.stderr.toString()}`, + ); + } + + setEnvFromInfisicalExport(JSON.parse(result.stdout.toString())); +}; + +const main = async () => { + const env = getArg("--env") ?? DEFAULT_ENV; + const criticalRole = getArg("--role") ?? DEFAULT_CRITICAL_ROLE; + + loadInfisicalEnv(env); + + const criticalDatabaseUrl = process.env.DATABASE_CRITICAL_URL; + if (!criticalDatabaseUrl) { + throw new Error(`DATABASE_CRITICAL_URL is not set in Infisical env "${env}"`); + } + + const client = new Pool({ + connectionString: criticalDatabaseUrl, + max: 1, + }); + + try { + for (const [key, value] of Object.entries(ROLE_SETTINGS)) { + await client.query( + `ALTER ROLE ${quoteIdent(criticalRole)} SET ${key} = ${quoteLiteral( + value, + )}`, + ); + } + + const result = await client.query<{ rolconfig: string[] | null }>( + "SELECT rolconfig FROM pg_roles WHERE rolname = $1", + [criticalRole], + ); + + console.log(`Updated ${criticalRole} in ${env}:`); + console.log(result.rows[0]?.rolconfig ?? []); + + console.log("Verified via DATABASE_CRITICAL_URL:"); + for (const key of Object.keys(ROLE_SETTINGS)) { + const verifyResult = await client.query>(`SHOW ${key}`); + console.log(`${key}=${verifyResult.rows[0]?.[key]}`); + } + + console.log("Restart app connections for existing pools to pick this up."); + } finally { + await client.end(); + } +}; + +await main(); diff --git a/server/src/db/redactDatabaseUrl.ts b/server/src/db/redactDatabaseUrl.ts new file mode 100644 index 000000000..635d81099 --- /dev/null +++ b/server/src/db/redactDatabaseUrl.ts @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex").slice(0, 12); + +const mask = (value = "") => + value.length > 6 + ? `${value.slice(0, 3)}***${value.slice(-3)}` + : `${value[0] ?? ""}***${value.slice(-1)}`; + +const auth = (url: URL) => { + const username = decodeURIComponent(url.username); + const password = decodeURIComponent(url.password); + if (!username && !password) return ""; + + return `${mask(username)}${password ? `:${mask(password)}` : ""}@`; +}; + +const formatUrl = (value: string) => { + const url = new URL(value); + return `${url.protocol}//${auth(url)}${url.host}${url.pathname}${ + url.search ? "?" : "" + }`; +}; + +export const redactDatabaseUrl = (databaseUrl?: string) => { + const value = databaseUrl?.trim(); + if (!value) return "unset"; + + try { + return `${formatUrl(value)} #${hash(value)}`; + } catch { + return ` #${hash(value)}`; + } +}; + +export const getRedactedDatabaseUrls = () => ({ + primary: redactDatabaseUrl(process.env.DATABASE_URL), + replica: redactDatabaseUrl(process.env.DATABASE_REPLICA_URL), + critical: redactDatabaseUrl( + process.env.DATABASE_CRITICAL_URL || process.env.DATABASE_URL, + ), +}); diff --git a/server/src/init.ts b/server/src/init.ts index 6fb5bf225..de54b4fb0 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -10,6 +10,7 @@ import { initPgHealthMonitor, shutdownPgHealthMonitor, } from "./db/pgHealthMonitor.js"; +import { getRedactedDatabaseUrls } from "./db/redactDatabaseUrl.js"; import { logger } from "./external/logtail/logtailUtils.js"; import { startAllEdgeConfigPolling, @@ -38,6 +39,8 @@ import { startMemoryMonitor } from "./utils/memoryMonitor.js"; checkEnvVars(); const init = async ({ startupStartedAt }: { startupStartedAt: number }) => { + logger.info(getRedactedDatabaseUrls(), "DB URLs"); + const app = createHonoApp(); initPgHealthMonitor({ client: clientCritical }); diff --git a/server/tests/unit/db/redactDatabaseUrl.test.ts b/server/tests/unit/db/redactDatabaseUrl.test.ts new file mode 100644 index 000000000..4708d9253 --- /dev/null +++ b/server/tests/unit/db/redactDatabaseUrl.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { redactDatabaseUrl } from "@/db/redactDatabaseUrl.js"; + +describe("redactDatabaseUrl", () => { + test("redacts credentials and query string", () => { + const redacted = redactDatabaseUrl( + "postgres://user:secret@db.example.com:5432/autumn?sslmode=require", + ); + + expect(redacted).toMatch( + /^postgres:\/\/u\*\*\*r:s\*\*\*t@db\.example\.com:5432\/autumn\? #[a-f0-9]{12}$/, + ); + expect(redacted).not.toContain("user"); + expect(redacted).not.toContain("secret"); + expect(redacted).not.toContain("sslmode=require"); + }); + + test("does not echo invalid urls", () => { + const redacted = redactDatabaseUrl("not a url with secret"); + + expect(redacted).toMatch(/^ #[a-f0-9]{12}$/); + expect(redacted).not.toContain("not a url with secret"); + }); +});