mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role with row access to the app's schema, MAINTAIN on measurements and ownership of the pgboss schema, and starts the app with a DATABASE_URL for it in place of the owner's. The role cannot alter or drop tables, create roles, schemas or functions in the app's schema, read server files or run programs, or write the migration history. The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a separate worker container derives the same one, and it is sent as a SCRAM verifier. Every boot re-applies the grants in one transaction under an advisory lock, and drops as the owner the indexes the maintenance job may no longer drop. When the owner cannot create roles, the server is older than Postgres 17, or the role cannot log in, the boot says why and the app stays on the owner as before. DB_APP_ROLE=off skips it.
This commit is contained in:
1 parent
95525a0259
commit
485161a657
5 files changed
+1059
-3
No files matched your search
@@ -257,6 +257,8 @@ COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrat
|
||||
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
|
||||
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
|
||||
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
|
||||
COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs
|
||||
COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs
|
||||
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
|
||||
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
|
||||
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
|
||||
|
||||
@@ -107,6 +107,22 @@ NODE_PATH=/opt/prisma-cli/node_modules \
|
||||
node scripts/migrate-deploy.mjs
|
||||
echo "HealthLog: Migrations complete."
|
||||
|
||||
# ── The app's database role ──────────────────────────────────
|
||||
# Everything above ran as the owner DATABASE_URL names. The app itself
|
||||
# connects as `healthlog_app`, which reads and writes rows and runs the queue
|
||||
# but cannot change the schema (scripts/db-app-role.mjs says what it may and
|
||||
# may not do, and docs/self-hosting/database-role.md how to opt out). The
|
||||
# script hands the role's URL over on descriptor 3 only, never on stdout or
|
||||
# stderr; nothing handed over means it said why and the app stays on the
|
||||
# owner, as before. The owner's URL is replaced, not kept beside it, so it
|
||||
# does not reach the app's environment.
|
||||
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
|
||||
if [ -n "$app_database_url" ]; then
|
||||
DATABASE_URL="$app_database_url"
|
||||
export DATABASE_URL
|
||||
fi
|
||||
unset app_database_url
|
||||
|
||||
# ── Start application ────────────────────────────────────────
|
||||
echo "HealthLog: Starting application..."
|
||||
exec "$@"
|
||||
@@ -0,0 +1,648 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The database role the running app connects as.
|
||||
*
|
||||
* The migrations, the dump before them and the restore connect as the owner
|
||||
* `DATABASE_URL` names. The long-running web and worker processes do not need
|
||||
* the owner: they read and write rows, run the queue, and maintain one table.
|
||||
* After the migrations, `docker-entrypoint.sh` runs this script as the owner.
|
||||
* It ensures a login role, `healthlog_app`, that can do exactly that, and
|
||||
* hands the entrypoint a `DATABASE_URL` for it; the entrypoint then starts the
|
||||
* app with that URL in place of the owner's, so the owner's credentials never
|
||||
* reach the app's environment.
|
||||
*
|
||||
* What the role can do:
|
||||
* - SELECT, INSERT, UPDATE, DELETE on every table of the app's schema, and
|
||||
* USAGE, SELECT, UPDATE on its sequences. `_prisma_migrations` is SELECT
|
||||
* only: the app reads the migration history, it never writes it.
|
||||
* - MAINTAIN on `measurements` (Postgres 17+): the maintenance job's
|
||||
* `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY`.
|
||||
* - Own the `pgboss` schema and everything in it. pg-boss creates and
|
||||
* migrates its own tables, functions and per-queue partitions at runtime,
|
||||
* so it needs DDL there; it is the queue's private schema and nothing of
|
||||
* the app's data lives in it.
|
||||
*
|
||||
* What it cannot do: create, alter or drop anything in the app's schema;
|
||||
* create schemas, databases or roles; act as a superuser, so no
|
||||
* `COPY ... FROM PROGRAM`, no `pg_read_file`, no untrusted languages; write
|
||||
* the migration history. `CREATE` on the app's schema is revoked from PUBLIC
|
||||
* as well (the default since Postgres 15; a database first created on an
|
||||
* older major still grants it), where the owner may do so.
|
||||
*
|
||||
* Every boot re-applies it, inside one transaction under an advisory lock, so
|
||||
* a table a new migration created is covered before the app starts, a web and
|
||||
* a worker container booting together do not race, and a restored database
|
||||
* (whose objects `pg_restore --no-owner` hands to the owner) is put right.
|
||||
* Grants only touch what lacks them, so a boot after the first writes
|
||||
* almost nothing to the catalog.
|
||||
*
|
||||
* The password is derived, not stored: HMAC-SHA256 of a fixed label under
|
||||
* API_TOKEN_HMAC_KEY. Every container of one instance carries that key, so a
|
||||
* separate worker container derives the same password without a new
|
||||
* variable. It is set as a SCRAM verifier computed here, so the plaintext
|
||||
* never travels to the server, where a statement log could keep it. The role
|
||||
* is marked with a comment; a role of the same name without the mark is not
|
||||
* HealthLog's, and is left alone.
|
||||
*
|
||||
* Fail open, loudly. When the owner cannot do this (a managed or external
|
||||
* database whose user may not create roles, Postgres before 17, a pg_hba.conf
|
||||
* that does not let the new role in, a lock that did not come in time), the
|
||||
* script says why in one notice and the app runs as the owner, as it did
|
||||
* before. `DB_APP_ROLE=off` skips it for an operator who manages roles
|
||||
* themselves, including one whose `DATABASE_URL` already names a restricted role.
|
||||
*
|
||||
* Why Postgres 17: the maintenance job vacuums and rebuilds the indexes of
|
||||
* `measurements`, and before the MAINTAIN privilege only the table's owner
|
||||
* could. Rather than leave that job broken on 16, an older server keeps
|
||||
* today's behaviour.
|
||||
*
|
||||
* Index drops stay with the owner: `DROP INDEX` belongs to the table's owner
|
||||
* and MAINTAIN does not include it. When the role is in place this script
|
||||
* also drops, as the owner, the indexes the maintenance job used to drop
|
||||
* (retired indexes, and the invalid copies an interrupted concurrent rebuild
|
||||
* leaves), each under a short lock wait, so a held lock delays the boot by
|
||||
* seconds and the drop waits for the next boot.
|
||||
*
|
||||
* Interface: the URL for the role goes to file descriptor 3 and nowhere else;
|
||||
* every message goes to stderr, and no message carries a password or a URL.
|
||||
* The entrypoint reads it with `url="$(node scripts/db-app-role.mjs 3>&1 1>&2)"`.
|
||||
* Without descriptor 3 (a manual run) the role is still ensured and the URL is
|
||||
* not printed. Exit 0 in every case the app should start; nothing written to
|
||||
* descriptor 3 means: stay on the owner.
|
||||
*
|
||||
* Plain ESM with no imports beyond Node, `pg` and the shared dump module, so
|
||||
* it runs from the runtime image like the other entrypoint scripts.
|
||||
*/
|
||||
import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto";
|
||||
import { writeSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { connect } from "../src/lib/database-backup/dump-core.mjs";
|
||||
import {
|
||||
CONCURRENT_COPY,
|
||||
RETIRED_MEASUREMENT_INDEXES,
|
||||
} from "../src/lib/jobs/retired-measurement-indexes.mjs";
|
||||
|
||||
/** The role's name. Cluster-wide, like every Postgres role. */
|
||||
export const APP_ROLE = "healthlog_app";
|
||||
|
||||
/** The mark on a role this script created. */
|
||||
export const ROLE_COMMENT =
|
||||
"Managed by HealthLog: the app's least-privilege login (scripts/db-app-role.mjs). Recreated and re-granted on every start.";
|
||||
|
||||
/** The schema pg-boss keeps its queue in (its default; HealthLog sets none). */
|
||||
export const QUEUE_SCHEMA = "pgboss";
|
||||
|
||||
/** Tables the role may maintain (VACUUM, ANALYZE, REINDEX). */
|
||||
export const MAINTAINED_TABLES = Object.freeze(["measurements"]);
|
||||
|
||||
/** Tables the role may only read. */
|
||||
export const READ_ONLY_TABLES = Object.freeze(["_prisma_migrations"]);
|
||||
|
||||
/** The label the password is derived under. A new label is a new password. */
|
||||
const PASSWORD_LABEL = "healthlog database app role v1";
|
||||
|
||||
/** The first major with the MAINTAIN privilege. */
|
||||
export const MIN_SERVER_VERSION_NUM = 170000;
|
||||
|
||||
/** How long the setup waits for a lock before staying on the owner. */
|
||||
const SETUP_LOCK_TIMEOUT = "15s";
|
||||
|
||||
/** How long a boot-time index drop waits for its lock. */
|
||||
export const BOOT_DROP_LOCK_TIMEOUT = "5s";
|
||||
|
||||
/** An identifier this script splices into SQL: lower-case catalog names only. */
|
||||
const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/;
|
||||
|
||||
function say(message) {
|
||||
process.stderr.write(`HealthLog: ${message}\n`);
|
||||
}
|
||||
|
||||
/** Double-quote an identifier after checking its shape. */
|
||||
function ident(name) {
|
||||
if (!IDENTIFIER.test(name)) {
|
||||
throw new Error("refusing to splice an identifier of unexpected shape");
|
||||
}
|
||||
return `"${name}"`;
|
||||
}
|
||||
|
||||
/** The role's password: 64 hex characters, the same in every container. */
|
||||
export function appRolePassword(hmacKey) {
|
||||
if (!hmacKey) throw new Error("API_TOKEN_HMAC_KEY is not set");
|
||||
return createHmac("sha256", hmacKey).update(PASSWORD_LABEL).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* A SCRAM-SHA-256 verifier in the form `pg_authid.rolpassword` stores
|
||||
* (RFC 5802/7677; the password is ASCII hex, so SASLprep leaves it as is).
|
||||
* Postgres takes a string of this shape as already hashed.
|
||||
*/
|
||||
export function scramVerifier(
|
||||
password,
|
||||
salt = randomBytes(16),
|
||||
iterations = 4096,
|
||||
) {
|
||||
const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256");
|
||||
const clientKey = createHmac("sha256", salted).update("Client Key").digest();
|
||||
const storedKey = createHash("sha256").update(clientKey).digest();
|
||||
const serverKey = createHmac("sha256", salted).update("Server Key").digest();
|
||||
return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** The owner's URL with the role's name and password: same host, database and parameters. */
|
||||
export function appRoleUrl(ownerUrl, password) {
|
||||
const url = new URL(ownerUrl);
|
||||
url.username = APP_ROLE;
|
||||
url.password = password;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** The user a URL names, for the notices. Never the password. */
|
||||
function userOf(databaseUrl) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(databaseUrl).username) || "(default)";
|
||||
} catch {
|
||||
return "(unparsable)";
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(err) {
|
||||
const code = err?.code;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return typeof code === "string" ? `${code} ${message}` : message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the connected user can set the role up, and why not when it
|
||||
* cannot. Read-only.
|
||||
*
|
||||
* @param {{ query: Function }} client
|
||||
* @returns {Promise<{ ok: true, superuser: boolean } | { ok: false, reason: string, detail: string }>}
|
||||
*/
|
||||
export async function checkPreconditions(client) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT current_setting('server_version_num')::int AS version,
|
||||
r.rolsuper AS superuser,
|
||||
r.rolcreaterole AS createrole,
|
||||
current_user AS me
|
||||
FROM pg_roles r WHERE r.rolname = current_user`,
|
||||
);
|
||||
const me = rows[0];
|
||||
if (me.me === APP_ROLE) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "already_app_role",
|
||||
detail: `DATABASE_URL already names ${APP_ROLE}`,
|
||||
};
|
||||
}
|
||||
if (me.version < MIN_SERVER_VERSION_NUM) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "server_too_old",
|
||||
detail: `Postgres ${Math.floor(me.version / 10000)} has no MAINTAIN privilege (17 or newer does)`,
|
||||
};
|
||||
}
|
||||
if (!me.superuser && !me.createrole) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "cannot_create_roles",
|
||||
detail: `the database user ${me.me} may not create roles (a managed or external database, or a user without CREATEROLE)`,
|
||||
};
|
||||
}
|
||||
const existing = await client.query(
|
||||
`SELECT shobj_description(r.oid, 'pg_authid') AS comment,
|
||||
pg_has_role(r.oid, current_user, 'MEMBER') AS member_of_owner,
|
||||
r.rolsuper OR r.rolreplication OR r.rolbypassrls AS elevated
|
||||
FROM pg_roles r WHERE r.rolname = $1`,
|
||||
[APP_ROLE],
|
||||
);
|
||||
const role = existing.rows[0];
|
||||
if (role && role.comment !== ROLE_COMMENT) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "foreign_role",
|
||||
detail: `a role named ${APP_ROLE} exists that HealthLog did not create`,
|
||||
};
|
||||
}
|
||||
if (role?.member_of_owner) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "role_inherits_owner",
|
||||
detail: `${APP_ROLE} is a member of ${me.me}, so it would hold the owner's rights`,
|
||||
};
|
||||
}
|
||||
if (role?.elevated && !me.superuser) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "role_elevated",
|
||||
detail: `${APP_ROLE} has SUPERUSER, REPLICATION or BYPASSRLS, and only a superuser can take it away`,
|
||||
};
|
||||
}
|
||||
return { ok: true, superuser: me.superuser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update the role and apply every grant, in one transaction.
|
||||
* Call it on a client connected as the owner, after the migrations.
|
||||
*
|
||||
* @param {{ query: Function }} client
|
||||
* @param {{ password: string, schema?: string, superuser: boolean }} options
|
||||
*/
|
||||
export async function applyAppRole(client, options) {
|
||||
const schema = options.schema ?? "public";
|
||||
const role = ident(APP_ROLE);
|
||||
const app = ident(schema);
|
||||
const queue = ident(QUEUE_SCHEMA);
|
||||
// The verifier is a base64/`$`/`:` string; it never contains a quote.
|
||||
const verifier = scramVerifier(options.password);
|
||||
if (verifier.includes("'")) throw new Error("unexpected verifier shape");
|
||||
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
await client.query(`SET LOCAL lock_timeout = '${SETUP_LOCK_TIMEOUT}'`);
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(hashtext('healthlog.db-app-role'))`,
|
||||
);
|
||||
|
||||
const { rows: present } = await client.query(
|
||||
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
|
||||
[APP_ROLE],
|
||||
);
|
||||
// The attributes a superuser alone may set are named only by one; a
|
||||
// role a CREATEROLE user creates never has them (checked beforehand).
|
||||
const elevatedOff = options.superuser
|
||||
? " NOSUPERUSER NOREPLICATION NOBYPASSRLS"
|
||||
: "";
|
||||
const attributes = `LOGIN NOCREATEDB NOCREATEROLE NOINHERIT${elevatedOff}`;
|
||||
if (present.length === 0) {
|
||||
await client.query(
|
||||
`CREATE ROLE ${role} ${attributes} PASSWORD '${verifier}'`,
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`ALTER ROLE ${role} ${attributes} PASSWORD '${verifier}'`,
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`COMMENT ON ROLE ${role} IS '${ROLE_COMMENT.replace(/'/g, "''")}'`,
|
||||
);
|
||||
|
||||
// A non-superuser owner hands objects to the role only when it may act
|
||||
// as it (Postgres 16+), and must keep reading what the role owns: the
|
||||
// dump before a migration reads the queue schema too. The membership
|
||||
// runs owner -> role, never the other way.
|
||||
if (!options.superuser) {
|
||||
await client.query(
|
||||
`GRANT ${role} TO CURRENT_USER WITH INHERIT TRUE, SET TRUE`,
|
||||
);
|
||||
}
|
||||
|
||||
const { rows: db } = await client.query(
|
||||
`SELECT current_database() AS name`,
|
||||
);
|
||||
await client.query(
|
||||
`GRANT CONNECT, TEMPORARY ON DATABASE "${String(db[0].name).replace(/"/g, '""')}" TO ${role}`,
|
||||
);
|
||||
|
||||
// The app's schema: rows, never structure.
|
||||
await client.query(`GRANT USAGE ON SCHEMA ${app} TO ${role}`);
|
||||
await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM ${role}`);
|
||||
const { rows: schemaOwner } = await client.query(
|
||||
`SELECT pg_has_role(current_user, nspowner, 'USAGE') AS owns
|
||||
FROM pg_namespace WHERE nspname = $1`,
|
||||
[schema],
|
||||
);
|
||||
if (options.superuser || schemaOwner[0]?.owns) {
|
||||
await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM PUBLIC`);
|
||||
} else {
|
||||
say(
|
||||
`notice: the schema ${schema} is not the database user's to change; PUBLIC may still create objects in it`,
|
||||
);
|
||||
}
|
||||
|
||||
const { rows: tables } = await client.query(
|
||||
`SELECT c.relname AS name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
||||
AND c.relname <> ALL($3::text[])
|
||||
AND NOT (has_table_privilege($2, c.oid, 'SELECT')
|
||||
AND has_table_privilege($2, c.oid, 'INSERT')
|
||||
AND has_table_privilege($2, c.oid, 'UPDATE')
|
||||
AND has_table_privilege($2, c.oid, 'DELETE'))`,
|
||||
[schema, APP_ROLE, READ_ONLY_TABLES],
|
||||
);
|
||||
for (const { name } of tables) {
|
||||
await client.query(
|
||||
`GRANT SELECT, INSERT, UPDATE, DELETE ON ${app}.${ident(name)} TO ${role}`,
|
||||
);
|
||||
}
|
||||
for (const name of READ_ONLY_TABLES) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT to_regclass($1) IS NOT NULL AS present`,
|
||||
[`${app}.${ident(name)}`],
|
||||
);
|
||||
if (!rows[0].present) continue;
|
||||
await client.query(
|
||||
`REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${app}.${ident(name)} FROM ${role}`,
|
||||
);
|
||||
await client.query(`GRANT SELECT ON ${app}.${ident(name)} TO ${role}`);
|
||||
}
|
||||
|
||||
const { rows: sequences } = await client.query(
|
||||
`SELECT c.relname AS name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = $1 AND c.relkind = 'S'
|
||||
AND NOT (has_sequence_privilege($2, c.oid, 'USAGE')
|
||||
AND has_sequence_privilege($2, c.oid, 'SELECT')
|
||||
AND has_sequence_privilege($2, c.oid, 'UPDATE'))`,
|
||||
[schema, APP_ROLE],
|
||||
);
|
||||
for (const { name } of sequences) {
|
||||
await client.query(
|
||||
`GRANT USAGE, SELECT, UPDATE ON SEQUENCE ${app}.${ident(name)} TO ${role}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Objects the owner creates later in this schema (the migrations of the
|
||||
// next update) are granted at creation as well. The per-boot pass above
|
||||
// is what the app relies on; this covers the time between the two.
|
||||
await client.query(
|
||||
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role}`,
|
||||
);
|
||||
await client.query(
|
||||
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${role}`,
|
||||
);
|
||||
|
||||
for (const name of MAINTAINED_TABLES) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT to_regclass($1) IS NOT NULL AS present`,
|
||||
[`${app}.${ident(name)}`],
|
||||
);
|
||||
if (rows[0].present) {
|
||||
await client.query(
|
||||
`GRANT MAINTAIN ON ${app}.${ident(name)} TO ${role}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The queue's schema belongs to the role: pg-boss creates its tables,
|
||||
// functions and partitions there at runtime. On the first boot after an
|
||||
// update, and after a restore, everything in it still belongs to the
|
||||
// owner and is handed over once.
|
||||
await client.query(
|
||||
`CREATE SCHEMA IF NOT EXISTS ${queue} AUTHORIZATION ${role}`,
|
||||
);
|
||||
await client.query(`ALTER SCHEMA ${queue} OWNER TO ${role}`);
|
||||
await transferQueueObjects(client, role);
|
||||
|
||||
await client.query("COMMIT");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand every object in the queue schema the role does not own to it. */
|
||||
async function transferQueueObjects(client, role) {
|
||||
const { rows: relations } = await client.query(
|
||||
`SELECT c.oid::regclass::text AS name, c.relkind AS kind
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = $1
|
||||
AND c.relowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)
|
||||
AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
|
||||
-- A sequence a column owns follows its table.
|
||||
AND NOT (c.relkind = 'S' AND EXISTS (
|
||||
SELECT 1 FROM pg_depend d
|
||||
WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid
|
||||
AND d.deptype IN ('a', 'i')))
|
||||
ORDER BY c.relkind = 'r' DESC, c.oid`,
|
||||
[QUEUE_SCHEMA, APP_ROLE],
|
||||
);
|
||||
for (const { name, kind } of relations) {
|
||||
const what =
|
||||
kind === "S"
|
||||
? "SEQUENCE"
|
||||
: kind === "v"
|
||||
? "VIEW"
|
||||
: kind === "m"
|
||||
? "MATERIALIZED VIEW"
|
||||
: kind === "f"
|
||||
? "FOREIGN TABLE"
|
||||
: "TABLE";
|
||||
// `regclass::text` is the catalog's own quoting of the name.
|
||||
await client.query(`ALTER ${what} ${name} OWNER TO ${role}`);
|
||||
}
|
||||
|
||||
const { rows: routines } = await client.query(
|
||||
`SELECT p.oid::regprocedure::text AS signature
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = $1
|
||||
AND p.proowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)`,
|
||||
[QUEUE_SCHEMA, APP_ROLE],
|
||||
);
|
||||
for (const { signature } of routines) {
|
||||
await client.query(`ALTER ROUTINE ${signature} OWNER TO ${role}`);
|
||||
}
|
||||
|
||||
// Enums, domains and free-standing composite types; a table's row type
|
||||
// and an array type follow their owner object.
|
||||
const { rows: types } = await client.query(
|
||||
`SELECT t.oid::regtype::text AS name, t.typtype AS kind
|
||||
FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
LEFT JOIN pg_class c ON c.oid = t.typrelid
|
||||
WHERE n.nspname = $1
|
||||
AND t.typowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)
|
||||
AND t.typelem = 0
|
||||
AND (t.typtype IN ('e', 'd', 'r') OR (t.typtype = 'c' AND c.relkind = 'c'))`,
|
||||
[QUEUE_SCHEMA, APP_ROLE],
|
||||
);
|
||||
for (const { name, kind } of types) {
|
||||
await client.query(
|
||||
`ALTER ${kind === "d" ? "DOMAIN" : "TYPE"} ${name} OWNER TO ${role}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop, as the owner, the indexes on `measurements` the maintenance job can
|
||||
* no longer drop as the role: the retired ones and the invalid copies of a
|
||||
* concurrent rebuild. Each waits at most {@link BOOT_DROP_LOCK_TIMEOUT}; one
|
||||
* that does not get its lock is left for the next boot.
|
||||
*
|
||||
* @param {{ query: Function }} client a connection outside any transaction
|
||||
* @returns {Promise<{ dropped: string[], left: string[] }>}
|
||||
*/
|
||||
export async function dropOwnerOnlyIndexes(client) {
|
||||
const dropped = [];
|
||||
const left = [];
|
||||
const { rows: table } = await client.query(
|
||||
`SELECT to_regclass('measurements') IS NOT NULL AS present`,
|
||||
);
|
||||
if (!table[0].present) return { dropped, left };
|
||||
const { rows } = await client.query(
|
||||
`SELECT c.relname AS name, i.indisvalid AS valid
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE i.indrelid = 'measurements'::regclass`,
|
||||
);
|
||||
const targets = rows
|
||||
.filter(
|
||||
(r) =>
|
||||
RETIRED_MEASUREMENT_INDEXES.includes(r.name) ||
|
||||
(!r.valid && CONCURRENT_COPY.test(r.name)),
|
||||
)
|
||||
.map((r) => r.name);
|
||||
if (targets.length === 0) return { dropped, left };
|
||||
const { rows: setting } = await client.query(
|
||||
`SELECT current_setting('lock_timeout') AS value`,
|
||||
);
|
||||
try {
|
||||
await client.query(`SET lock_timeout = '${BOOT_DROP_LOCK_TIMEOUT}'`);
|
||||
for (const name of targets) {
|
||||
try {
|
||||
await client.query(`DROP INDEX CONCURRENTLY IF EXISTS ${ident(name)}`);
|
||||
dropped.push(name);
|
||||
} catch (err) {
|
||||
left.push(name);
|
||||
say(
|
||||
`notice: index ${name} on measurements was not dropped (${errorText(err)}); the next start tries again`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.query(`SELECT set_config('lock_timeout', $1, false)`, [
|
||||
setting[0]?.value ?? "0",
|
||||
]);
|
||||
}
|
||||
return { dropped, left };
|
||||
}
|
||||
|
||||
/** Log in as the role once, so a pg_hba.conf that refuses it shows here. */
|
||||
async function proveLogin(url) {
|
||||
const client = await connect(url);
|
||||
try {
|
||||
const { rows } = await client.query(`SELECT current_user AS me`);
|
||||
if (rows[0].me !== APP_ROLE) {
|
||||
throw new Error(`logged in as ${rows[0].me}, not ${APP_ROLE}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the URL to descriptor 3 when the caller opened it. */
|
||||
function handOver(url) {
|
||||
try {
|
||||
writeSync(3, url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The boot step. Returns the role's URL when the app should use it, or null
|
||||
* to stay on the owner.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} env
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function ensureAppRole(env = process.env) {
|
||||
const ownerUrl = env.DATABASE_URL;
|
||||
const mode = (env.DB_APP_ROLE ?? "").trim().toLowerCase();
|
||||
if (mode === "off" || mode === "false") {
|
||||
say(
|
||||
`DB_APP_ROLE=off: the app connects as ${userOf(ownerUrl)}, the user DATABASE_URL names`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (mode && mode !== "auto" && mode !== "on") {
|
||||
say(
|
||||
`notice: DB_APP_ROLE="${mode}" is not auto or off; treating it as auto`,
|
||||
);
|
||||
}
|
||||
if (!ownerUrl) return null;
|
||||
|
||||
const user = userOf(ownerUrl);
|
||||
const fallBack = (detail) => {
|
||||
say(
|
||||
`notice: the app connects as ${user}, the user DATABASE_URL names, not as the restricted role ${APP_ROLE}: ${detail}. ` +
|
||||
"This is how every earlier release ran. " +
|
||||
`If ${user} is a restricted role you manage yourself, DB_APP_ROLE=off silences this; otherwise see docs/self-hosting/database-role.md.`,
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = await connect(ownerUrl);
|
||||
} catch (err) {
|
||||
return fallBack(`could not connect to set it up (${errorText(err)})`);
|
||||
}
|
||||
try {
|
||||
const pre = await checkPreconditions(client);
|
||||
if (!pre.ok) {
|
||||
if (pre.reason === "already_app_role") {
|
||||
say(`DATABASE_URL already names ${APP_ROLE}; nothing to set up`);
|
||||
return null;
|
||||
}
|
||||
return fallBack(pre.detail);
|
||||
}
|
||||
const password = appRolePassword(env.API_TOKEN_HMAC_KEY);
|
||||
const schema = new URL(ownerUrl).searchParams.get("schema") || "public";
|
||||
try {
|
||||
await applyAppRole(client, {
|
||||
password,
|
||||
schema,
|
||||
superuser: pre.superuser,
|
||||
});
|
||||
} catch (err) {
|
||||
return fallBack(`setting it up failed (${errorText(err)})`);
|
||||
}
|
||||
const url = appRoleUrl(ownerUrl, password);
|
||||
try {
|
||||
await proveLogin(url);
|
||||
} catch (err) {
|
||||
return fallBack(
|
||||
`${APP_ROLE} cannot log in (${errorText(err)}); a pg_hba.conf that lists users by name needs a line for it`,
|
||||
);
|
||||
}
|
||||
const { dropped } = await dropOwnerOnlyIndexes(client);
|
||||
if (dropped.length > 0) {
|
||||
say(`dropped index ${dropped.join(", ")} on measurements`);
|
||||
}
|
||||
say(
|
||||
`the app connects as ${APP_ROLE}: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)`,
|
||||
);
|
||||
return url;
|
||||
} catch (err) {
|
||||
return fallBack(`the check failed (${errorText(err)})`);
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(env = process.env) {
|
||||
const url = await ensureAppRole(env);
|
||||
if (url && !handOver(url)) {
|
||||
say(
|
||||
"the role is ready; the URL goes to file descriptor 3, which this run did not open",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main().then(
|
||||
(code) => process.exit(code),
|
||||
(err) => {
|
||||
say(`notice: the database role step failed (${errorText(err)})`);
|
||||
process.exit(0);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -74,9 +74,11 @@ function entrypointScripts(): string[] {
|
||||
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
|
||||
return [
|
||||
...new Set(
|
||||
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
// A line that starts with the call, or a command substitution that
|
||||
// makes it (`url="$(node scripts/db-app-role.mjs …)"`).
|
||||
[
|
||||
...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm),
|
||||
].map((m) => m[1]),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
@@ -126,6 +128,7 @@ describe("scripts the entrypoint runs ship in the image", () => {
|
||||
it("finds the boot scripts it is meant to check", () => {
|
||||
// An empty match would agree with any Dockerfile.
|
||||
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
|
||||
expect(entrypointScripts()).toContain("scripts/db-app-role.mjs");
|
||||
});
|
||||
|
||||
it("copies every boot script and its local imports into the runner stage", () => {
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* The app's restricted database role, against a real Postgres.
|
||||
*
|
||||
* `scripts/db-app-role.mjs` runs at boot as the owner and hands the app a URL
|
||||
* for `healthlog_app`. Proven here: the role does what the app does (Prisma
|
||||
* reads and writes, pg-boss installing its schema and creating a queue
|
||||
* partition, the measurement maintenance's VACUUM and concurrent rebuild),
|
||||
* and is refused what it must not do (DDL in the app's schema, roles,
|
||||
* schemas, server files and programs, the migration history). A second run is
|
||||
* a no-op that keeps the same password, the index the role may not drop is
|
||||
* dropped by the next run as the owner, and an owner that may not create
|
||||
* roles leaves the app on the owner with a notice.
|
||||
*
|
||||
* On Postgres 16 (the nightly `INTEGRATION_PG_IMAGE` run) the script stays
|
||||
* on the owner by design; the file proves that instead.
|
||||
*/
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Client } from "pg";
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
MAINTENANCE_SESSION_OPTIONS,
|
||||
RETIRED_MEASUREMENT_INDEXES,
|
||||
runMeasurementMaintenance,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
import {
|
||||
APP_ROLE,
|
||||
appRolePassword,
|
||||
ensureAppRole,
|
||||
} from "../../scripts/db-app-role.mjs";
|
||||
|
||||
const HMAC_KEY = "a".repeat(64);
|
||||
const PROBE_OWNER = "app_role_probe_owner";
|
||||
const USER_ID = "user-app-role-probe";
|
||||
const RETIRED = RETIRED_MEASUREMENT_INDEXES[0];
|
||||
|
||||
function ownerUrl(): string {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is not set for the integration run");
|
||||
return url;
|
||||
}
|
||||
|
||||
function withoutSchema(url: string): string {
|
||||
const u = new URL(url);
|
||||
u.searchParams.delete("schema");
|
||||
u.searchParams.delete("pgbouncer");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
async function connect(url: string, options?: string): Promise<Client> {
|
||||
const client = new Client({ connectionString: withoutSchema(url), options });
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Run the boot step and keep what it says on stderr. */
|
||||
async function boot(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<{ url: string | null; said: string }> {
|
||||
const lines: string[] = [];
|
||||
const spy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation((chunk: string | Uint8Array) => {
|
||||
lines.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
const url = await ensureAppRole(env as NodeJS.ProcessEnv);
|
||||
return { url, said: lines.join("") };
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function sqlState(db: Client, sql: string): Promise<string | null> {
|
||||
try {
|
||||
await db.query(sql);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return (err as { code?: string }).code ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
let owner: Client;
|
||||
let serverVersion = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await connect(ownerUrl());
|
||||
const { rows } = await owner.query<{ v: number }>(
|
||||
`SELECT current_setting('server_version_num')::int AS v`,
|
||||
);
|
||||
serverVersion = rows[0].v;
|
||||
// Each file starts from a worker database without the queue schema.
|
||||
await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// The role is cluster-wide: hand back what it owns here and drop it, so no
|
||||
// other file or run sees it.
|
||||
for (const role of [APP_ROLE, PROBE_OWNER]) {
|
||||
const { rows } = await owner.query(
|
||||
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
|
||||
[role],
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
await owner.query(`REASSIGN OWNED BY "${role}" TO CURRENT_USER`);
|
||||
await owner.query(`DROP OWNED BY "${role}"`);
|
||||
await owner.query(`DROP ROLE "${role}"`);
|
||||
}
|
||||
await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`);
|
||||
await owner.query(`DELETE FROM users WHERE id = $1`, [USER_ID]);
|
||||
await owner.end();
|
||||
});
|
||||
|
||||
describe("DB_APP_ROLE=off", () => {
|
||||
it("stays on the owner without touching the database", async () => {
|
||||
const { url, said } = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
DB_APP_ROLE: "off",
|
||||
});
|
||||
expect(url).toBeNull();
|
||||
expect(said).toContain("DB_APP_ROLE=off");
|
||||
const { rows } = await owner.query(
|
||||
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
|
||||
[APP_ROLE],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an owner that may not create roles", () => {
|
||||
it("leaves the app on that user, with a notice and no secret in it", async () => {
|
||||
await owner.query(
|
||||
`CREATE ROLE ${PROBE_OWNER} LOGIN NOSUPERUSER NOCREATEROLE PASSWORD 'probe-owner-pw'`,
|
||||
);
|
||||
const url = new URL(ownerUrl());
|
||||
url.username = PROBE_OWNER;
|
||||
url.password = "probe-owner-pw";
|
||||
const { url: handed, said } = await boot({
|
||||
DATABASE_URL: url.toString(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(handed).toBeNull();
|
||||
expect(said).toContain(`the app connects as ${PROBE_OWNER}`);
|
||||
if (serverVersion >= 170000) {
|
||||
expect(said).toContain("may not create roles");
|
||||
}
|
||||
expect(said).not.toContain("probe-owner-pw");
|
||||
expect(said).not.toContain(appRolePassword(HMAC_KEY));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Postgres before 17", () => {
|
||||
it("stays on the owner: no MAINTAIN privilege", async (ctx) => {
|
||||
if (serverVersion >= 170000) ctx.skip();
|
||||
const { url, said } = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(url).toBeNull();
|
||||
expect(said).toContain("no MAINTAIN privilege");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the restricted role (Postgres 17+)", () => {
|
||||
let appUrl: string;
|
||||
let app: Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (serverVersion < 170000) return;
|
||||
// A database first created before Postgres 15 still lets PUBLIC create
|
||||
// objects in `public`; the boot must take that away from the role too.
|
||||
await owner.query(`GRANT CREATE ON SCHEMA public TO PUBLIC`);
|
||||
// An index the role may not drop, as an instance that has not run 0416.
|
||||
await owner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
|
||||
);
|
||||
const first = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(first.url, first.said).not.toBeNull();
|
||||
expect(first.said).toContain(`the app connects as ${APP_ROLE}`);
|
||||
expect(first.said).not.toContain(appRolePassword(HMAC_KEY));
|
||||
// The first run drops it as the owner; put it back for the role's turn.
|
||||
expect(first.said).toContain(`dropped index ${RETIRED}`);
|
||||
await owner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
|
||||
);
|
||||
appUrl = first.url!;
|
||||
app = await connect(appUrl);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.end();
|
||||
});
|
||||
|
||||
it("keeps the owner's host, database and parameters", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const owned = new URL(ownerUrl());
|
||||
const handed = new URL(appUrl);
|
||||
expect(handed.username).toBe(APP_ROLE);
|
||||
expect(handed.password).toBe(appRolePassword(HMAC_KEY));
|
||||
expect(handed.host).toBe(owned.host);
|
||||
expect(handed.pathname).toBe(owned.pathname);
|
||||
expect(handed.search).toBe(owned.search);
|
||||
});
|
||||
|
||||
it("reads and writes rows through Prisma", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const db = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: withoutSchema(appUrl) }),
|
||||
});
|
||||
try {
|
||||
await db.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "app-role-probe",
|
||||
email: "app-role-probe@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
await db.measurement.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
type: "WEIGHT",
|
||||
value: 70,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-01-01T08:00:00Z"),
|
||||
},
|
||||
});
|
||||
await db.user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { username: "app-role-probe-2" },
|
||||
});
|
||||
expect(await db.measurement.count({ where: { userId: USER_ID } })).toBe(
|
||||
1,
|
||||
);
|
||||
await db.measurement.deleteMany({ where: { userId: USER_ID } });
|
||||
const migrations = await db.$queryRaw<{ n: bigint }[]>`
|
||||
SELECT count(*) AS n FROM _prisma_migrations`;
|
||||
expect(Number(migrations[0].n)).toBeGreaterThan(100);
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs pg-boss: install, a queue partition, a job", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const boss = new PgBoss({
|
||||
connectionString: withoutSchema(appUrl),
|
||||
createSchema: false,
|
||||
supervise: false,
|
||||
schedule: false,
|
||||
});
|
||||
boss.on("error", () => {});
|
||||
await boss.start();
|
||||
try {
|
||||
await boss.createQueue("app-role-probe", { policy: "standard" });
|
||||
const id = await boss.send("app-role-probe", { n: 1 });
|
||||
const [job] = await boss.fetch("app-role-probe");
|
||||
expect(job?.id).toBe(id);
|
||||
await boss.complete("app-role-probe", job.id);
|
||||
} finally {
|
||||
await boss.stop({ graceful: false });
|
||||
}
|
||||
const { rows } = await owner.query<{ foreign: string }>(
|
||||
`SELECT count(*)::text AS foreign FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'pgboss' AND c.relkind IN ('r', 'p')
|
||||
AND pg_get_userbyid(c.relowner) <> $1`,
|
||||
[APP_ROLE],
|
||||
);
|
||||
expect(rows[0].foreign).toBe("0");
|
||||
});
|
||||
|
||||
it("vacuums and rebuilds measurements, and leaves the drop to the owner", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const db = await connect(appUrl, MAINTENANCE_SESSION_OPTIONS);
|
||||
try {
|
||||
const summary = await runMeasurementMaintenance(db, {
|
||||
vacuum: true,
|
||||
reindex: true,
|
||||
});
|
||||
expect(summary.outcome).toBe("completed");
|
||||
// A VACUUM without the privilege only warns and skips the table, so
|
||||
// the step reads ok either way; the grant is what makes it real.
|
||||
const { rows } = await db.query<{ maintain: boolean }>(
|
||||
`SELECT has_table_privilege(current_user, 'measurements', 'MAINTAIN') AS maintain`,
|
||||
);
|
||||
expect(rows[0].maintain).toBe(true);
|
||||
expect(summary.steps.find((s) => s.step === "vacuum")?.ok).toBe(true);
|
||||
const rebuilt = summary.steps.filter((s) => s.step === "reindex");
|
||||
expect(rebuilt.length).toBeGreaterThan(0);
|
||||
expect(rebuilt.every((s) => s.ok)).toBe(true);
|
||||
expect(summary.dropsLeftForBoot).toEqual([RETIRED]);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
|
||||
it("is refused schema changes, roles, schemas, server files and the migration history", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const refused = {
|
||||
dropTable: await sqlState(app, `DROP TABLE "users"`),
|
||||
alterTable: await sqlState(
|
||||
app,
|
||||
`ALTER TABLE "users" ADD COLUMN probe text`,
|
||||
),
|
||||
createTable: await sqlState(app, `CREATE TABLE public.probe (x int)`),
|
||||
createFunction: await sqlState(
|
||||
app,
|
||||
`CREATE FUNCTION public.probe() RETURNS int LANGUAGE sql AS 'SELECT 1'`,
|
||||
),
|
||||
createRole: await sqlState(app, `CREATE ROLE probe_escalation`),
|
||||
createSchema: await sqlState(app, `CREATE SCHEMA probe`),
|
||||
readFile: await sqlState(app, `SELECT pg_read_file('postgresql.conf')`),
|
||||
program: await sqlState(app, `COPY (SELECT 1) TO PROGRAM 'true'`),
|
||||
dropIndex: await sqlState(app, `DROP INDEX "${RETIRED}"`),
|
||||
writeMigrations: await sqlState(
|
||||
app,
|
||||
`DELETE FROM _prisma_migrations WHERE false`,
|
||||
),
|
||||
becomeOwner: await sqlState(
|
||||
app,
|
||||
`SET ROLE ${new URL(ownerUrl()).username}`,
|
||||
),
|
||||
};
|
||||
expect(refused).toEqual({
|
||||
dropTable: "42501",
|
||||
alterTable: "42501",
|
||||
createTable: "42501",
|
||||
createFunction: "42501",
|
||||
createRole: "42501",
|
||||
createSchema: "42501",
|
||||
readFile: "42501",
|
||||
program: "42501",
|
||||
dropIndex: "42501",
|
||||
writeMigrations: "42501",
|
||||
becomeOwner: "42501",
|
||||
});
|
||||
const { rows } = await app.query(
|
||||
`SELECT rolsuper, rolcreaterole, rolcreatedb, rolinherit, rolreplication, rolbypassrls
|
||||
FROM pg_roles WHERE rolname = current_user`,
|
||||
);
|
||||
expect(rows[0]).toEqual({
|
||||
rolsuper: false,
|
||||
rolcreaterole: false,
|
||||
rolcreatedb: false,
|
||||
rolinherit: false,
|
||||
rolreplication: false,
|
||||
rolbypassrls: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent: a second boot keeps the password and drops what the role could not", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
// A table a later migration adds: the next boot grants it.
|
||||
await owner.query(`CREATE TABLE public.app_role_probe_new (x int)`);
|
||||
try {
|
||||
const second = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(second.url).toBe(appUrl);
|
||||
expect(second.said).toContain(`dropped index ${RETIRED}`);
|
||||
// The connection opened before the second boot still works.
|
||||
await app.query(`INSERT INTO public.app_role_probe_new VALUES (1)`);
|
||||
const third = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(third.url).toBe(appUrl);
|
||||
expect(third.said).not.toContain("dropped index");
|
||||
const { rows } = await owner.query(
|
||||
`SELECT to_regclass($1) IS NULL AS gone`,
|
||||
[RETIRED],
|
||||
);
|
||||
expect(rows[0].gone).toBe(true);
|
||||
} finally {
|
||||
await owner.query(`DROP TABLE public.app_role_probe_new`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user