mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'fix/v147-review-dbrole' into release/v1.47.0
This commit is contained in:
commit
2d8b782f34
13 files changed
+1091
-337
No files matched your search
+3
-2
@@ -39,8 +39,9 @@
|
||||
# Left commented here so that append does not create a duplicate key. Uncomment
|
||||
# and set it directly instead if you prefer to edit the file by hand.
|
||||
|
||||
# The container runs the app as `healthlog_app`, a database role it creates on
|
||||
# every start that reads and writes rows but cannot change the schema. The
|
||||
# The container runs the app as `healthlog_app_<database>_<hash>`, a database
|
||||
# role it creates on every start that reads and writes rows but cannot change
|
||||
# the schema. The
|
||||
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
|
||||
# you manage database roles yourself (docs/self-hosting/database-role.md).
|
||||
# DB_APP_ROLE="off"
|
||||
|
||||
@@ -527,9 +527,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# settings at all; the pooler's or the database's own defaults then apply.
|
||||
# DATABASE_SESSION_OPTIONS_DISABLED="1"
|
||||
#
|
||||
# The app runs as `healthlog_app`, a database role the container creates on
|
||||
# every start: it reads and writes rows but cannot alter or drop tables or
|
||||
# create roles. Migrations and restores still use the user in DATABASE_URL.
|
||||
# The app runs as `healthlog_app_<database>_<hash>`, a database role the
|
||||
# container creates for this database on every start: it reads and writes
|
||||
# rows but cannot alter or drop tables, own anything, or create roles. Migrations and restores still use the user in DATABASE_URL.
|
||||
# Where that user may not create roles (a managed database), the start log
|
||||
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
|
||||
# the database roles yourself. See docs/self-hosting/database-role.md.
|
||||
|
||||
+3
-2
@@ -81,8 +81,9 @@ x-healthlog-env: &healthlog_env
|
||||
# 1 sends neither of the two settings above (they ride the libpq `options`
|
||||
# startup parameter, which PgBouncer refuses by default).
|
||||
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
|
||||
# The app connects as `healthlog_app`, a role the entrypoint creates that
|
||||
# reads and writes rows but cannot change the schema; the migrations keep
|
||||
# The app connects as `healthlog_app_<database>_<hash>`, a role the
|
||||
# entrypoint creates for this database that reads and writes rows but
|
||||
# cannot change the schema; the migrations and the queue's install keep
|
||||
# the owner above. off keeps the app on the owner (for an operator who
|
||||
# manages database roles). See docs/self-hosting/database-role.md.
|
||||
DB_APP_ROLE: "${DB_APP_ROLE:-}"
|
||||
|
||||
@@ -109,8 +109,8 @@ 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
|
||||
# connects as `healthlog_app_<database>_<hash>`, this database's own role,
|
||||
# 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
|
||||
|
||||
+14
-11
@@ -143,18 +143,21 @@ it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
|
||||
### The app's database role
|
||||
|
||||
The steps above run as the owner `DATABASE_URL` names: the dump, the
|
||||
migrations and, after them, the setup of `healthlog_app`, the role the app
|
||||
then connects as ([database-role.md](../self-hosting/database-role.md)). The
|
||||
app never runs a migration. A migration that creates a table needs nothing
|
||||
more: the setup grants the role every table it lacks before the app starts.
|
||||
migrations and, after them, the job queue's own install or migration and the
|
||||
setup of `healthlog_app_<database>_<hash>`, the role the app then connects as
|
||||
([database-role.md](../self-hosting/database-role.md)). Neither the app nor
|
||||
its queue ever runs a migration. A migration that creates a table needs
|
||||
nothing more: the setup grants the role every table it lacks before the app
|
||||
starts.
|
||||
|
||||
On the first start of the release that brings the role, the setup hands the
|
||||
existing `pgboss` tables to it, which takes each table's lock for a moment.
|
||||
Stop the old container (and a separate worker) before the new one starts, as
|
||||
for any update with migrations; a lock that does not come within 15 seconds
|
||||
leaves that start on the owner with a notice, and the next start tries again.
|
||||
A web container and a worker container that start together wait for each
|
||||
other on an advisory lock and derive the same password.
|
||||
The queue schema `pgboss` stays the owner's. When an update brings a newer
|
||||
pg-boss with a schema migration, the start runs it as the owner, including
|
||||
its index builds, before the app starts; on a large queue that start takes as
|
||||
long as the builds. Stop the old container (and a separate worker) before the
|
||||
new one starts, as for any update with migrations. A web container and a
|
||||
worker container that start together wait for each other on an advisory lock
|
||||
and derive the same password. Going back to 1.46 runs as the owner, as 1.46
|
||||
always did, and finds nothing in the queue schema it did not create.
|
||||
|
||||
### v1.46.0 notes
|
||||
|
||||
|
||||
@@ -148,11 +148,12 @@ different.
|
||||
key that does not open the data.
|
||||
|
||||
The restore runs as the owner `DATABASE_URL` names, and the restored tables
|
||||
belong to it. The app connects as `healthlog_app`
|
||||
belong to it. The app connects as its own role,
|
||||
`healthlog_app_<database>_<hash>`
|
||||
([database-role.md](../self-hosting/database-role.md)): the start in step 5
|
||||
grants that role the restored tables and hands it the queue schema before the
|
||||
app runs. A dump carries grants to `healthlog_app`; `db-restore` leaves them
|
||||
out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
|
||||
grants that role the restored tables, the queue's included, before the app
|
||||
runs; the queue schema stays the owner's. A dump carries grants to the role;
|
||||
`db-restore` leaves them out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
|
||||
the same two flags, or it fails on a database without the role.
|
||||
|
||||
When the dumps are still in the server's `backups` volume (the database
|
||||
|
||||
@@ -3,39 +3,97 @@
|
||||
The container connects to Postgres under two names. The user in
|
||||
`DATABASE_URL` (on the bundled compose file, `healthlog`, which the official
|
||||
Postgres image makes a superuser) owns the schema: the entrypoint uses it for
|
||||
the dump before an update, the migrations and the restore. The app itself,
|
||||
web and worker, connects as `healthlog_app`, a role the entrypoint sets up
|
||||
after the migrations on every start. Nothing to configure: the first start of
|
||||
a release with this change creates it.
|
||||
the dump before an update, the migrations, the job queue's own install and
|
||||
migrations, and the restore. The app itself, web and worker, connects as a
|
||||
role the entrypoint sets up for this database after the migrations on every
|
||||
start. Nothing to configure: the first start of a release with this change
|
||||
creates it.
|
||||
|
||||
The point is the blast radius of a bug. If a dependency of the app were ever
|
||||
made to run code inside the container, the database connection it found would
|
||||
be one that can read and write rows, not one that can drop tables, rewrite the
|
||||
schema or reach the server's files.
|
||||
schema, plant code for the owner to run, or reach the server's files.
|
||||
|
||||
## Its name
|
||||
|
||||
`healthlog_app_<database>_<hash>`: the database's name, cut to letters,
|
||||
digits and underscores, and eight hex digits of a hash of it. On the bundled
|
||||
compose file that is `healthlog_app_healthlog_` and the hash. The start log
|
||||
names it, and so does `pg_stat_activity`.
|
||||
|
||||
Postgres roles belong to the whole server, not to one database. One role per
|
||||
database keeps two HealthLog instances on one server (production and staging
|
||||
on a shared Postgres) apart: each has its own role and password, neither
|
||||
locks the other out, and neither role has any grant in the other database. A
|
||||
role that holds an explicit `CONNECT` grant on another database is not used
|
||||
(the start says so). Postgres lets every role connect to a database whose
|
||||
`CONNECT` is granted to `PUBLIC`, the default; the role finds nothing to read
|
||||
there. To shut the door as well, revoke it on each HealthLog database:
|
||||
`REVOKE CONNECT ON DATABASE <name> FROM PUBLIC;` (and grant it back to any
|
||||
monitoring user that relied on it).
|
||||
|
||||
## What the role may and may not do
|
||||
|
||||
| May | May not |
|
||||
| ------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| read, insert, update and delete rows in every table of the app's schema | create, alter or drop tables, indexes, types or functions there |
|
||||
| use and advance its sequences | write `_prisma_migrations` (it may read it) |
|
||||
| `VACUUM`, `ANALYZE` and `REINDEX` the `measurements` table (`MAINTAIN`) | create roles, schemas or databases |
|
||||
| own the `pgboss` schema, where the job queue keeps and changes its tables | act as a superuser: no `COPY … PROGRAM`, no `pg_read_file` |
|
||||
| create temporary tables | become the owner (`SET ROLE`) |
|
||||
| May | May not |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| read, insert, update and delete rows in every table of the app's schema | create, alter or drop tables, indexes, types or functions anywhere |
|
||||
| use and advance its sequences | write `_prisma_migrations` (it may read it) |
|
||||
| `VACUUM`, `ANALYZE` and `REINDEX` the `measurements` table (`MAINTAIN`) | create triggers or rules, own anything |
|
||||
| read and write the job queue's rows in `pgboss` | write `pgboss.bam` or the version in `pgboss.version` (it may read) |
|
||||
| create temporary tables | create roles, schemas or databases; become the owner (`SET ROLE`) |
|
||||
| | act as a superuser: no `COPY … PROGRAM`, no `pg_read_file` |
|
||||
|
||||
The queue schema is the role's because pg-boss creates and migrates its own
|
||||
tables, functions and per-queue partitions while the app runs. Nothing of the
|
||||
app's data lives there. The start also takes `CREATE` on the app's schema
|
||||
away from `PUBLIC`, which Postgres 15 and newer already do for a new database
|
||||
but a database first created on an older major still allows.
|
||||
The start also takes `CREATE` on the app's schema away from `PUBLIC`, which
|
||||
Postgres 15 and newer already do for a new database but a database first
|
||||
created on an older major still allows.
|
||||
|
||||
Its password is derived from `API_TOKEN_HMAC_KEY`, so a separate worker
|
||||
container ([scaling.md](scaling.md)) arrives at the same one without a new
|
||||
variable, and it is stored as a SCRAM hash. The owner's URL is replaced, not
|
||||
kept beside the role's, so it is not in the app's environment.
|
||||
`docker compose exec app …` still starts with the container's configured
|
||||
environment, so the operator scripts (`reset-password.mjs` and the others)
|
||||
run as the owner as before.
|
||||
## The job queue's schema
|
||||
|
||||
`pgboss` belongs to the owner. pg-boss changes its schema when it installs,
|
||||
when it migrates (and in the index builds a migration leaves in
|
||||
`pgboss.bam`), and for queues created with `partition: true`, which HealthLog
|
||||
does not use. So the start installs and migrates the queue schema as the
|
||||
owner, with pg-boss's own plans, before the app runs, and the worker starts
|
||||
pg-boss with `migrate: false`: it only reads and writes rows, and refuses to
|
||||
run on a schema that is not at its version rather than change it. A new queue
|
||||
is a row.
|
||||
|
||||
The role may not write the two tables whose contents decide what runs as the
|
||||
owner: `pgboss.bam` holds SQL that pg-boss's migration runner executes, and
|
||||
the version in `pgboss.version` decides which migrations run. Owning nothing
|
||||
in the schema, the role cannot plant a trigger, a rule or a function body
|
||||
that a later session as the owner would run with the owner's rights: a
|
||||
rollback to 1.46 (which runs as the owner throughout), `DB_APP_ROLE=off`, a
|
||||
container that fell back to the owner, or the old container during a deploy.
|
||||
For the same reason a container that falls back to the owner and one that
|
||||
runs as the role can share the schema: the owner creates every object in it
|
||||
either way, and the next start grants the role what is new.
|
||||
|
||||
Every start, on every path (the role, `DB_APP_ROLE=off`, a fallback), also
|
||||
takes back anything in `pgboss` that a HealthLog-made role owns and drops any
|
||||
trigger or rule there (pg-boss defines none). A pre-release build handed the
|
||||
schema to a server-wide `healthlog_app` role; when the start finds that, it
|
||||
also compares every queue function with pg-boss's own definition and puts
|
||||
back the ones that differ, retires that role's grants in this database, and
|
||||
drops it once no database refers to it. The log names each.
|
||||
|
||||
pg-boss rebuilds bloated queue indexes only on tables it owns; under the role
|
||||
it reports them. The start rebuilds them as the owner, one at a time with a
|
||||
five-second lock wait.
|
||||
|
||||
## The password
|
||||
|
||||
Derived from `API_TOKEN_HMAC_KEY` and the database's name, so a separate
|
||||
worker container ([scaling.md](scaling.md)) arrives at the same one without a
|
||||
new variable, and stored as a SCRAM hash. Rotating `API_TOKEN_HMAC_KEY` with a
|
||||
web/worker split means restarting all containers together: the first one to
|
||||
start sets the new password, and a container still on the old key cannot
|
||||
open new connections until it restarts with the new one.
|
||||
|
||||
The owner's URL is replaced, not kept beside the role's, so it is not in the
|
||||
app's environment. `docker compose exec app …` still starts with the
|
||||
container's configured environment, so the operator scripts
|
||||
(`reset-password.mjs` and the others) run as the owner as before.
|
||||
|
||||
Each start re-applies the grants, so a table the new migrations created is
|
||||
covered before the app starts, and a restored database is put right on the
|
||||
@@ -46,14 +104,14 @@ start after the restore.
|
||||
When it is in place:
|
||||
|
||||
```text
|
||||
HealthLog: the app connects as healthlog_app: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)
|
||||
HealthLog: the app connects as healthlog_app_healthlog_1a2b3c4d: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)
|
||||
```
|
||||
|
||||
When it is not, one notice names the reason, and the app runs as the
|
||||
`DATABASE_URL` user, exactly as every earlier release did:
|
||||
|
||||
```text
|
||||
HealthLog: notice: the app connects as healthlog, the user DATABASE_URL names, not as the restricted role healthlog_app: <reason>. …
|
||||
HealthLog: notice: the app connects as healthlog, the user DATABASE_URL names, not as a restricted role: <reason>. …
|
||||
```
|
||||
|
||||
The reasons:
|
||||
@@ -68,12 +126,14 @@ The reasons:
|
||||
[postgres-major-upgrade.md](../ops/postgres-major-upgrade.md) for one you
|
||||
run yourself.
|
||||
- **The role cannot log in.** A `pg_hba.conf` that lists users by name needs a
|
||||
line for `healthlog_app` beside the one for the owner.
|
||||
- **A role named `healthlog_app` exists that HealthLog did not create.** The
|
||||
start leaves a role it does not recognise alone.
|
||||
- **Setting it up failed**, most often a lock it waited 15 seconds for: the
|
||||
first start hands the existing queue tables to the role, and another
|
||||
container still using them holds it up. The next start tries again.
|
||||
line for the role beside the one for the owner.
|
||||
- **The role holds `CONNECT` on another database.** Revoke it there.
|
||||
- **A role of that name exists that HealthLog did not create** for this
|
||||
database. The start leaves a role it does not recognise alone.
|
||||
- **The queue schema is newer than this image's pg-boss**, after going back
|
||||
to an older image: only the owner runs on it.
|
||||
- **Setting it up failed**, most often a lock it waited 15 seconds for while
|
||||
another container held it. The next start tries again.
|
||||
|
||||
None of these stops the start.
|
||||
|
||||
@@ -83,17 +143,22 @@ Set `DB_APP_ROLE=off` in `.env` and recreate the container. The app then
|
||||
connects as the `DATABASE_URL` user. Use it when you manage database roles
|
||||
yourself, including when `DATABASE_URL` already names a restricted role of
|
||||
your own; then that role needs what the table above lists, and the
|
||||
migrations need a user that owns the schema.
|
||||
migrations and the queue's install need a user that owns the schema.
|
||||
|
||||
The role stays in the database when you turn it off and does no harm. To
|
||||
remove it, as the owner:
|
||||
The role stays in the database when you turn it off and owns nothing. To
|
||||
remove it, as the owner, with the name from the start log:
|
||||
|
||||
```sql
|
||||
REASSIGN OWNED BY healthlog_app TO healthlog;
|
||||
DROP OWNED BY healthlog_app;
|
||||
DROP ROLE healthlog_app;
|
||||
DROP OWNED BY healthlog_app_healthlog_1a2b3c4d;
|
||||
DROP ROLE healthlog_app_healthlog_1a2b3c4d;
|
||||
```
|
||||
|
||||
## Going back to 1.46
|
||||
|
||||
1.46 knows nothing of the role and runs as the owner, as it always did. The
|
||||
queue schema is the owner's, so it finds nothing it did not create. The role
|
||||
stays, unused, until you drop it as above.
|
||||
|
||||
## Index drops at start
|
||||
|
||||
`DROP INDEX` belongs to a table's owner; `MAINTAIN` does not include it. The
|
||||
@@ -104,9 +169,8 @@ start drops them as the owner, waiting at most five seconds for each
|
||||
|
||||
## Dumps and restores
|
||||
|
||||
The nightly dump runs as the role and reads everything: the app's schema
|
||||
through its grants, the queue schema as its owner. A dump carries the grants
|
||||
to `healthlog_app`; `db-restore` leaves them and the ownership out
|
||||
The nightly dump runs as the role and reads everything through its grants. A
|
||||
dump carries those grants; `db-restore` leaves them and the ownership out
|
||||
(`--no-owner --no-privileges`), restores as the owner, and the next start
|
||||
grants again. A restore of your own with `pg_restore` should do the same, or
|
||||
it fails on a database without the role.
|
||||
@@ -131,12 +131,13 @@ and restart the worker together with the web containers.
|
||||
|
||||
## Database role
|
||||
|
||||
Both containers run the entrypoint, and both set up `healthlog_app`, the
|
||||
database role the app connects as
|
||||
([database-role.md](database-role.md)). They take turns on an advisory lock
|
||||
and derive the same password from `API_TOKEN_HMAC_KEY`, which the shared
|
||||
environment block gives both, so a worker that starts after the web container
|
||||
changes nothing the web container relies on.
|
||||
Both containers run the entrypoint, and both set up the database role the
|
||||
app connects as ([database-role.md](database-role.md)). They take turns on an
|
||||
advisory lock and derive the same password from `API_TOKEN_HMAC_KEY` and the
|
||||
database's name, which the shared environment block gives both, so a worker
|
||||
that starts after the web container changes nothing the web container relies
|
||||
on. Rotating `API_TOKEN_HMAC_KEY` changes that password: restart all
|
||||
containers together.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
|
||||
+614
-202
File diff suppressed because it is too large.
Load diff
@@ -228,7 +228,7 @@
|
||||
},
|
||||
{
|
||||
"name": "DB_APP_ROLE",
|
||||
"purpose": "auto (default) or off. Auto: after the migrations the entrypoint creates the role healthlog_app, which may read and write rows and run the queue but not change the schema, and starts the app with it in place of the DATABASE_URL user. Off keeps the app on the DATABASE_URL user, for an operator who manages database roles.",
|
||||
"purpose": "auto (default) or off. Auto: after the migrations the entrypoint creates this database's role healthlog_app_<database>_<hash>, which may read and write rows and run the queue but not change the schema, and starts the app with it in place of the DATABASE_URL user. Off keeps the app on the DATABASE_URL user, for an operator who manages database roles.",
|
||||
"example": "off"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -89,20 +89,25 @@ function databaseOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker's pg-boss. `createSchema` says whether pg-boss's first install
|
||||
* may run `CREATE SCHEMA IF NOT EXISTS pgboss`. Postgres checks the database's
|
||||
* CREATE privilege before it checks whether the schema exists, so under the
|
||||
* app's restricted role (`scripts/db-app-role.mjs`), which may not create
|
||||
* schemas, that statement fails even though the boot already created the
|
||||
* schema for it. The caller passes false when the schema is there.
|
||||
* The worker's pg-boss. `ownsQueueSchema` is false when the connection does
|
||||
* not own the `pgboss` schema, which is how the app's restricted role
|
||||
* (`scripts/db-app-role.mjs`) runs: the boot installed and migrated the
|
||||
* schema as the owner, so pg-boss neither installs, migrates nor runs the
|
||||
* index builds a migration queues (`migrate: false`), and never sends
|
||||
* `CREATE SCHEMA IF NOT EXISTS` (`createSchema: false`), which Postgres
|
||||
* refuses without the database's CREATE privilege even when the schema is
|
||||
* there. With `migrate: false`, pg-boss refuses to start on a schema that is
|
||||
* not at its version, rather than run on it.
|
||||
*/
|
||||
export function createWorkerBoss(
|
||||
connectionString: string,
|
||||
options: { createSchema?: boolean } = {},
|
||||
options: { ownsQueueSchema?: boolean } = {},
|
||||
): PgBoss {
|
||||
return new PgBoss({
|
||||
...databaseOptions(connectionString, "worker"),
|
||||
...(options.createSchema === false ? { createSchema: false } : {}),
|
||||
...(options.ownsQueueSchema === false
|
||||
? { createSchema: false, migrate: false }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,17 @@ export async function startReminderWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The queue schema exists once a worker has started, and on every boot
|
||||
// the restricted database role runs under (the entrypoint creates it for
|
||||
// that role); only a fresh database without either needs pg-boss to
|
||||
// create it.
|
||||
const [{ present }] = await prisma.$queryRaw<{ present: boolean }[]>`
|
||||
SELECT to_regnamespace('pgboss') IS NOT NULL AS present`;
|
||||
const boss = createWorkerBoss(DATABASE_URL, { createSchema: !present });
|
||||
// Under the restricted database role the boot installed and migrated the
|
||||
// queue schema as its owner, and the role owns nothing there; pg-boss then
|
||||
// only reads and writes rows. A connection that owns the schema, or a fresh
|
||||
// database without one, lets pg-boss install and migrate it itself.
|
||||
const [{ owned }] = await prisma.$queryRaw<{ owned: boolean | null }[]>`
|
||||
SELECT pg_has_role(current_user, n.nspowner, 'USAGE') AS owned
|
||||
FROM (SELECT 1) one
|
||||
LEFT JOIN pg_namespace n ON n.nspname = 'pgboss'`;
|
||||
const boss = createWorkerBoss(DATABASE_URL, {
|
||||
ownsQueueSchema: owned !== false,
|
||||
});
|
||||
|
||||
boss.on("error", (error: unknown) => {
|
||||
workerLog("error", "boss emitted error", error);
|
||||
|
||||
@@ -2,44 +2,68 @@
|
||||
* 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.
|
||||
* for this database's own role (`healthlog_app_<database>_<hash>`). Proven
|
||||
* here:
|
||||
*
|
||||
* - The role does what the app does: Prisma reads and writes, the queue
|
||||
* lifecycle the worker runs (start with `migrate: false`, create a new
|
||||
* queue, send, fetch, complete, schedule, delete a queue, a supervise
|
||||
* pass), the measurement maintenance's VACUUM and concurrent rebuild.
|
||||
* - It is refused what it must not do: DDL in either schema, triggers,
|
||||
* functions, roles, schemas, server files and programs, the migration
|
||||
* history, and the two queue tables whose contents pg-boss executes or
|
||||
* migrates by.
|
||||
* - It owns nothing in the queue schema, which the boot installs as the
|
||||
* owner, and the boot takes back, from a pre-release state, a schema a
|
||||
* HealthLog role owned, with a planted trigger and a replaced function.
|
||||
* - Two databases on one server get two roles that do not lock each other
|
||||
* out and cannot read each other's data; a role that holds CONNECT on
|
||||
* another database is refused.
|
||||
* - A second run is a no-op that keeps the password; an owner that may not
|
||||
* create roles, and DB_APP_ROLE=off, leave the app on the owner.
|
||||
*
|
||||
* 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 { createWorkerBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
MAINTENANCE_SESSION_OPTIONS,
|
||||
RETIRED_MEASUREMENT_INDEXES,
|
||||
runMeasurementMaintenance,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
import {
|
||||
APP_ROLE,
|
||||
LEGACY_APP_ROLE,
|
||||
MANAGED_MARK,
|
||||
appRoleName,
|
||||
appRolePassword,
|
||||
ensureAppRole,
|
||||
restoreQueueFunctions,
|
||||
} from "../../scripts/db-app-role.mjs";
|
||||
import { TEMPLATE_DATABASE } from "./worker-database";
|
||||
|
||||
const HMAC_KEY = "a".repeat(64);
|
||||
const OTHER_HMAC_KEY = "b".repeat(64);
|
||||
const PROBE_OWNER = "app_role_probe_owner";
|
||||
const USER_ID = "user-app-role-probe";
|
||||
const RETIRED = RETIRED_MEASUREMENT_INDEXES[0];
|
||||
const OTHER_DB = `app_role_probe_other_${process.pid}`;
|
||||
|
||||
function ownerUrl(): string {
|
||||
function ownerUrl(database?: string): string {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is not set for the integration run");
|
||||
return url;
|
||||
if (!database) return url;
|
||||
const u = new URL(url);
|
||||
u.pathname = `/${database}`;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function databaseOf(url: string): string {
|
||||
return decodeURIComponent(new URL(url).pathname.slice(1));
|
||||
}
|
||||
|
||||
function withoutSchema(url: string): string {
|
||||
@@ -83,23 +107,46 @@ async function sqlState(db: Client, sql: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Objects in the queue schema not owned by `owner`. */
|
||||
async function queueObjectsNotOwnedBy(
|
||||
db: Client,
|
||||
owner: string,
|
||||
): Promise<number> {
|
||||
const { rows } = await db.query<{ n: string }>(
|
||||
`SELECT (
|
||||
(SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'pgboss' AND pg_get_userbyid(c.relowner) <> $1)
|
||||
+ (SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'pgboss' AND pg_get_userbyid(p.proowner) <> $1)
|
||||
+ (SELECT count(*) FROM pg_namespace WHERE nspname = 'pgboss'
|
||||
AND pg_get_userbyid(nspowner) <> $1))::text AS n`,
|
||||
[owner],
|
||||
);
|
||||
return Number(rows[0].n);
|
||||
}
|
||||
|
||||
let owner: Client;
|
||||
let ownerName = "";
|
||||
let serverVersion = 0;
|
||||
const roleA = () => appRoleName(databaseOf(ownerUrl()));
|
||||
const roleB = appRoleName(OTHER_DB);
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await connect(ownerUrl());
|
||||
const { rows } = await owner.query<{ v: number }>(
|
||||
`SELECT current_setting('server_version_num')::int AS v`,
|
||||
const { rows } = await owner.query<{ v: number; me: string }>(
|
||||
`SELECT current_setting('server_version_num')::int AS v, current_user AS me`,
|
||||
);
|
||||
serverVersion = rows[0].v;
|
||||
ownerName = rows[0].me;
|
||||
// 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]) {
|
||||
await owner.query(`DROP DATABASE IF EXISTS "${OTHER_DB}" WITH (FORCE)`);
|
||||
// Roles are cluster-wide: hand back what they hold here and drop them, so
|
||||
// no other file or run sees them.
|
||||
for (const role of [roleA(), roleB, LEGACY_APP_ROLE, PROBE_OWNER]) {
|
||||
const { rows } = await owner.query(
|
||||
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
|
||||
[role],
|
||||
@@ -114,8 +161,24 @@ afterAll(async () => {
|
||||
await owner.end();
|
||||
});
|
||||
|
||||
describe("the role's name", () => {
|
||||
it("is per database, under 63 characters, and stable", () => {
|
||||
expect(appRoleName("healthlog")).toMatch(
|
||||
/^healthlog_app_healthlog_[0-9a-f]{8}$/,
|
||||
);
|
||||
expect(appRoleName("healthlog")).toBe(appRoleName("healthlog"));
|
||||
expect(appRoleName("healthlog")).not.toBe(appRoleName("healthlog-staging"));
|
||||
// Names that clean up alike still differ by their hash.
|
||||
expect(appRoleName("a-b")).not.toBe(appRoleName("a_b"));
|
||||
expect(appRoleName("x".repeat(63)).length).toBeLessThanOrEqual(63);
|
||||
expect(appRolePassword(HMAC_KEY, "a")).not.toBe(
|
||||
appRolePassword(HMAC_KEY, "b"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DB_APP_ROLE=off", () => {
|
||||
it("stays on the owner without touching the database", async () => {
|
||||
it("stays on the owner and creates no role", async () => {
|
||||
const { url, said } = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
@@ -125,7 +188,7 @@ describe("DB_APP_ROLE=off", () => {
|
||||
expect(said).toContain("DB_APP_ROLE=off");
|
||||
const { rows } = await owner.query(
|
||||
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
|
||||
[APP_ROLE],
|
||||
[roleA()],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
@@ -149,7 +212,9 @@ describe("an owner that may not create roles", () => {
|
||||
expect(said).toContain("may not create roles");
|
||||
}
|
||||
expect(said).not.toContain("probe-owner-pw");
|
||||
expect(said).not.toContain(appRolePassword(HMAC_KEY));
|
||||
expect(said).not.toContain(
|
||||
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,8 +248,11 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
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));
|
||||
expect(first.said).toContain(`the app connects as ${roleA()}`);
|
||||
expect(first.said).toContain("queue schema installed as the owner");
|
||||
expect(first.said).not.toContain(
|
||||
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
|
||||
);
|
||||
// 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(
|
||||
@@ -198,12 +266,14 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
await app?.end();
|
||||
});
|
||||
|
||||
it("keeps the owner's host, database and parameters", async (ctx) => {
|
||||
it("keeps the owner's host, database and parameters", (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.username).toBe(roleA());
|
||||
expect(handed.password).toBe(
|
||||
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
|
||||
);
|
||||
expect(handed.host).toBe(owned.host);
|
||||
expect(handed.pathname).toBe(owned.pathname);
|
||||
expect(handed.search).toBe(owned.search);
|
||||
@@ -248,33 +318,35 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs pg-boss: install, a queue partition, a job", async (ctx) => {
|
||||
it("runs the worker's queue lifecycle owning nothing in the queue schema", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const boss = new PgBoss({
|
||||
connectionString: withoutSchema(appUrl),
|
||||
createSchema: false,
|
||||
supervise: false,
|
||||
schedule: false,
|
||||
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
|
||||
const errors: unknown[] = [];
|
||||
// The options the worker passes when it does not own the schema.
|
||||
const boss = createWorkerBoss(withoutSchema(appUrl), {
|
||||
ownsQueueSchema: false,
|
||||
});
|
||||
boss.on("error", () => {});
|
||||
boss.on("error", (err: unknown) => errors.push(err));
|
||||
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");
|
||||
// A queue no earlier boot created: a row, no table.
|
||||
const queue = `app-role-probe-${Date.now()}`;
|
||||
await boss.createQueue(queue, { policy: "standard" });
|
||||
await boss.createQueue(`${queue}-singleton`, { policy: "singleton" });
|
||||
const id = await boss.send(queue, { n: 1 });
|
||||
const [job] = await boss.fetch(queue);
|
||||
expect(job?.id).toBe(id);
|
||||
await boss.complete("app-role-probe", job.id);
|
||||
await boss.complete(queue, job.id);
|
||||
await boss.schedule(queue, "0 3 * * *", {}, { tz: "Europe/Berlin" });
|
||||
await boss.unschedule(queue);
|
||||
await boss.supervise();
|
||||
await boss.deleteQueue(`${queue}-singleton`);
|
||||
} 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");
|
||||
expect(errors).toEqual([]);
|
||||
// Still nothing of the role's in the schema.
|
||||
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
|
||||
});
|
||||
|
||||
it("vacuums and rebuilds measurements, and leaves the drop to the owner", async (ctx) => {
|
||||
@@ -302,7 +374,7 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("is refused schema changes, roles, schemas, server files and the migration history", async (ctx) => {
|
||||
it("is refused schema changes, triggers, functions, roles, server files and the queue's executed tables", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const refused = {
|
||||
dropTable: await sqlState(app, `DROP TABLE "users"`),
|
||||
@@ -324,9 +396,32 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
app,
|
||||
`DELETE FROM _prisma_migrations WHERE false`,
|
||||
),
|
||||
becomeOwner: await sqlState(
|
||||
becomeOwner: await sqlState(app, `SET ROLE "${ownerName}"`),
|
||||
queueTrigger: await sqlState(
|
||||
app,
|
||||
`SET ROLE ${new URL(ownerUrl()).username}`,
|
||||
`CREATE TRIGGER probe BEFORE INSERT ON pgboss.job_common
|
||||
FOR EACH ROW EXECUTE FUNCTION pgboss.job_now()`,
|
||||
),
|
||||
queueFunction: await sqlState(
|
||||
app,
|
||||
`CREATE FUNCTION pgboss.probe() RETURNS int LANGUAGE sql AS 'SELECT 1'`,
|
||||
),
|
||||
replaceQueueFunction: await sqlState(
|
||||
app,
|
||||
`CREATE OR REPLACE FUNCTION pgboss.job_now() RETURNS timestamptz LANGUAGE sql AS 'SELECT now()'`,
|
||||
),
|
||||
queueTable: await sqlState(app, `CREATE TABLE pgboss.probe (x int)`),
|
||||
alterQueueTable: await sqlState(
|
||||
app,
|
||||
`ALTER TABLE pgboss.queue ADD COLUMN probe text`,
|
||||
),
|
||||
writeBam: await sqlState(
|
||||
app,
|
||||
`INSERT INTO pgboss.bam (name, version, status, command) VALUES ('probe', 1, 'pending', 'SELECT 1')`,
|
||||
),
|
||||
writeQueueVersion: await sqlState(
|
||||
app,
|
||||
`UPDATE pgboss.version SET version = version`,
|
||||
),
|
||||
};
|
||||
expect(refused).toEqual({
|
||||
@@ -341,7 +436,18 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
dropIndex: "42501",
|
||||
writeMigrations: "42501",
|
||||
becomeOwner: "42501",
|
||||
queueTrigger: "42501",
|
||||
queueFunction: "42501",
|
||||
replaceQueueFunction: "42501",
|
||||
queueTable: "42501",
|
||||
alterQueueTable: "42501",
|
||||
writeBam: "42501",
|
||||
writeQueueVersion: "42501",
|
||||
});
|
||||
// pg-boss's own maintenance stamps stay writable.
|
||||
expect(
|
||||
await sqlState(app, `UPDATE pgboss.version SET cron_on = cron_on`),
|
||||
).toBeNull();
|
||||
const { rows } = await app.query(
|
||||
`SELECT rolsuper, rolcreaterole, rolcreatedb, rolinherit, rolreplication, rolbypassrls
|
||||
FROM pg_roles WHERE rolname = current_user`,
|
||||
@@ -356,10 +462,165 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent: a second boot keeps the password and drops what the role could not", async (ctx) => {
|
||||
it("does not let the worker migrate the queue schema as the role", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const { rows } = await owner.query<{ v: number }>(
|
||||
`SELECT max(version)::int AS v FROM pgboss.version`,
|
||||
);
|
||||
await owner.query(`UPDATE pgboss.version SET version = $1`, [
|
||||
rows[0].v - 1,
|
||||
]);
|
||||
try {
|
||||
const boss = createWorkerBoss(withoutSchema(appUrl), {
|
||||
ownsQueueSchema: false,
|
||||
});
|
||||
boss.on("error", () => {});
|
||||
await expect(boss.start()).rejects.toThrow(/requires migrations/);
|
||||
await boss.stop({ graceful: false }).catch(() => {});
|
||||
} finally {
|
||||
await owner.query(`UPDATE pgboss.version SET version = $1`, [rows[0].v]);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves pg-boss's own functions alone on an untouched schema", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
expect(await restoreQueueFunctions(owner)).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives a second database on the server its own role, without locking the first out", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
await owner.query(
|
||||
`SELECT pg_advisory_lock(hashtext('healthlog-integration-clone'))`,
|
||||
);
|
||||
try {
|
||||
await owner.query(
|
||||
`CREATE DATABASE "${OTHER_DB}" TEMPLATE "${TEMPLATE_DATABASE}"`,
|
||||
);
|
||||
} finally {
|
||||
await owner.query(
|
||||
`SELECT pg_advisory_unlock(hashtext('healthlog-integration-clone'))`,
|
||||
);
|
||||
}
|
||||
const other = await boot({
|
||||
DATABASE_URL: ownerUrl(OTHER_DB),
|
||||
API_TOKEN_HMAC_KEY: OTHER_HMAC_KEY,
|
||||
});
|
||||
expect(other.url, other.said).not.toBeNull();
|
||||
expect(new URL(other.url!).username).toBe(roleB);
|
||||
expect(roleB).not.toBe(roleA());
|
||||
|
||||
// Both log in to their own database, the first after the second's boot.
|
||||
for (const [url, role] of [
|
||||
[appUrl, roleA()],
|
||||
[other.url!, roleB],
|
||||
] as const) {
|
||||
const db = await connect(url);
|
||||
const { rows } = await db.query(`SELECT current_user AS me`);
|
||||
expect(rows[0].me).toBe(role);
|
||||
await db.query(`SELECT count(*) FROM users`);
|
||||
await db.end();
|
||||
}
|
||||
|
||||
// Each is refused the other's data and queue.
|
||||
for (const [url, database] of [
|
||||
[appUrl, OTHER_DB],
|
||||
[other.url!, databaseOf(ownerUrl())],
|
||||
] as const) {
|
||||
const u = new URL(url);
|
||||
u.pathname = `/${database}`;
|
||||
const db = await connect(u.toString());
|
||||
expect(await sqlState(db, `SELECT count(*) FROM users`)).toBe("42501");
|
||||
expect(await sqlState(db, `SELECT count(*) FROM pgboss.job`)).toBe(
|
||||
"42501",
|
||||
);
|
||||
expect(await sqlState(db, `CREATE TABLE public.probe (x int)`)).toBe(
|
||||
"42501",
|
||||
);
|
||||
await db.end();
|
||||
}
|
||||
|
||||
// A role that holds CONNECT on another database is not used.
|
||||
await owner.query(
|
||||
`GRANT CONNECT ON DATABASE "${OTHER_DB}" TO "${roleA()}"`,
|
||||
);
|
||||
try {
|
||||
const refused = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(refused.url).toBeNull();
|
||||
expect(refused.said).toContain(
|
||||
`holds CONNECT on another database (${OTHER_DB})`,
|
||||
);
|
||||
} finally {
|
||||
await owner.query(
|
||||
`REVOKE CONNECT ON DATABASE "${OTHER_DB}" FROM "${roleA()}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes the queue schema back from a pre-release role, with what it planted", async (ctx) => {
|
||||
if (serverVersion < 170000) ctx.skip();
|
||||
const { rows: before } = await owner.query<{ src: string }>(
|
||||
`SELECT prosrc AS src FROM pg_proc WHERE oid = 'pgboss.job_now()'::regprocedure`,
|
||||
);
|
||||
// The state a pre-release build left: the schema and everything in it
|
||||
// owned by the cluster-wide role, which could then plant code.
|
||||
await owner.query(
|
||||
`CREATE ROLE "${LEGACY_APP_ROLE}" NOLOGIN;
|
||||
COMMENT ON ROLE "${LEGACY_APP_ROLE}" IS '${MANAGED_MARK}: the app''s least-privilege login (scripts/db-app-role.mjs). Recreated and re-granted on every start.';
|
||||
GRANT SELECT ON users TO "${LEGACY_APP_ROLE}";
|
||||
ALTER SCHEMA pgboss OWNER TO "${LEGACY_APP_ROLE}";
|
||||
ALTER TABLE pgboss.job_common OWNER TO "${LEGACY_APP_ROLE}";
|
||||
ALTER FUNCTION pgboss.job_now() OWNER TO "${LEGACY_APP_ROLE}";
|
||||
CREATE OR REPLACE FUNCTION pgboss.job_now() RETURNS timestamptz
|
||||
LANGUAGE sql AS 'SELECT now() - interval ''1 day''';
|
||||
CREATE FUNCTION pgboss.planted() RETURNS trigger LANGUAGE plpgsql
|
||||
AS 'BEGIN RETURN NEW; END';
|
||||
ALTER FUNCTION pgboss.planted() OWNER TO "${LEGACY_APP_ROLE}";
|
||||
CREATE TRIGGER planted BEFORE INSERT ON pgboss.job_common
|
||||
FOR EACH ROW EXECUTE FUNCTION pgboss.planted();
|
||||
CREATE RULE planted AS ON DELETE TO pgboss.queue DO INSTEAD NOTHING;`,
|
||||
);
|
||||
|
||||
const next = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
API_TOKEN_HMAC_KEY: HMAC_KEY,
|
||||
});
|
||||
expect(next.url, next.said).toBe(appUrl);
|
||||
expect(next.said).toContain("took back");
|
||||
expect(next.said).toContain("trigger planted");
|
||||
expect(next.said).toContain("rule planted");
|
||||
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
|
||||
const { rows: after } = await owner.query<{ src: string }>(
|
||||
`SELECT prosrc AS src FROM pg_proc WHERE oid = 'pgboss.job_now()'::regprocedure`,
|
||||
);
|
||||
expect(after[0].src).toBe(before[0].src);
|
||||
const { rows: leftovers } = await owner.query(
|
||||
`SELECT to_regprocedure('pgboss.planted()') AS planted,
|
||||
(SELECT count(*)::int FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'pgboss' AND NOT t.tgisinternal) AS triggers,
|
||||
(SELECT count(*)::int FROM pg_roles WHERE rolname = $1) AS legacy`,
|
||||
[LEGACY_APP_ROLE],
|
||||
);
|
||||
expect(leftovers[0]).toEqual({ planted: null, triggers: 0, legacy: 0 });
|
||||
// Taken back, not dropped: the queue's tables are all still there.
|
||||
const { rows: tables } = await owner.query(
|
||||
`SELECT to_regclass('pgboss.job_common') IS NOT NULL AS common,
|
||||
(SELECT count(*)::int FROM pgboss.queue) > 0 AS queues`,
|
||||
);
|
||||
expect(tables[0]).toEqual({ common: true, queues: true });
|
||||
});
|
||||
|
||||
it("is idempotent: a later 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)`);
|
||||
// The boot before this one dropped it; put it back once more.
|
||||
await owner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
|
||||
);
|
||||
try {
|
||||
const second = await boot({
|
||||
DATABASE_URL: ownerUrl(),
|
||||
@@ -375,6 +636,7 @@ describe("the restricted role (Postgres 17+)", () => {
|
||||
});
|
||||
expect(third.url).toBe(appUrl);
|
||||
expect(third.said).not.toContain("dropped index");
|
||||
expect(third.said).not.toContain("took back");
|
||||
const { rows } = await owner.query(
|
||||
`SELECT to_regclass($1) IS NULL AS gone`,
|
||||
[RETIRED],
|
||||
|
||||
Reference in new issue
Block a user