Files
threadcount-community/scripts/ops-ro-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

79 lines
4.5 KiB
JavaScript

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