/* Prove the reveal role can read exactly four columns of one table, and nothing else. * * node scripts/ops-reveal-probe.cjs * * Connects as ops_reveal (OPS_REVEAL_DATABASE_URL). The role exists so that revealing a * coordinator's contacts is a narrower door with its own key rather than an exception in code: * lib/ops/reveal.ts selects id + coordinator + coordinatorEmail + coordinatorPhone from Facility * and may never select more. This probe is the fact behind that sentence — it must succeed on * those four and be refused on every other column and every other table, including the ones * ops_ro can count. * * Like ops-ro-probe.cjs it exits 2 (skipped) on the local PGlite server, which ignores roles. */ require("dotenv/config"); const { Client } = require("pg"); const url = process.env.OPS_REVEAL_DATABASE_URL; if (!url) { console.error("OPS_REVEAL_DATABASE_URL must be set"); process.exit(1); } const MUST_SUCCEED = [ ['read the four contact columns', 'SELECT "id", "coordinator", "coordinatorEmail", "coordinatorPhone" FROM "Facility" LIMIT 1'], ]; const MUST_BE_REFUSED = [ ["a facility's name", 'SELECT "name" FROM "Facility" LIMIT 1'], ["a facility's logo", 'SELECT "logoData" FROM "Facility" LIMIT 1'], ["a facility's whole row", 'SELECT * FROM "Facility" LIMIT 1'], ["counting staff", 'SELECT count(*) FROM "Staff"'], ["a wearer's name", 'SELECT "first" FROM "Staff" LIMIT 1'], ["a coordinator's email", 'SELECT "email" FROM "User" LIMIT 1'], ["a password hash", 'SELECT "passwordHash" FROM "User" LIMIT 1'], ["a photo", 'SELECT "data" FROM "Photo" LIMIT 1'], ["a request's reason", 'SELECT "reason" FROM "Request" LIMIT 1'], ["an audit event", 'SELECT "op" FROM "AuditEvent" LIMIT 1'], ["an operator row", 'SELECT "email" FROM "Operator" LIMIT 1'], ["a reveal grant", 'SELECT "reason" FROM "RevealGrant" LIMIT 1'], ["the migration table", 'SELECT count(*) FROM "_prisma_migrations"'], ["writing anything", 'UPDATE "Facility" SET "coordinator" = "coordinator" WHERE false'], ]; (async () => { const c = new Client({ connectionString: url }); await c.connect(); 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_reveal" || who.rows[0].super) { console.log(`ops-reveal-probe: SKIPPED — connected as ${who.rows[0].session_user}${who.rows[0].super ? " (superuser)" : ""}, not ops_reveal.`); 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 reveal role is wider than four columns"); } 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-reveal-probe: PASS=${pass} FAIL=${fail}`); process.exit(fail === 0 ? 0 : 1); })().catch((e) => { console.error("ops-reveal-probe: could not connect as ops_reveal :: " + e.message); process.exit(1); });