1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
79 lines
4.5 KiB
JavaScript
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); });
|