Files
threadcount-community/scripts/ops-reveal-probe.cjs
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

66 lines
3.5 KiB
JavaScript

/* 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); });