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.
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
// postgres's admin client — postgres's own code, living in the module (novox/hq ADR 0039). Both this
|
|
// module's tools and its provisioner import it, and nothing outside postgres does.
|
|
//
|
|
// SQL is executed through `psql`, not a wire-protocol driver: the module may take NO npm dependency
|
|
// beyond @novox/mesh-sdk, and hand-rolling startup + SCRAM auth + the query protocol is more surface
|
|
// than this should carry — so it shells out to the client the postgres tools ship, the same way
|
|
// minio drives itself through `mc` and mailu through doveadm. One boundary, `query()`, and every
|
|
// method is built on it.
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface QueryResult {
|
|
/** The command tag postgres returns, e.g. "SELECT", "CREATE DATABASE". */
|
|
readonly command: string;
|
|
readonly rows: Record<string, unknown>[];
|
|
}
|
|
|
|
export interface PgConn {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
readonly user: string;
|
|
readonly password: string;
|
|
/**
|
|
* The read-only login's password, which the mesh mints for this module (`own-secrets.reader`).
|
|
* Absent when the mesh has not delivered it: then a caller's statement is refused, never run as
|
|
* the admin (novox/hq issue 193).
|
|
*/
|
|
readonly readerPassword?: string;
|
|
}
|
|
|
|
/**
|
|
* The login a caller's statement runs as (novox/hq issue 193). It may read every table and change
|
|
* nothing: `pg_read_all_data` and no other grant, and every transaction it opens is read-only by
|
|
* the server's own setting. A statement cannot climb out of a login the way it can out of a
|
|
* transaction wrapped around it as text: `COMMIT; DROP …` ended the old wrapper and ran the rest as
|
|
* the superuser, and even one read-only statement as a superuser can run a program on the server.
|
|
*/
|
|
export const READER = "mesh_store_reader";
|
|
|
|
|
|
export class PostgresClient {
|
|
constructor(private readonly conn: PgConn) {}
|
|
|
|
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
|
|
private readerReady?: Promise<void>;
|
|
|
|
/**
|
|
* Build from the module's resolved environment. Reads MESH_POSTGRES_* first (the documented
|
|
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
|
|
* container. Throws if it cannot find a host and an admin password.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): PostgresClient {
|
|
const url = env.MESH_PROVISION_POSTGRES ? safeUrl(env.MESH_PROVISION_POSTGRES) : undefined;
|
|
const host = env.MESH_POSTGRES_HOST ?? url?.hostname;
|
|
// MESH_PROVISION_POSTGRES_PORT is the seat's twin (mesh-controller's own
|
|
// internal/envfile.Placed pattern): which port this machine actually put mesh-store at, when
|
|
// that differs from what the connection string above already says — e.g. adopted in place at
|
|
// a predecessor's port. Empty means the mesh has nothing to add and the string's own port
|
|
// stands; a placeholder the mesh never filled (a manifest ahead of the running controller)
|
|
// is treated the same way, not as a fault.
|
|
const seatPort = (env.MESH_PROVISION_POSTGRES_PORT ?? "").trim();
|
|
const filledSeatPort = seatPort && !/^\$\{[^}]*\}$/.test(seatPort) ? seatPort : undefined;
|
|
const portSource = env.MESH_POSTGRES_PORT ?? filledSeatPort ?? url?.port ?? "5432";
|
|
const port = Number(portSource) || 5432;
|
|
const user = env.MESH_POSTGRES_USER ?? url?.username ?? "postgres";
|
|
const password = env.MESH_POSTGRES_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
|
|
if (!host || !password) {
|
|
throw new Error("postgres host or admin password is not set — postgres's own code cannot reach the server");
|
|
}
|
|
const readerPassword = env.MESH_POSTGRES_READER_PASSWORD ??
|
|
readSecretFile(env.MESH_POSTGRES_READER_PASSWORD_FILE);
|
|
return new PostgresClient({ host, port, user, password, readerPassword });
|
|
}
|
|
|
|
get host(): string {
|
|
return this.conn.host;
|
|
}
|
|
|
|
get port(): number {
|
|
return this.conn.port;
|
|
}
|
|
|
|
/** Execute SQL against a database as the admin and return its rows, through `psql` (see header). */
|
|
async query(sql: string, database = "postgres"): Promise<QueryResult> {
|
|
// Executed through `psql`, the way minio drives itself through `mc` and mailu through doveadm:
|
|
// node has no postgres wire client without an npm dependency, and the module owns its own code
|
|
// (ADR 0039), so it shells out to the client the postgres tools ship. CSV so the rows come back
|
|
// structured; ON_ERROR_STOP so a failed statement is an error here, not a success with a warning.
|
|
const { stdout } = await run(
|
|
"psql",
|
|
["-h", this.conn.host, "-p", String(this.conn.port), "-U", this.conn.user, "-d", database,
|
|
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "--csv", "-c", sql],
|
|
{ env: { ...process.env, PGPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
|
);
|
|
const rows = parseCsvRows(stdout);
|
|
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
|
|
}
|
|
|
|
/**
|
|
* Create a login role and a database it owns, idempotently. The DDL is the full, correct shape, run through query(). Extensions can be requested per
|
|
* database and are created as the admin (a plain owner cannot install most of them).
|
|
*/
|
|
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
|
|
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
|
|
if (roles.rows.length === 0) {
|
|
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
|
} else {
|
|
// VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the
|
|
// provisioner runs would report it lost, and only clearing the expiry makes applying it again work.
|
|
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
|
}
|
|
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
|
|
if (dbs.rows.length === 0) {
|
|
await this.query(`CREATE DATABASE ${ident(database)} OWNER ${ident(role)}`);
|
|
}
|
|
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
|
|
}
|
|
|
|
/**
|
|
* Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its
|
|
* credential, checked by connecting as it. Read-only. `false` only when the server says so (the
|
|
* role, the password or the database is wrong or gone); an unreachable server rejects instead,
|
|
* because being unable to ask is not evidence of loss (novox/hq issue 120).
|
|
*/
|
|
async canConnectAs(database: string, role: string, password: string): Promise<boolean> {
|
|
try {
|
|
await run(
|
|
"psql",
|
|
["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database,
|
|
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"],
|
|
{ env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 },
|
|
);
|
|
return true;
|
|
} catch (err) {
|
|
const text = `${(err as { stderr?: string }).stderr ?? ""}`;
|
|
if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) {
|
|
return false;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Drop a database and its owning role, idempotently, after evicting live connections. */
|
|
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
|
|
await this.query(
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " +
|
|
literal(database) + " AND pid <> pg_backend_pid()",
|
|
);
|
|
await this.query(`DROP DATABASE IF EXISTS ${ident(database)}`);
|
|
await this.query(`DROP ROLE IF EXISTS ${ident(role)}`);
|
|
}
|
|
|
|
/** List the non-template databases, with size, for the postgres_list_databases tool. */
|
|
async listDatabases(): Promise<{ name: string; sizeBytes: number }[]> {
|
|
const res = await this.query(
|
|
"SELECT datname, pg_database_size(datname) AS size FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
|
);
|
|
return res.rows.map((r) => ({ name: String(r.datname), sizeBytes: Number(r.size) }));
|
|
}
|
|
|
|
/**
|
|
* Make the read-only login, idempotently, with the password the mesh minted for it. Run as the
|
|
* admin, because only the admin can make a role.
|
|
*/
|
|
async ensureReader(): Promise<void> {
|
|
const password = this.conn.readerPassword;
|
|
if (!password) throw readerMissing();
|
|
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(READER));
|
|
const verb = roles.rows.length === 0 ? "CREATE" : "ALTER";
|
|
// Every attribute stated, so an existing role someone widened is narrowed again on every start.
|
|
await this.query(
|
|
`${verb} ROLE ${ident(READER)} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION ` +
|
|
`NOBYPASSRLS INHERIT PASSWORD ${literal(password)} VALID UNTIL 'infinity'`,
|
|
);
|
|
await this.query(`GRANT pg_read_all_data TO ${ident(READER)}`);
|
|
await this.query(`ALTER ROLE ${ident(READER)} SET default_transaction_read_only = on`);
|
|
await this.query(`ALTER ROLE ${ident(READER)} SET statement_timeout = '60s'`);
|
|
}
|
|
|
|
/**
|
|
* Run a caller's statement against a named database as the read-only login, for the
|
|
* postgres_query tool and the store seat's `query` verb (novox/hq ADR 0159, issue 193).
|
|
*
|
|
* **Read-only by the login, not by text around the statement.** The statement is sent as it was
|
|
* given, as the reader, whose role can write nothing and whose transactions the server makes
|
|
* read-only. Never as the admin: without the reader's password the call is refused.
|
|
*/
|
|
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
|
this.readerReady ??= this.ensureReader().catch((err) => {
|
|
this.readerReady = undefined; // asked again next call, not failed for the process's life
|
|
throw err;
|
|
});
|
|
await this.readerReady;
|
|
const { stdout } = await run(
|
|
"psql",
|
|
// -q: no command tags, so the output is the header and the rows and nothing else — the tags
|
|
// were what came back as rows keyed by BEGIN.
|
|
["-h", this.conn.host, "-p", String(this.conn.port), "-U", READER, "-d", database,
|
|
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-q", "--csv", "-c", sql],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
PGPASSWORD: this.conn.readerPassword,
|
|
// Read-only from the first statement, before the role's own setting is read.
|
|
PGOPTIONS: "-c default_transaction_read_only=on -c statement_timeout=60s",
|
|
},
|
|
maxBuffer: 16 << 20,
|
|
},
|
|
);
|
|
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
|
|
return { command, rows: parseCsvRows(stdout) };
|
|
}
|
|
}
|
|
|
|
function readerMissing(): Error {
|
|
return new Error(
|
|
"the read-only login's password was not delivered (own-secrets.reader, " +
|
|
"MESH_POSTGRES_READER_PASSWORD_FILE), so the statement is refused rather than run as the " +
|
|
"admin (novox/hq issue 193)",
|
|
);
|
|
}
|
|
|
|
/** Generate a URL-safe password. */
|
|
export function generatePassword(): string {
|
|
return randomBytes(24).toString("base64url");
|
|
}
|
|
|
|
/** Quote a SQL identifier (double quotes, doubled internal quotes). */
|
|
export function ident(id: string): string {
|
|
return '"' + id.replace(/"/g, '""') + '"';
|
|
}
|
|
|
|
/** Quote a SQL string literal (single quotes, doubled internal quotes). */
|
|
export function literal(val: string): string {
|
|
return "'" + val.replace(/'/g, "''") + "'";
|
|
}
|
|
|
|
function readSecretFile(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try {
|
|
return readFileSync(path, "utf8").trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function safeUrl(raw: string): URL | undefined {
|
|
try {
|
|
return new URL(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Parse psql --csv output into row objects. RFC-4180: fields may be quoted, an embedded quote is
|
|
* doubled, and a quoted field may span newlines. Empty output (a DDL statement) yields no rows. */
|
|
function parseCsvRows(csv: string): Record<string, unknown>[] {
|
|
const records = parseCsv(csv);
|
|
if (records.length === 0) return [];
|
|
const [header, ...rows] = records;
|
|
return rows.map((cells) => {
|
|
const row: Record<string, unknown> = {};
|
|
header.forEach((name, i) => (row[name] = cells[i] ?? null));
|
|
return row;
|
|
});
|
|
}
|
|
|
|
function parseCsv(text: string): string[][] {
|
|
const records: string[][] = [];
|
|
let field = "";
|
|
let record: string[] = [];
|
|
let inQuotes = false;
|
|
let started = false;
|
|
const endRecord = (): void => {
|
|
if (started || field.length > 0 || record.length > 0) {
|
|
record.push(field);
|
|
records.push(record);
|
|
}
|
|
field = "";
|
|
record = [];
|
|
started = false;
|
|
};
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i];
|
|
if (inQuotes) {
|
|
if (c === '"') {
|
|
if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
|
|
} else field += c;
|
|
} else if (c === '"') { inQuotes = true; started = true; }
|
|
else if (c === ",") { record.push(field); field = ""; started = true; }
|
|
else if (c === "\n" || c === "\r") {
|
|
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
endRecord();
|
|
} else { field += c; started = true; }
|
|
}
|
|
endRecord();
|
|
return records;
|
|
}
|