postgres: the store's query runs as a read-only login, never as the admin (hq #193)
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.
This commit is contained in:
@@ -25,12 +25,30 @@ export interface PgConn {
|
||||
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
|
||||
@@ -54,7 +72,9 @@ export class PostgresClient {
|
||||
if (!host || !password) {
|
||||
throw new Error("postgres host or admin password is not set — postgres's own code cannot reach the server");
|
||||
}
|
||||
return new PostgresClient({ host, port, user, password });
|
||||
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 {
|
||||
@@ -143,11 +163,66 @@ export class PostgresClient {
|
||||
return res.rows.map((r) => ({ name: String(r.datname), sizeBytes: Number(r.size) }));
|
||||
}
|
||||
|
||||
/** Run a read-only SQL statement against a named database, for the postgres_query tool. */
|
||||
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
||||
// The read-only guarantee is a wrapping transaction the server honours.
|
||||
return this.query(`BEGIN TRANSACTION READ ONLY; ${sql}; ROLLBACK;`, database);
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
Reference in New Issue
Block a user