From 75bede3de68c65cea7098def0eeab28ff4d0ba8c Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Mon, 28 Sep 2026 22:56:45 +0200 Subject: [PATCH] Document the missing-GRANT gap in production apply paths pgdb testdb's "permissions files apply last" guarantee doesn't transfer to a forward-only production migration runner, which applies each file exactly once, ever, as whatever role it connects as -- a recurring real-world bug class (see bmsuisse/PgMigrator#31307). Points at execute_sql_script/created_table_names/missing_privileges (added in #39) as the primitives for building a guard against it. Co-Authored-By: Claude Sonnet 5 --- README.md | 26 ++++++++++++++++++++++++++ docs/database-layout.md | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/README.md b/README.md index de56ad0..7126e54 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,32 @@ for a real one. functions the CLI calls, so a project can script around them without shelling out. +### Guarding against missing GRANTs in a production apply path + +A migration that creates a table doesn't automatically get that table granted +to your app's runtime role — see `docs/database-layout.md`'s "permissions +files always apply last" section for why a one-time `permissions`/`grants` +file running through a forward-only migration path (unlike `pgdb testdb`, +which re-derives the whole schema every time) can silently stop covering new +tables. Three functions in `pgdevkit.migrate` exist for building your own +guard around this: + +- `execute_sql_script(conninfo, sql)` — runs a raw SQL script as one committed + transaction, split the same statement-boundary-safe way `apply_migration` + is, but with **no** tracking-table bookkeeping. For a script meant to + re-run every time (e.g. an idempotent `GRANT ... ON ALL TABLES IN SCHEMA x + TO role`), call this directly instead of routing it through + `apply_migration`'s once-ever tracked-migration path. +- `created_table_names(sql)` — table names any `CREATE TABLE` in a raw script + targets, for when you ran it through `execute_sql_script` (which returns + nothing) rather than `apply_migration` (whose `ApplyResult.verified_tables` + already gives you this). +- `missing_privileges(conninfo, role, tables, privilege="select")` — checks + `has_table_privilege` for `role` against each of `tables`, returning the + ones it can't access. Run it after applying a migration (or an + `execute_sql_script` grants sync) to confirm the grant actually landed, + instead of finding out from a production 500. + ## `pgdevkit.db` — helpers for application code Install with the `db` extra: `pip install pgdevkit[db]`. diff --git a/docs/database-layout.md b/docs/database-layout.md index 8ae7ad2..472726d 100644 --- a/docs/database-layout.md +++ b/docs/database-layout.md @@ -74,6 +74,27 @@ back unconditionally and applies it only once the whole rest of the tree `permissions` files as if every table/view they reference is guaranteed to already exist, because it is. +**That guarantee is specific to `pgdb testdb` — a production apply path built +on `pgdevkit.migrate` doesn't get it for free.** `pgdb testdb`/`ensure_testdb()` +re-derive the whole schema from scratch every time and can afford to hold +`permissions` back until everything else settles. A production migration +runner is forward-only: it runs each file from `migrations`/ +`_migration_scripts` exactly once, ever, as whatever shared role the runner +itself connects as. If your `permissions`/`grants` file goes through that +path as an ordinary one-time migration, it covers whatever tables existed the +one time it ran — full stop. A table added by any later migration, by any +migrant runner or connecting role, silently never gets the grant, and the +first symptom is a production `InsufficientPrivilege` 500 on that table. +Two `pgdevkit.migrate` functions exist specifically to close that gap in your +own production-apply tooling: `execute_sql_script(conninfo, sql)` runs an +idempotent script (e.g. that same blanket `GRANT ... ON ALL TABLES IN SCHEMA`) +outside the tracked/forward-only flow, so your tooling can re-run it +unconditionally on every deploy instead of once; `missing_privileges(conninfo, +role, tables)` (paired with `created_table_names(sql)` for a raw script, or +`ApplyResult.verified_tables` from `apply_migration`) checks whether a role +actually has the access your `permissions` file was supposed to grant it, so +a gap surfaces immediately instead of as a later outage. + --- ## File-naming conventions