Files
scan-receipts/scripts/verify-db-permissions.mjs
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

264 lines
10 KiB
JavaScript

#!/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/<grantor>", sequence is "receipt_app=rU/<grantor>".
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);