diff --git a/Dockerfile b/Dockerfile index 37143e516..7c03b74e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -257,6 +257,8 @@ COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrat COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs +COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs +COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b31e9871e..e74be1f56 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -107,6 +107,22 @@ NODE_PATH=/opt/prisma-cli/node_modules \ node scripts/migrate-deploy.mjs echo "HealthLog: Migrations complete." +# ── The app's database role ────────────────────────────────── +# Everything above ran as the owner DATABASE_URL names. The app itself +# connects as `healthlog_app`, which reads and writes rows and runs the queue +# but cannot change the schema (scripts/db-app-role.mjs says what it may and +# may not do, and docs/self-hosting/database-role.md how to opt out). The +# script hands the role's URL over on descriptor 3 only, never on stdout or +# stderr; nothing handed over means it said why and the app stays on the +# owner, as before. The owner's URL is replaced, not kept beside it, so it +# does not reach the app's environment. +app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url="" +if [ -n "$app_database_url" ]; then + DATABASE_URL="$app_database_url" + export DATABASE_URL +fi +unset app_database_url + # ── Start application ──────────────────────────────────────── echo "HealthLog: Starting application..." exec "$@" diff --git a/scripts/db-app-role.mjs b/scripts/db-app-role.mjs new file mode 100644 index 000000000..73b9ca2f5 --- /dev/null +++ b/scripts/db-app-role.mjs @@ -0,0 +1,648 @@ +#!/usr/bin/env node +/** + * The database role the running app connects as. + * + * The migrations, the dump before them and the restore connect as the owner + * `DATABASE_URL` names. The long-running web and worker processes do not need + * the owner: they read and write rows, run the queue, and maintain one table. + * After the migrations, `docker-entrypoint.sh` runs this script as the owner. + * It ensures a login role, `healthlog_app`, that can do exactly that, and + * hands the entrypoint a `DATABASE_URL` for it; the entrypoint then starts the + * app with that URL in place of the owner's, so the owner's credentials never + * reach the app's environment. + * + * What the role can do: + * - SELECT, INSERT, UPDATE, DELETE on every table of the app's schema, and + * USAGE, SELECT, UPDATE on its sequences. `_prisma_migrations` is SELECT + * only: the app reads the migration history, it never writes it. + * - MAINTAIN on `measurements` (Postgres 17+): the maintenance job's + * `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY`. + * - Own the `pgboss` schema and everything in it. pg-boss creates and + * migrates its own tables, functions and per-queue partitions at runtime, + * so it needs DDL there; it is the queue's private schema and nothing of + * the app's data lives in it. + * + * What it cannot do: create, alter or drop anything in the app's schema; + * create schemas, databases or roles; act as a superuser, so no + * `COPY ... FROM PROGRAM`, no `pg_read_file`, no untrusted languages; write + * the migration history. `CREATE` on the app's schema is revoked from PUBLIC + * as well (the default since Postgres 15; a database first created on an + * older major still grants it), where the owner may do so. + * + * Every boot re-applies it, inside one transaction under an advisory lock, so + * a table a new migration created is covered before the app starts, a web and + * a worker container booting together do not race, and a restored database + * (whose objects `pg_restore --no-owner` hands to the owner) is put right. + * Grants only touch what lacks them, so a boot after the first writes + * almost nothing to the catalog. + * + * The password is derived, not stored: HMAC-SHA256 of a fixed label under + * API_TOKEN_HMAC_KEY. Every container of one instance carries that key, so a + * separate worker container derives the same password without a new + * variable. It is set as a SCRAM verifier computed here, so the plaintext + * never travels to the server, where a statement log could keep it. The role + * is marked with a comment; a role of the same name without the mark is not + * HealthLog's, and is left alone. + * + * Fail open, loudly. When the owner cannot do this (a managed or external + * database whose user may not create roles, Postgres before 17, a pg_hba.conf + * that does not let the new role in, a lock that did not come in time), the + * script says why in one notice and the app runs as the owner, as it did + * before. `DB_APP_ROLE=off` skips it for an operator who manages roles + * themselves, including one whose `DATABASE_URL` already names a restricted role. + * + * Why Postgres 17: the maintenance job vacuums and rebuilds the indexes of + * `measurements`, and before the MAINTAIN privilege only the table's owner + * could. Rather than leave that job broken on 16, an older server keeps + * today's behaviour. + * + * Index drops stay with the owner: `DROP INDEX` belongs to the table's owner + * and MAINTAIN does not include it. When the role is in place this script + * also drops, as the owner, the indexes the maintenance job used to drop + * (retired indexes, and the invalid copies an interrupted concurrent rebuild + * leaves), each under a short lock wait, so a held lock delays the boot by + * seconds and the drop waits for the next boot. + * + * Interface: the URL for the role goes to file descriptor 3 and nowhere else; + * every message goes to stderr, and no message carries a password or a URL. + * The entrypoint reads it with `url="$(node scripts/db-app-role.mjs 3>&1 1>&2)"`. + * Without descriptor 3 (a manual run) the role is still ensured and the URL is + * not printed. Exit 0 in every case the app should start; nothing written to + * descriptor 3 means: stay on the owner. + * + * Plain ESM with no imports beyond Node, `pg` and the shared dump module, so + * it runs from the runtime image like the other entrypoint scripts. + */ +import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto"; +import { writeSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +import { connect } from "../src/lib/database-backup/dump-core.mjs"; +import { + CONCURRENT_COPY, + RETIRED_MEASUREMENT_INDEXES, +} from "../src/lib/jobs/retired-measurement-indexes.mjs"; + +/** The role's name. Cluster-wide, like every Postgres role. */ +export const APP_ROLE = "healthlog_app"; + +/** The mark on a role this script created. */ +export const ROLE_COMMENT = + "Managed by HealthLog: the app's least-privilege login (scripts/db-app-role.mjs). Recreated and re-granted on every start."; + +/** The schema pg-boss keeps its queue in (its default; HealthLog sets none). */ +export const QUEUE_SCHEMA = "pgboss"; + +/** Tables the role may maintain (VACUUM, ANALYZE, REINDEX). */ +export const MAINTAINED_TABLES = Object.freeze(["measurements"]); + +/** Tables the role may only read. */ +export const READ_ONLY_TABLES = Object.freeze(["_prisma_migrations"]); + +/** The label the password is derived under. A new label is a new password. */ +const PASSWORD_LABEL = "healthlog database app role v1"; + +/** The first major with the MAINTAIN privilege. */ +export const MIN_SERVER_VERSION_NUM = 170000; + +/** How long the setup waits for a lock before staying on the owner. */ +const SETUP_LOCK_TIMEOUT = "15s"; + +/** How long a boot-time index drop waits for its lock. */ +export const BOOT_DROP_LOCK_TIMEOUT = "5s"; + +/** An identifier this script splices into SQL: lower-case catalog names only. */ +const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/; + +function say(message) { + process.stderr.write(`HealthLog: ${message}\n`); +} + +/** Double-quote an identifier after checking its shape. */ +function ident(name) { + if (!IDENTIFIER.test(name)) { + throw new Error("refusing to splice an identifier of unexpected shape"); + } + return `"${name}"`; +} + +/** The role's password: 64 hex characters, the same in every container. */ +export function appRolePassword(hmacKey) { + if (!hmacKey) throw new Error("API_TOKEN_HMAC_KEY is not set"); + return createHmac("sha256", hmacKey).update(PASSWORD_LABEL).digest("hex"); +} + +/** + * A SCRAM-SHA-256 verifier in the form `pg_authid.rolpassword` stores + * (RFC 5802/7677; the password is ASCII hex, so SASLprep leaves it as is). + * Postgres takes a string of this shape as already hashed. + */ +export function scramVerifier( + password, + salt = randomBytes(16), + iterations = 4096, +) { + const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256"); + const clientKey = createHmac("sha256", salted).update("Client Key").digest(); + const storedKey = createHash("sha256").update(clientKey).digest(); + const serverKey = createHmac("sha256", salted).update("Server Key").digest(); + return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`; +} + +/** The owner's URL with the role's name and password: same host, database and parameters. */ +export function appRoleUrl(ownerUrl, password) { + const url = new URL(ownerUrl); + url.username = APP_ROLE; + url.password = password; + return url.toString(); +} + +/** The user a URL names, for the notices. Never the password. */ +function userOf(databaseUrl) { + try { + return decodeURIComponent(new URL(databaseUrl).username) || "(default)"; + } catch { + return "(unparsable)"; + } +} + +function errorText(err) { + const code = err?.code; + const message = err instanceof Error ? err.message : String(err); + return typeof code === "string" ? `${code} ${message}` : message; +} + +/** + * Whether the connected user can set the role up, and why not when it + * cannot. Read-only. + * + * @param {{ query: Function }} client + * @returns {Promise<{ ok: true, superuser: boolean } | { ok: false, reason: string, detail: string }>} + */ +export async function checkPreconditions(client) { + const { rows } = await client.query( + `SELECT current_setting('server_version_num')::int AS version, + r.rolsuper AS superuser, + r.rolcreaterole AS createrole, + current_user AS me + FROM pg_roles r WHERE r.rolname = current_user`, + ); + const me = rows[0]; + if (me.me === APP_ROLE) { + return { + ok: false, + reason: "already_app_role", + detail: `DATABASE_URL already names ${APP_ROLE}`, + }; + } + if (me.version < MIN_SERVER_VERSION_NUM) { + return { + ok: false, + reason: "server_too_old", + detail: `Postgres ${Math.floor(me.version / 10000)} has no MAINTAIN privilege (17 or newer does)`, + }; + } + if (!me.superuser && !me.createrole) { + return { + ok: false, + reason: "cannot_create_roles", + detail: `the database user ${me.me} may not create roles (a managed or external database, or a user without CREATEROLE)`, + }; + } + const existing = await client.query( + `SELECT shobj_description(r.oid, 'pg_authid') AS comment, + pg_has_role(r.oid, current_user, 'MEMBER') AS member_of_owner, + r.rolsuper OR r.rolreplication OR r.rolbypassrls AS elevated + FROM pg_roles r WHERE r.rolname = $1`, + [APP_ROLE], + ); + const role = existing.rows[0]; + if (role && role.comment !== ROLE_COMMENT) { + return { + ok: false, + reason: "foreign_role", + detail: `a role named ${APP_ROLE} exists that HealthLog did not create`, + }; + } + if (role?.member_of_owner) { + return { + ok: false, + reason: "role_inherits_owner", + detail: `${APP_ROLE} is a member of ${me.me}, so it would hold the owner's rights`, + }; + } + if (role?.elevated && !me.superuser) { + return { + ok: false, + reason: "role_elevated", + detail: `${APP_ROLE} has SUPERUSER, REPLICATION or BYPASSRLS, and only a superuser can take it away`, + }; + } + return { ok: true, superuser: me.superuser }; +} + +/** + * Create or update the role and apply every grant, in one transaction. + * Call it on a client connected as the owner, after the migrations. + * + * @param {{ query: Function }} client + * @param {{ password: string, schema?: string, superuser: boolean }} options + */ +export async function applyAppRole(client, options) { + const schema = options.schema ?? "public"; + const role = ident(APP_ROLE); + const app = ident(schema); + const queue = ident(QUEUE_SCHEMA); + // The verifier is a base64/`$`/`:` string; it never contains a quote. + const verifier = scramVerifier(options.password); + if (verifier.includes("'")) throw new Error("unexpected verifier shape"); + + await client.query("BEGIN"); + try { + await client.query(`SET LOCAL lock_timeout = '${SETUP_LOCK_TIMEOUT}'`); + await client.query( + `SELECT pg_advisory_xact_lock(hashtext('healthlog.db-app-role'))`, + ); + + const { rows: present } = await client.query( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [APP_ROLE], + ); + // The attributes a superuser alone may set are named only by one; a + // role a CREATEROLE user creates never has them (checked beforehand). + const elevatedOff = options.superuser + ? " NOSUPERUSER NOREPLICATION NOBYPASSRLS" + : ""; + const attributes = `LOGIN NOCREATEDB NOCREATEROLE NOINHERIT${elevatedOff}`; + if (present.length === 0) { + await client.query( + `CREATE ROLE ${role} ${attributes} PASSWORD '${verifier}'`, + ); + } else { + await client.query( + `ALTER ROLE ${role} ${attributes} PASSWORD '${verifier}'`, + ); + } + await client.query( + `COMMENT ON ROLE ${role} IS '${ROLE_COMMENT.replace(/'/g, "''")}'`, + ); + + // A non-superuser owner hands objects to the role only when it may act + // as it (Postgres 16+), and must keep reading what the role owns: the + // dump before a migration reads the queue schema too. The membership + // runs owner -> role, never the other way. + if (!options.superuser) { + await client.query( + `GRANT ${role} TO CURRENT_USER WITH INHERIT TRUE, SET TRUE`, + ); + } + + const { rows: db } = await client.query( + `SELECT current_database() AS name`, + ); + await client.query( + `GRANT CONNECT, TEMPORARY ON DATABASE "${String(db[0].name).replace(/"/g, '""')}" TO ${role}`, + ); + + // The app's schema: rows, never structure. + await client.query(`GRANT USAGE ON SCHEMA ${app} TO ${role}`); + await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM ${role}`); + const { rows: schemaOwner } = await client.query( + `SELECT pg_has_role(current_user, nspowner, 'USAGE') AS owns + FROM pg_namespace WHERE nspname = $1`, + [schema], + ); + if (options.superuser || schemaOwner[0]?.owns) { + await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM PUBLIC`); + } else { + say( + `notice: the schema ${schema} is not the database user's to change; PUBLIC may still create objects in it`, + ); + } + + const { rows: tables } = await client.query( + `SELECT c.relname AS name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f') + AND c.relname <> ALL($3::text[]) + AND NOT (has_table_privilege($2, c.oid, 'SELECT') + AND has_table_privilege($2, c.oid, 'INSERT') + AND has_table_privilege($2, c.oid, 'UPDATE') + AND has_table_privilege($2, c.oid, 'DELETE'))`, + [schema, APP_ROLE, READ_ONLY_TABLES], + ); + for (const { name } of tables) { + await client.query( + `GRANT SELECT, INSERT, UPDATE, DELETE ON ${app}.${ident(name)} TO ${role}`, + ); + } + for (const name of READ_ONLY_TABLES) { + const { rows } = await client.query( + `SELECT to_regclass($1) IS NOT NULL AS present`, + [`${app}.${ident(name)}`], + ); + if (!rows[0].present) continue; + await client.query( + `REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${app}.${ident(name)} FROM ${role}`, + ); + await client.query(`GRANT SELECT ON ${app}.${ident(name)} TO ${role}`); + } + + const { rows: sequences } = await client.query( + `SELECT c.relname AS name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = 'S' + AND NOT (has_sequence_privilege($2, c.oid, 'USAGE') + AND has_sequence_privilege($2, c.oid, 'SELECT') + AND has_sequence_privilege($2, c.oid, 'UPDATE'))`, + [schema, APP_ROLE], + ); + for (const { name } of sequences) { + await client.query( + `GRANT USAGE, SELECT, UPDATE ON SEQUENCE ${app}.${ident(name)} TO ${role}`, + ); + } + + // Objects the owner creates later in this schema (the migrations of the + // next update) are granted at creation as well. The per-boot pass above + // is what the app relies on; this covers the time between the two. + await client.query( + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role}`, + ); + await client.query( + `ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${role}`, + ); + + for (const name of MAINTAINED_TABLES) { + const { rows } = await client.query( + `SELECT to_regclass($1) IS NOT NULL AS present`, + [`${app}.${ident(name)}`], + ); + if (rows[0].present) { + await client.query( + `GRANT MAINTAIN ON ${app}.${ident(name)} TO ${role}`, + ); + } + } + + // The queue's schema belongs to the role: pg-boss creates its tables, + // functions and partitions there at runtime. On the first boot after an + // update, and after a restore, everything in it still belongs to the + // owner and is handed over once. + await client.query( + `CREATE SCHEMA IF NOT EXISTS ${queue} AUTHORIZATION ${role}`, + ); + await client.query(`ALTER SCHEMA ${queue} OWNER TO ${role}`); + await transferQueueObjects(client, role); + + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK").catch(() => {}); + throw err; + } +} + +/** Hand every object in the queue schema the role does not own to it. */ +async function transferQueueObjects(client, role) { + const { rows: relations } = await client.query( + `SELECT c.oid::regclass::text AS name, c.relkind AS kind + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 + AND c.relowner <> (SELECT oid FROM pg_roles WHERE rolname = $2) + AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S') + -- A sequence a column owns follows its table. + AND NOT (c.relkind = 'S' AND EXISTS ( + SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid + AND d.deptype IN ('a', 'i'))) + ORDER BY c.relkind = 'r' DESC, c.oid`, + [QUEUE_SCHEMA, APP_ROLE], + ); + for (const { name, kind } of relations) { + const what = + kind === "S" + ? "SEQUENCE" + : kind === "v" + ? "VIEW" + : kind === "m" + ? "MATERIALIZED VIEW" + : kind === "f" + ? "FOREIGN TABLE" + : "TABLE"; + // `regclass::text` is the catalog's own quoting of the name. + await client.query(`ALTER ${what} ${name} OWNER TO ${role}`); + } + + const { rows: routines } = await client.query( + `SELECT p.oid::regprocedure::text AS signature + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + AND p.proowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)`, + [QUEUE_SCHEMA, APP_ROLE], + ); + for (const { signature } of routines) { + await client.query(`ALTER ROUTINE ${signature} OWNER TO ${role}`); + } + + // Enums, domains and free-standing composite types; a table's row type + // and an array type follow their owner object. + const { rows: types } = await client.query( + `SELECT t.oid::regtype::text AS name, t.typtype AS kind + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_class c ON c.oid = t.typrelid + WHERE n.nspname = $1 + AND t.typowner <> (SELECT oid FROM pg_roles WHERE rolname = $2) + AND t.typelem = 0 + AND (t.typtype IN ('e', 'd', 'r') OR (t.typtype = 'c' AND c.relkind = 'c'))`, + [QUEUE_SCHEMA, APP_ROLE], + ); + for (const { name, kind } of types) { + await client.query( + `ALTER ${kind === "d" ? "DOMAIN" : "TYPE"} ${name} OWNER TO ${role}`, + ); + } +} + +/** + * Drop, as the owner, the indexes on `measurements` the maintenance job can + * no longer drop as the role: the retired ones and the invalid copies of a + * concurrent rebuild. Each waits at most {@link BOOT_DROP_LOCK_TIMEOUT}; one + * that does not get its lock is left for the next boot. + * + * @param {{ query: Function }} client a connection outside any transaction + * @returns {Promise<{ dropped: string[], left: string[] }>} + */ +export async function dropOwnerOnlyIndexes(client) { + const dropped = []; + const left = []; + const { rows: table } = await client.query( + `SELECT to_regclass('measurements') IS NOT NULL AS present`, + ); + if (!table[0].present) return { dropped, left }; + const { rows } = await client.query( + `SELECT c.relname AS name, i.indisvalid AS valid + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE i.indrelid = 'measurements'::regclass`, + ); + const targets = rows + .filter( + (r) => + RETIRED_MEASUREMENT_INDEXES.includes(r.name) || + (!r.valid && CONCURRENT_COPY.test(r.name)), + ) + .map((r) => r.name); + if (targets.length === 0) return { dropped, left }; + const { rows: setting } = await client.query( + `SELECT current_setting('lock_timeout') AS value`, + ); + try { + await client.query(`SET lock_timeout = '${BOOT_DROP_LOCK_TIMEOUT}'`); + for (const name of targets) { + try { + await client.query(`DROP INDEX CONCURRENTLY IF EXISTS ${ident(name)}`); + dropped.push(name); + } catch (err) { + left.push(name); + say( + `notice: index ${name} on measurements was not dropped (${errorText(err)}); the next start tries again`, + ); + } + } + } finally { + await client.query(`SELECT set_config('lock_timeout', $1, false)`, [ + setting[0]?.value ?? "0", + ]); + } + return { dropped, left }; +} + +/** Log in as the role once, so a pg_hba.conf that refuses it shows here. */ +async function proveLogin(url) { + const client = await connect(url); + try { + const { rows } = await client.query(`SELECT current_user AS me`); + if (rows[0].me !== APP_ROLE) { + throw new Error(`logged in as ${rows[0].me}, not ${APP_ROLE}`); + } + } finally { + await client.end().catch(() => {}); + } +} + +/** Write the URL to descriptor 3 when the caller opened it. */ +function handOver(url) { + try { + writeSync(3, url); + return true; + } catch { + return false; + } +} + +/** + * The boot step. Returns the role's URL when the app should use it, or null + * to stay on the owner. + * + * @param {NodeJS.ProcessEnv} env + * @returns {Promise} + */ +export async function ensureAppRole(env = process.env) { + const ownerUrl = env.DATABASE_URL; + const mode = (env.DB_APP_ROLE ?? "").trim().toLowerCase(); + if (mode === "off" || mode === "false") { + say( + `DB_APP_ROLE=off: the app connects as ${userOf(ownerUrl)}, the user DATABASE_URL names`, + ); + return null; + } + if (mode && mode !== "auto" && mode !== "on") { + say( + `notice: DB_APP_ROLE="${mode}" is not auto or off; treating it as auto`, + ); + } + if (!ownerUrl) return null; + + const user = userOf(ownerUrl); + const fallBack = (detail) => { + say( + `notice: the app connects as ${user}, the user DATABASE_URL names, not as the restricted role ${APP_ROLE}: ${detail}. ` + + "This is how every earlier release ran. " + + `If ${user} is a restricted role you manage yourself, DB_APP_ROLE=off silences this; otherwise see docs/self-hosting/database-role.md.`, + ); + return null; + }; + + let client; + try { + client = await connect(ownerUrl); + } catch (err) { + return fallBack(`could not connect to set it up (${errorText(err)})`); + } + try { + const pre = await checkPreconditions(client); + if (!pre.ok) { + if (pre.reason === "already_app_role") { + say(`DATABASE_URL already names ${APP_ROLE}; nothing to set up`); + return null; + } + return fallBack(pre.detail); + } + const password = appRolePassword(env.API_TOKEN_HMAC_KEY); + const schema = new URL(ownerUrl).searchParams.get("schema") || "public"; + try { + await applyAppRole(client, { + password, + schema, + superuser: pre.superuser, + }); + } catch (err) { + return fallBack(`setting it up failed (${errorText(err)})`); + } + const url = appRoleUrl(ownerUrl, password); + try { + await proveLogin(url); + } catch (err) { + return fallBack( + `${APP_ROLE} cannot log in (${errorText(err)}); a pg_hba.conf that lists users by name needs a line for it`, + ); + } + const { dropped } = await dropOwnerOnlyIndexes(client); + if (dropped.length > 0) { + say(`dropped index ${dropped.join(", ")} on measurements`); + } + say( + `the app connects as ${APP_ROLE}: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)`, + ); + return url; + } catch (err) { + return fallBack(`the check failed (${errorText(err)})`); + } finally { + await client.end().catch(() => {}); + } +} + +export async function main(env = process.env) { + const url = await ensureAppRole(env); + if (url && !handOver(url)) { + say( + "the role is ready; the URL goes to file descriptor 3, which this run did not open", + ); + } + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().then( + (code) => process.exit(code), + (err) => { + say(`notice: the database role step failed (${errorText(err)})`); + process.exit(0); + }, + ); +} diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts index 629a62f48..a7db951d9 100644 --- a/src/__tests__/container-scripts-ship-guard.test.ts +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -74,9 +74,11 @@ function entrypointScripts(): string[] { const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8"); return [ ...new Set( - [...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map( - (m) => m[1], - ), + // A line that starts with the call, or a command substitution that + // makes it (`url="$(node scripts/db-app-role.mjs …)"`). + [ + ...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm), + ].map((m) => m[1]), ), ].sort(); } @@ -126,6 +128,7 @@ describe("scripts the entrypoint runs ship in the image", () => { it("finds the boot scripts it is meant to check", () => { // An empty match would agree with any Dockerfile. expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs"); + expect(entrypointScripts()).toContain("scripts/db-app-role.mjs"); }); it("copies every boot script and its local imports into the runner stage", () => { diff --git a/tests/integration/db-app-role.test.ts b/tests/integration/db-app-role.test.ts new file mode 100644 index 000000000..0be78cf29 --- /dev/null +++ b/tests/integration/db-app-role.test.ts @@ -0,0 +1,387 @@ +/** + * The app's restricted database role, against a real Postgres. + * + * `scripts/db-app-role.mjs` runs at boot as the owner and hands the app a URL + * for `healthlog_app`. Proven here: the role does what the app does (Prisma + * reads and writes, pg-boss installing its schema and creating a queue + * partition, the measurement maintenance's VACUUM and concurrent rebuild), + * and is refused what it must not do (DDL in the app's schema, roles, + * schemas, server files and programs, the migration history). A second run is + * a no-op that keeps the same password, the index the role may not drop is + * dropped by the next run as the owner, and an owner that may not create + * roles leaves the app on the owner with a notice. + * + * On Postgres 16 (the nightly `INTEGRATION_PG_IMAGE` run) the script stays + * on the owner by design; the file proves that instead. + */ +import { PrismaPg } from "@prisma/adapter-pg"; +import { Client } from "pg"; +import { PgBoss } from "pg-boss"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { PrismaClient } from "@/generated/prisma/client"; +import { + MAINTENANCE_SESSION_OPTIONS, + RETIRED_MEASUREMENT_INDEXES, + runMeasurementMaintenance, +} from "@/lib/jobs/measurement-maintenance"; +import { + APP_ROLE, + appRolePassword, + ensureAppRole, +} from "../../scripts/db-app-role.mjs"; + +const HMAC_KEY = "a".repeat(64); +const PROBE_OWNER = "app_role_probe_owner"; +const USER_ID = "user-app-role-probe"; +const RETIRED = RETIRED_MEASUREMENT_INDEXES[0]; + +function ownerUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set for the integration run"); + return url; +} + +function withoutSchema(url: string): string { + const u = new URL(url); + u.searchParams.delete("schema"); + u.searchParams.delete("pgbouncer"); + return u.toString(); +} + +async function connect(url: string, options?: string): Promise { + const client = new Client({ connectionString: withoutSchema(url), options }); + await client.connect(); + return client; +} + +/** Run the boot step and keep what it says on stderr. */ +async function boot( + env: Record, +): Promise<{ url: string | null; said: string }> { + const lines: string[] = []; + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + lines.push(String(chunk)); + return true; + }); + try { + const url = await ensureAppRole(env as NodeJS.ProcessEnv); + return { url, said: lines.join("") }; + } finally { + spy.mockRestore(); + } +} + +async function sqlState(db: Client, sql: string): Promise { + try { + await db.query(sql); + return null; + } catch (err) { + return (err as { code?: string }).code ?? "unknown"; + } +} + +let owner: Client; +let serverVersion = 0; + +beforeAll(async () => { + owner = await connect(ownerUrl()); + const { rows } = await owner.query<{ v: number }>( + `SELECT current_setting('server_version_num')::int AS v`, + ); + serverVersion = rows[0].v; + // Each file starts from a worker database without the queue schema. + await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`); +}); + +afterAll(async () => { + // The role is cluster-wide: hand back what it owns here and drop it, so no + // other file or run sees it. + for (const role of [APP_ROLE, PROBE_OWNER]) { + const { rows } = await owner.query( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [role], + ); + if (rows.length === 0) continue; + await owner.query(`REASSIGN OWNED BY "${role}" TO CURRENT_USER`); + await owner.query(`DROP OWNED BY "${role}"`); + await owner.query(`DROP ROLE "${role}"`); + } + await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`); + await owner.query(`DELETE FROM users WHERE id = $1`, [USER_ID]); + await owner.end(); +}); + +describe("DB_APP_ROLE=off", () => { + it("stays on the owner without touching the database", async () => { + const { url, said } = await boot({ + DATABASE_URL: ownerUrl(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + DB_APP_ROLE: "off", + }); + expect(url).toBeNull(); + expect(said).toContain("DB_APP_ROLE=off"); + const { rows } = await owner.query( + `SELECT 1 FROM pg_roles WHERE rolname = $1`, + [APP_ROLE], + ); + expect(rows).toHaveLength(0); + }); +}); + +describe("an owner that may not create roles", () => { + it("leaves the app on that user, with a notice and no secret in it", async () => { + await owner.query( + `CREATE ROLE ${PROBE_OWNER} LOGIN NOSUPERUSER NOCREATEROLE PASSWORD 'probe-owner-pw'`, + ); + const url = new URL(ownerUrl()); + url.username = PROBE_OWNER; + url.password = "probe-owner-pw"; + const { url: handed, said } = await boot({ + DATABASE_URL: url.toString(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + }); + expect(handed).toBeNull(); + expect(said).toContain(`the app connects as ${PROBE_OWNER}`); + if (serverVersion >= 170000) { + expect(said).toContain("may not create roles"); + } + expect(said).not.toContain("probe-owner-pw"); + expect(said).not.toContain(appRolePassword(HMAC_KEY)); + }); +}); + +describe("Postgres before 17", () => { + it("stays on the owner: no MAINTAIN privilege", async (ctx) => { + if (serverVersion >= 170000) ctx.skip(); + const { url, said } = await boot({ + DATABASE_URL: ownerUrl(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + }); + expect(url).toBeNull(); + expect(said).toContain("no MAINTAIN privilege"); + }); +}); + +describe("the restricted role (Postgres 17+)", () => { + let appUrl: string; + let app: Client; + + beforeAll(async () => { + if (serverVersion < 170000) return; + // A database first created before Postgres 15 still lets PUBLIC create + // objects in `public`; the boot must take that away from the role too. + await owner.query(`GRANT CREATE ON SCHEMA public TO PUBLIC`); + // An index the role may not drop, as an instance that has not run 0416. + await owner.query( + `CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`, + ); + const first = await boot({ + DATABASE_URL: ownerUrl(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + }); + expect(first.url, first.said).not.toBeNull(); + expect(first.said).toContain(`the app connects as ${APP_ROLE}`); + expect(first.said).not.toContain(appRolePassword(HMAC_KEY)); + // The first run drops it as the owner; put it back for the role's turn. + expect(first.said).toContain(`dropped index ${RETIRED}`); + await owner.query( + `CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`, + ); + appUrl = first.url!; + app = await connect(appUrl); + }); + + afterAll(async () => { + await app?.end(); + }); + + it("keeps the owner's host, database and parameters", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + const owned = new URL(ownerUrl()); + const handed = new URL(appUrl); + expect(handed.username).toBe(APP_ROLE); + expect(handed.password).toBe(appRolePassword(HMAC_KEY)); + expect(handed.host).toBe(owned.host); + expect(handed.pathname).toBe(owned.pathname); + expect(handed.search).toBe(owned.search); + }); + + it("reads and writes rows through Prisma", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + const db = new PrismaClient({ + adapter: new PrismaPg({ connectionString: withoutSchema(appUrl) }), + }); + try { + await db.user.create({ + data: { + id: USER_ID, + username: "app-role-probe", + email: "app-role-probe@example.test", + role: "USER", + }, + }); + await db.measurement.create({ + data: { + userId: USER_ID, + type: "WEIGHT", + value: 70, + unit: "kg", + measuredAt: new Date("2026-01-01T08:00:00Z"), + }, + }); + await db.user.update({ + where: { id: USER_ID }, + data: { username: "app-role-probe-2" }, + }); + expect(await db.measurement.count({ where: { userId: USER_ID } })).toBe( + 1, + ); + await db.measurement.deleteMany({ where: { userId: USER_ID } }); + const migrations = await db.$queryRaw<{ n: bigint }[]>` + SELECT count(*) AS n FROM _prisma_migrations`; + expect(Number(migrations[0].n)).toBeGreaterThan(100); + } finally { + await db.$disconnect(); + } + }); + + it("runs pg-boss: install, a queue partition, a job", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + const boss = new PgBoss({ + connectionString: withoutSchema(appUrl), + createSchema: false, + supervise: false, + schedule: false, + }); + boss.on("error", () => {}); + await boss.start(); + try { + await boss.createQueue("app-role-probe", { policy: "standard" }); + const id = await boss.send("app-role-probe", { n: 1 }); + const [job] = await boss.fetch("app-role-probe"); + expect(job?.id).toBe(id); + await boss.complete("app-role-probe", job.id); + } finally { + await boss.stop({ graceful: false }); + } + const { rows } = await owner.query<{ foreign: string }>( + `SELECT count(*)::text AS foreign FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'pgboss' AND c.relkind IN ('r', 'p') + AND pg_get_userbyid(c.relowner) <> $1`, + [APP_ROLE], + ); + expect(rows[0].foreign).toBe("0"); + }); + + it("vacuums and rebuilds measurements, and leaves the drop to the owner", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + const db = await connect(appUrl, MAINTENANCE_SESSION_OPTIONS); + try { + const summary = await runMeasurementMaintenance(db, { + vacuum: true, + reindex: true, + }); + expect(summary.outcome).toBe("completed"); + // A VACUUM without the privilege only warns and skips the table, so + // the step reads ok either way; the grant is what makes it real. + const { rows } = await db.query<{ maintain: boolean }>( + `SELECT has_table_privilege(current_user, 'measurements', 'MAINTAIN') AS maintain`, + ); + expect(rows[0].maintain).toBe(true); + expect(summary.steps.find((s) => s.step === "vacuum")?.ok).toBe(true); + const rebuilt = summary.steps.filter((s) => s.step === "reindex"); + expect(rebuilt.length).toBeGreaterThan(0); + expect(rebuilt.every((s) => s.ok)).toBe(true); + expect(summary.dropsLeftForBoot).toEqual([RETIRED]); + } finally { + await db.end(); + } + }); + + it("is refused schema changes, roles, schemas, server files and the migration history", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + const refused = { + dropTable: await sqlState(app, `DROP TABLE "users"`), + alterTable: await sqlState( + app, + `ALTER TABLE "users" ADD COLUMN probe text`, + ), + createTable: await sqlState(app, `CREATE TABLE public.probe (x int)`), + createFunction: await sqlState( + app, + `CREATE FUNCTION public.probe() RETURNS int LANGUAGE sql AS 'SELECT 1'`, + ), + createRole: await sqlState(app, `CREATE ROLE probe_escalation`), + createSchema: await sqlState(app, `CREATE SCHEMA probe`), + readFile: await sqlState(app, `SELECT pg_read_file('postgresql.conf')`), + program: await sqlState(app, `COPY (SELECT 1) TO PROGRAM 'true'`), + dropIndex: await sqlState(app, `DROP INDEX "${RETIRED}"`), + writeMigrations: await sqlState( + app, + `DELETE FROM _prisma_migrations WHERE false`, + ), + becomeOwner: await sqlState( + app, + `SET ROLE ${new URL(ownerUrl()).username}`, + ), + }; + expect(refused).toEqual({ + dropTable: "42501", + alterTable: "42501", + createTable: "42501", + createFunction: "42501", + createRole: "42501", + createSchema: "42501", + readFile: "42501", + program: "42501", + dropIndex: "42501", + writeMigrations: "42501", + becomeOwner: "42501", + }); + const { rows } = await app.query( + `SELECT rolsuper, rolcreaterole, rolcreatedb, rolinherit, rolreplication, rolbypassrls + FROM pg_roles WHERE rolname = current_user`, + ); + expect(rows[0]).toEqual({ + rolsuper: false, + rolcreaterole: false, + rolcreatedb: false, + rolinherit: false, + rolreplication: false, + rolbypassrls: false, + }); + }); + + it("is idempotent: a second boot keeps the password and drops what the role could not", async (ctx) => { + if (serverVersion < 170000) ctx.skip(); + // A table a later migration adds: the next boot grants it. + await owner.query(`CREATE TABLE public.app_role_probe_new (x int)`); + try { + const second = await boot({ + DATABASE_URL: ownerUrl(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + }); + expect(second.url).toBe(appUrl); + expect(second.said).toContain(`dropped index ${RETIRED}`); + // The connection opened before the second boot still works. + await app.query(`INSERT INTO public.app_role_probe_new VALUES (1)`); + const third = await boot({ + DATABASE_URL: ownerUrl(), + API_TOKEN_HMAC_KEY: HMAC_KEY, + }); + expect(third.url).toBe(appUrl); + expect(third.said).not.toContain("dropped index"); + const { rows } = await owner.query( + `SELECT to_regclass($1) IS NULL AS gone`, + [RETIRED], + ); + expect(rows[0].gone).toBe(true); + } finally { + await owner.query(`DROP TABLE public.app_role_probe_new`); + } + }); +});