#!/usr/bin/env node /** * Verify the database least-privilege setup (scripts/db-permissions.sql). * * Connects twice: * 1. as the OWNER (DATABASE_URL from env, .env.local or .env) to inspect the * role attributes and privilege catalog, and * 2. as the RUNTIME role `receipt_app` (APP_DATABASE_URL if set, otherwise * derived from DATABASE_URL with user receipt_app and the runtime * password) to prove the role can do what the app needs — and cannot do * DDL. * * Prints one PASS/FAIL line per check and exits 1 if any check fails. * * Usage: * node scripts/verify-db-permissions.mjs * APP_DATABASE_URL=... node scripts/verify-db-permissions.mjs */ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Client } from "pg"; const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const DEFAULT_RUNTIME_PASSWORD = "receipt_app_secure_password"; const RUNTIME_ROLE = "receipt_app"; let passed = 0; let failed = 0; function check(name, ok, detail = "") { if (ok) { passed++; console.log(`[PASS] ${name}${detail ? ` - ${detail}` : ""}`); } else { failed++; console.log(`[FAIL] ${name}${detail ? ` - ${detail}` : ""}`); } } /** Minimal .env parser: strips quotes, ignores comments; first key wins. */ function parseEnvFile(filePath) { const out = {}; let text; try { text = readFileSync(filePath, "utf8"); } catch { return out; } for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq < 1) continue; const key = line.slice(0, eq).trim(); let value = line.slice(eq + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (!(key in out)) out[key] = value; } return out; } // Env precedence: process.env > .env.local > .env. const fileEnv = { ...parseEnvFile(path.join(ROOT, ".env")), ...parseEnvFile(path.join(ROOT, ".env.local")) }; const ownerUrl = process.env.DATABASE_URL || fileEnv.DATABASE_URL; if (!ownerUrl) { console.error("FATAL: DATABASE_URL not found. Set it in the environment, .env.local or .env."); process.exit(1); } const runtimePassword = process.env.APP_DATABASE_PASSWORD || fileEnv.APP_DATABASE_PASSWORD || DEFAULT_RUNTIME_PASSWORD; /** Runtime URL: APP_DATABASE_URL when provided, else derived from the owner URL. */ function runtimeUrl() { const explicit = process.env.APP_DATABASE_URL || fileEnv.APP_DATABASE_URL; if (explicit) return explicit; const u = new URL(ownerUrl); u.username = RUNTIME_ROLE; u.password = runtimePassword; return u.toString(); } console.log(`Verifying least-privilege setup for role "${RUNTIME_ROLE}" ...\n`); // --------------------------------------------------------------------------- // 1) Owner connection: role attributes + privilege catalog // --------------------------------------------------------------------------- const owner = new Client({ connectionString: ownerUrl }); await owner.connect(); const roleRes = await owner.query( `SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin FROM pg_roles WHERE rolname = $1`, [RUNTIME_ROLE] ); if (roleRes.rows.length === 0) { check(`${RUNTIME_ROLE} exists`, false, "role not found"); console.error("\nAborting: runtime role is missing. Run: node scripts/apply-db-permissions.mjs"); await owner.end(); process.exit(1); } const r = roleRes.rows[0]; check(`${RUNTIME_ROLE} exists with LOGIN`, r.rolcanlogin === true); check(`${RUNTIME_ROLE} is NOT a superuser`, r.rolsuper === false); check(`${RUNTIME_ROLE} has no CREATEDB`, r.rolcreatedb === false); check(`${RUNTIME_ROLE} has no CREATEROLE`, r.rolcreaterole === false); const privRes = await owner.query( `SELECT has_database_privilege($1, current_database(), 'CONNECT') AS db_connect, has_database_privilege($1, current_database(), 'TEMP') AS db_temp, has_schema_privilege($1, 'public', 'USAGE') AS schema_usage, has_schema_privilege($1, 'public', 'CREATE') AS schema_create`, [RUNTIME_ROLE] ); const p = privRes.rows[0]; check(`${RUNTIME_ROLE} has CONNECT on the database`, p.db_connect === true); check(`${RUNTIME_ROLE} has USAGE on schema public`, p.schema_usage === true); check(`${RUNTIME_ROLE} has NO CREATE on schema public (no DDL)`, p.schema_create === false); const publicRes = await owner.query( `SELECT has_schema_privilege('public', 'public', 'CREATE') AS public_create` ); check( "PUBLIC pseudo-role has NO CREATE on schema public (schema hardened)", publicRes.rows[0].public_create === false ); // All current public tables: DML granted, DDL (TRUNCATE/REFERENCES) NOT granted. const tableRes = await owner.query( `SELECT c.relname, has_table_privilege($1, c.oid, 'SELECT') AS can_select, has_table_privilege($1, c.oid, 'INSERT') AS can_insert, has_table_privilege($1, c.oid, 'UPDATE') AS can_update, has_table_privilege($1, c.oid, 'DELETE') AS can_delete, has_table_privilege($1, c.oid, 'TRUNCATE') AS can_truncate, has_table_privilege($1, c.oid, 'REFERENCES') AS can_references FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY c.relname`, [RUNTIME_ROLE] ); const missingDml = tableRes.rows.filter((t) => !(t.can_select && t.can_insert && t.can_update && t.can_delete)); check( `${RUNTIME_ROLE} has SELECT/INSERT/UPDATE/DELETE on ALL public tables`, missingDml.length === 0, `${tableRes.rows.length} table(s)` + (missingDml.length ? `; missing on: ${missingDml.map((t) => t.relname).join(", ")}` : "") ); const grantedDdl = tableRes.rows.filter((t) => t.can_truncate || t.can_references); check( `${RUNTIME_ROLE} has NO TRUNCATE/REFERENCES on tables (no DDL)`, grantedDdl.length === 0, grantedDdl.length ? `granted on: ${grantedDdl.map((t) => t.relname).join(", ")}` : `${tableRes.rows.length} table(s) checked` ); // All current public sequences: USAGE/SELECT granted, UPDATE (setval) NOT granted. const seqRes = await owner.query( `SELECT c.relname, has_sequence_privilege($1, c.oid, 'USAGE') AS can_usage, has_sequence_privilege($1, c.oid, 'SELECT') AS can_select, has_sequence_privilege($1, c.oid, 'UPDATE') AS can_update FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'S' ORDER BY c.relname`, [RUNTIME_ROLE] ); const missingSeq = seqRes.rows.filter((s) => !(s.can_usage && s.can_select)); check( `${RUNTIME_ROLE} has USAGE/SELECT on ALL public sequences`, missingSeq.length === 0, `${seqRes.rows.length} sequence(s)` + (missingSeq.length ? `; missing on: ${missingSeq.map((s) => s.relname).join(", ")}` : "") ); const updateSeq = seqRes.rows.filter((s) => s.can_update); check( `${RUNTIME_ROLE} has NO UPDATE on sequences (no setval/rewind)`, updateSeq.length === 0, updateSeq.length ? `granted on: ${updateSeq.map((s) => s.relname).join(", ")}` : `${seqRes.rows.length} sequence(s) checked` ); // Default privileges: future objects created by the owner get the same grants. const defRes = await owner.query( `SELECT p.defaclobjtype, array_to_string(p.defaclacl, ',') AS acl FROM pg_default_acl p JOIN pg_namespace n ON n.oid = p.defaclnamespace WHERE n.nspname = 'public' AND pg_get_userbyid(p.defaclrole) = current_user` ); const aclText = defRes.rows.map((x) => `${x.defaclobjtype}:${x.acl}`).join(" | "); // Table default ACL is "receipt_app=arwd/", sequence is "receipt_app=rU/". check( "DEFAULT PRIVILEGES grant DML to receipt_app for future tables", aclText.includes("receipt_app=arwd"), aclText || "no default ACLs recorded" ); check( "DEFAULT PRIVILEGES grant USAGE/SELECT on future sequences", aclText.includes("receipt_app=rU"), aclText || "no default ACLs recorded" ); // Owner must still be able to DDL (migrations/schema init unaffected). try { await owner.query("CREATE TABLE public.__receipt_owner_ddl_check__(id integer)"); await owner.query("DROP TABLE public.__receipt_owner_ddl_check__"); check("owner role can still CREATE/DROP tables (migrations unaffected)", true, "CREATE + DROP in public succeeded"); } catch (err) { check("owner role can still CREATE/DROP tables (migrations unaffected)", false, err.message); } // --------------------------------------------------------------------------- // 2) Runtime connection as receipt_app // --------------------------------------------------------------------------- const appUrl = runtimeUrl(); const app = new Client({ connectionString: appUrl }); let connected = false; try { await app.connect(); connected = true; check("receipt_app can connect with its own credentials", true, "(CONNECT granted; URL from APP_DATABASE_URL or derived from DATABASE_URL)"); const me = await app.query("SELECT current_user AS u, current_database() AS db"); check("connected session runs as receipt_app", me.rows[0].u === RUNTIME_ROLE, `current_user=${me.rows[0].u}, db=${me.rows[0].db}`); try { const cnt = await app.query("SELECT count(*) AS n FROM users"); check("receipt_app can SELECT from users", true, `count(*) = ${cnt.rows[0].n}`); } catch (err) { check("receipt_app can SELECT from users", false, err.message); } // DDL attempt: must be denied (SQLSTATE 42501 = insufficient_privilege). try { await app.query("CREATE TABLE public.__receipt_app_perm_check__(id integer)"); await app.query("DROP TABLE public.__receipt_app_perm_check__").catch(() => {}); check("receipt_app CANNOT create tables (DDL denied)", false, "CREATE TABLE unexpectedly succeeded"); } catch (err) { if (err.code === "42501") { check("receipt_app CANNOT create tables (DDL denied)", true, `SQLSTATE 42501 insufficient_privilege`); } else { check("receipt_app CANNOT create tables (DDL denied)", false, `unexpected error: ${err.message}`); } } } catch (err) { check("receipt_app can connect with its own credentials", false, err.message); } finally { if (connected) await app.end().catch(() => {}); } await owner.end(); console.log(`\nResult: ${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);