/* Prove the operations console's database role cannot read customer content. * * node scripts/ops-ro-probe.cjs * * Connects as ops_ro (OPS_DATABASE_URL) and asks the database two questions: * - can it COUNT the staff register? must succeed — the console shows sizes * - can it READ a name from the register? must be refused — the console never shows one * plus the same pair for a coordinator's email and a facility's contact email. * * This is the check that makes "ops_ro is read-only and content-blind" a fact about the database * rather than a claim in a comment. It is run by scripts/e2e-ops.sh, and it is the right thing to * run by hand after any grant change. Exit 0 only when every refusal is refused and every count * counts. */ require("dotenv/config"); const { Client } = require("pg"); const url = process.env.OPS_DATABASE_URL; if (!url) { console.error("OPS_DATABASE_URL must be set"); process.exit(1); } const MUST_SUCCEED = [ ['count staff', 'SELECT count(*) FROM "Staff"'], ['count active staff', 'SELECT count(*) FROM "Staff" WHERE "inactive" = false'], ['count issues', 'SELECT count(*) FROM "Issue"'], ['read facility names', 'SELECT "name", "rev", "lastBackup" FROM "Facility" LIMIT 1'], ['read the plan scaffold', 'SELECT "plan", "planNote" FROM "Facility" LIMIT 1'], ['count users with 2FA', 'SELECT count(*) FROM "User" WHERE "totpEnabledAt" IS NOT NULL'], ['read audit op names', 'SELECT "op", "at" FROM "AuditEvent" LIMIT 1'], ['read applied migrations', 'SELECT count(*) FROM "_prisma_migrations"'], ]; const MUST_BE_REFUSED = [ ["a wearer's name", 'SELECT "first" FROM "Staff" LIMIT 1'], ["a wearer's phone", 'SELECT "phone" FROM "Staff" LIMIT 1'], ["a coordinator's email", 'SELECT "email" FROM "User" LIMIT 1'], ["a password hash", 'SELECT "passwordHash" FROM "User" LIMIT 1'], ["a facility's contact", 'SELECT "coordinatorEmail" FROM "Facility" LIMIT 1'], ["a facility's logo", 'SELECT "logoData" FROM "Facility" LIMIT 1'], ["a photo", 'SELECT "data" FROM "Photo" LIMIT 1'], ["a request's reason", 'SELECT "reason" FROM "Request" LIMIT 1'], ["a request message", 'SELECT "body" FROM "RequestMessage" LIMIT 1'], ["an audit event's actor", 'SELECT "userName" FROM "AuditEvent" LIMIT 1'], ["a staff account's email", 'SELECT "email" FROM "StaffAccount" LIMIT 1'], ["an operator row", 'SELECT "email" FROM "Operator" LIMIT 1'], ["writing anything", 'UPDATE "Facility" SET "rev" = "rev" WHERE false'], ]; (async () => { const c = new Client({ connectionString: url }); await c.connect(); // The local `prisma dev` server is PGlite — Postgres compiled to WebAssembly — and it runs every // connection as its one superuser whatever username the URL names. Every grant is meaningless // there, and a green run would be a lie. So: if the session is not actually ops_ro, this is not // an environment that can answer the question. Exit 2, distinct from a failure, and say so. // The real answer comes from running this on the box, against real Postgres, after the grants // migration has deployed. const who = await c.query("SELECT session_user, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS super"); if (who.rows[0].session_user !== "ops_ro" || who.rows[0].super) { console.log(`ops-ro-probe: SKIPPED — connected as ${who.rows[0].session_user}${who.rows[0].super ? " (superuser)" : ""}, not ops_ro.`); console.log(" This server does not enforce roles (PGlite runs everything as postgres). Run on production."); await c.end(); process.exit(2); } let pass = 0, fail = 0; for (const [name, sql] of MUST_SUCCEED) { try { await c.query(sql); pass++; console.log(" ✓ can " + name); } catch (e) { fail++; console.log(" ✗ cannot " + name + " :: " + e.message); } } for (const [name, sql] of MUST_BE_REFUSED) { try { await c.query(sql); fail++; console.log(" ✗ CAN READ " + name + " — the role is not content-blind"); } catch (e) { if (/permission denied/i.test(e.message)) { pass++; console.log(" ✓ refused " + name); } else { fail++; console.log(" ✗ " + name + " failed for the wrong reason :: " + e.message); } } } await c.end(); console.log(`ops-ro-probe: PASS=${pass} FAIL=${fail}`); process.exit(fail === 0 ? 0 : 1); })().catch((e) => { console.error("ops-ro-probe: could not connect as ops_ro :: " + e.message); process.exit(1); });