The verb wrapped the caller's text in BEGIN READ ONLY ... ROLLBACK as the superuser, so 'COMMIT; ...' left the transaction and, proven on a throwaway server, COPY TO PROGRAM ran a shell command on the database host. The statement now runs as mesh_store_reader: pg_read_all_data, no other grant, read-only transactions by role and session, its password an own-secret the mesh mints. Without that password the call is refused. -q drops the command tags that came back as rows keyed by BEGIN.
113 lines
5.4 KiB
TypeScript
113 lines
5.4 KiB
TypeScript
// What holds the store's read-only query to being read-only (novox/hq issue 193): a caller's
|
|
// statement runs as the reader login and never as the admin, is sent as given with no transaction
|
|
// wrapped around it as text, and comes back as rows keyed by their columns. The reader is made once,
|
|
// as the admin, with every attribute stated; without its password the statement is refused.
|
|
//
|
|
// psql is a fake on PATH that records each call's user, options and statement, and answers in CSV
|
|
// the way the real one does with -q. That the reader cannot write is the server's to enforce and is
|
|
// proven against a real server, not here; this holds the module to asking for it.
|
|
// Run against the compiled module (npm test builds first), the way the runtime loads it.
|
|
|
|
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { PostgresClient, READER } from "../dist/client.js";
|
|
|
|
let dir: string;
|
|
let log: string;
|
|
const originalPath = process.env.PATH;
|
|
|
|
before(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "postgres-reader-"));
|
|
log = join(dir, "calls.jsonl");
|
|
// Records argv, the user it connected as and the options it was given; answers a role lookup
|
|
// with no rows and anything else with a two-column result.
|
|
await writeFile(join(dir, "psql"), `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const args = process.argv.slice(2);
|
|
const at = (flag) => args[args.indexOf(flag) + 1];
|
|
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({
|
|
user: at("-U"), database: at("-d"), sql: at("-c"), quiet: args.includes("-q"),
|
|
password: process.env.PGPASSWORD, options: process.env.PGOPTIONS ?? "",
|
|
}) + "\\n");
|
|
const sql = at("-c");
|
|
if (/FROM pg_roles/.test(sql)) process.stdout.write("?column?\\n");
|
|
else if (/^(CREATE|ALTER|GRANT)/.test(sql)) process.stdout.write("");
|
|
else process.stdout.write("name,n\\nalpha,1\\n\\"b,eta\\",2\\n");
|
|
`);
|
|
await chmod(join(dir, "psql"), 0o755);
|
|
process.env.PATH = `${dir}:${originalPath}`;
|
|
});
|
|
|
|
after(async () => {
|
|
process.env.PATH = originalPath;
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function calls(): Promise<Record<string, unknown>[]> {
|
|
const text = await readFile(log, "utf8").catch(() => "");
|
|
await writeFile(log, "");
|
|
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
}
|
|
|
|
const conn = { host: "127.0.0.1", port: 5432, user: "postgres", password: "admin-secret" };
|
|
|
|
test("a caller's statement runs as the reader, as given, read-only, and comes back keyed by its columns", async () => {
|
|
const client = new PostgresClient({ ...conn, readerPassword: "reader-secret" });
|
|
const statement = "COMMIT; DROP TABLE everything";
|
|
const result = await client.readOnlyQuery("inventory", statement);
|
|
|
|
const made = await calls();
|
|
const asked = made.at(-1)!;
|
|
assert.equal(asked.user, READER, "the statement never runs as the admin");
|
|
assert.equal(asked.password, "reader-secret");
|
|
assert.equal(asked.sql, statement, "sent as given: no transaction wrapped around it as text");
|
|
assert.equal(asked.database, "inventory");
|
|
assert.equal(asked.quiet, true, "no command tags, which came back as rows keyed by BEGIN");
|
|
assert.match(String(asked.options), /default_transaction_read_only=on/);
|
|
|
|
assert.deepEqual(result.rows, [{ name: "alpha", n: "1" }, { name: "b,eta", n: "2" }]);
|
|
assert.equal(result.command, "COMMIT");
|
|
});
|
|
|
|
test("the reader is made as the admin, with every attribute stated, once per process", async () => {
|
|
const client = new PostgresClient({ ...conn, readerPassword: "reader-secret" });
|
|
await client.readOnlyQuery("inventory", "SELECT 1");
|
|
await client.readOnlyQuery("inventory", "SELECT 2");
|
|
|
|
const made = await calls();
|
|
const asAdmin = made.filter((c) => c.user === "postgres");
|
|
assert.ok(asAdmin.every((c) => c.password === "admin-secret"));
|
|
const ddl = asAdmin.map((c) => String(c.sql));
|
|
const role = ddl.find((s) => s.startsWith(`CREATE ROLE "${READER}"`));
|
|
assert.ok(role, "made when it does not exist");
|
|
for (const attribute of ["LOGIN", "NOSUPERUSER", "NOCREATEDB", "NOCREATEROLE", "NOREPLICATION", "NOBYPASSRLS"]) {
|
|
assert.match(role!, new RegExp(`\\b${attribute}\\b`));
|
|
}
|
|
assert.ok(ddl.includes(`GRANT pg_read_all_data TO "${READER}"`), "reads everything and is granted nothing else");
|
|
assert.ok(ddl.some((s) => /default_transaction_read_only = on/.test(s)));
|
|
assert.equal(ddl.filter((s) => s.startsWith("CREATE ROLE")).length, 1, "made once, not per call");
|
|
assert.equal(made.filter((c) => c.user === READER).length, 2);
|
|
});
|
|
|
|
test("without the reader's password the statement is refused, and nothing runs as the admin", async () => {
|
|
const client = new PostgresClient(conn);
|
|
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the admin/);
|
|
assert.deepEqual(await calls(), []);
|
|
});
|
|
|
|
test("the reader's password is read from the file the mesh delivers", async () => {
|
|
const file = join(dir, "reader.secret");
|
|
await writeFile(file, "from-the-file\n");
|
|
const client = PostgresClient.fromEnv({
|
|
MESH_POSTGRES_HOST: "127.0.0.1", MESH_POSTGRES_PASSWORD: "admin-secret",
|
|
MESH_POSTGRES_READER_PASSWORD_FILE: file,
|
|
});
|
|
await client.readOnlyQuery("inventory", "SELECT 1");
|
|
const asked = (await calls()).at(-1)!;
|
|
assert.equal(asked.password, "from-the-file");
|
|
});
|