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>
264 lines
10 KiB
JavaScript
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);
|