Skip to content

Fix permissions ordering in _iter_sql_files() itself, not a caller-side replay - #32

Merged
aersam merged 2 commits into
mainfrom
worktree-permissions-second-pass
Sep 8, 2026
Merged

Fix permissions ordering in _iter_sql_files() itself, not a caller-side replay#32
aersam merged 2 commits into
mainfrom
worktree-permissions-second-pass

Conversation

@aersam

@aersam aersam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

No description provided.

aersam and others added 2 commits September 8, 2026 14:27
apply_schema()'s _iter_sql_files() sorts files by (type_order, filename)
and does a first-pass-then-delayed-retry resolution for anything with an
unresolved SQL dependency at first attempt. A `permissions`-type file
(type_order=100, e.g. `grant select, insert, update, delete on all
tables in schema x to role;`) has no SQL dependencies of its own -- GRANT
statements aren't parsed as depending on the tables they name -- so it
always applies in the very first pass, at its sorted position. But a
table with an FK to another table in a different layer directory whose
filename happens to sort later fails its own first-pass attempt and
only actually gets created via the delayed-retry loop, which runs after
every non-delayed file -- permissions included. A blanket "grant ... on
all tables in schema X" is a one-time snapshot: it silently never
covers a table created after it ran.

Confirmed this is real, not theoretical: in a downstream project's
~200-file database/ tree, a live _iter_sql_files() dry run found 25
separate table/view files landing after its permissions file in actual
apply order, each a genuine missing-grant bug that only surfaced once
something connected as a restricted role instead of an admin/superuser
connection.

Fix: re-execute every permissions-type file (detected generically via
_get_type_order(file) == _TYPE_ORDER["permissions"], not hardcoded
filenames -- works for env-tagged variants like
permissions.local_test.sql too) a second time, after the whole apply
(delayed retries included) has fully converged. GRANT is idempotent, so
this is a no-op for anything the first pass already covered, and
correctly grants anything created only via delayed-retry. Applied to
both the Postgres path (apply_schema()) and the MSSQL path
(mssql/api.py's _apply(), which has its own separate apply loop with no
outer retry of its own) for consistency. Explicitly not extended to
`indexes` (type_order=101): CREATE INDEX ... ON tablename already has a
real, correctly-tracked per-object dependency via the existing scanner,
unlike a schema-wide GRANT.

Deliberately not implemented as ALTER DEFAULT PRIVILEGES -- that
approach is scoped to "whatever role ran the ALTER DEFAULT PRIVILEGES
statement" and silently breaks once a different identity later creates
objects in the same schema (confirmed by a downstream project's own
real incident history). Re-running the actual GRANT statements has no
such assumption.

New regression test (test_apply_schema_reapplies_permissions_for_a_delayed_table)
with a fixture pair (app.event's filename sorts before its own FK
target app.event_kind's filename, forcing the exact delayed-retry
scenario) and a permissions/grants.sql blanket grant -- verified this
fails with psycopg.errors.InsufficientPrivilege without the fix and
passes with it, connecting as the granted role and querying the
delayed table directly (not just asserting apply_schema() doesn't
raise). Full testdb suite (78 tests, temporarily running with
@requires_podman's Docker-API check bypassed against this sandbox's
already-reachable local Postgres) passes; the one pre-existing failure
(test_container.py, which genuinely needs Docker/Podman itself) is
unrelated and expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aqh5NvxzHH2ysU4ziSuwov
…de replay

Code review of the previous commit (collect permissions-type files during
the main apply loop, then re-execute them a second time after everything
converges) found the "second pass" approach had three real problems:

1. The replay in both apply_schema() (schema.py) and MSSQL's _apply()
   called _apply() directly with no try/except, unlike the first pass's
   protected failures/retry loop -- a transient error on replay (lock
   timeout, dropped connection) would now abort the whole apply even
   though every object and the first-pass GRANT already succeeded, a
   regression versus applying permissions files exactly once.
2. The fix was duplicated near-verbatim at two call sites instead of
   living once in _iter_sql_files(), which already owns the
   delayed/delivered convergence loop -- any future caller (a new
   engine backend, another entry point) wouldn't know to replicate the
   pattern and would silently reintroduce the exact bug being fixed.
3. Every apply_schema()/ensure_testdb() call now paid a wholly redundant
   extra pass of GRANT round-trips even in the common case where nothing
   was ever actually delayed.

Fixed properly instead: _iter_sql_files() now holds every permissions-type
file back unconditionally during its main loop (regardless of the file's
own -- always empty -- dependency set) and yields them only after its
existing `while delayed:` retry loop has fully converged. Nothing ever
legitimately depends on a permissions file (it creates no table/view/
schema of its own), so holding every one back is always safe. Both
apply_schema() and MSSQL's _apply() need zero changes at all now -- they
just consume _iter_sql_files() in order, with their existing (and, for
MSSQL, existing-and-unchanged) per-file error handling automatically
covering permissions files the same as everything else. Applied exactly
once, in the correct position, for every caller, with no code
duplication and no extra cost on the common path.

Also fixes a real race the review caught in the new test fixture:
permissions/grants.sql's `CREATE ROLE pgdevkit_test_reader` used an
`IF NOT EXISTS (SELECT ...) THEN CREATE` check-then-act pattern -- roles
are cluster-wide, not per-database, so two pytest processes (e.g. two
worktrees, this repo's own normal workflow) hitting the same shared
testdb container concurrently could both pass the check before either
commits, and the second CREATE ROLE would raise duplicate_object. Now
uses `CREATE ROLE ... EXCEPTION WHEN duplicate_object THEN NULL`
instead, mirroring the same atomic-create-and-catch pattern
pgdevkit/testdb/api.py's _ensure_database() already uses for the
equivalent CREATE DATABASE race (`except psycopg.errors.DuplicateDatabase: pass`).

Renamed the regression test (test_apply_schema_reapplies_permissions_for_a_delayed_table
-> test_apply_schema_applies_permissions_after_a_delayed_table) and its
comments to describe the new mechanism. Re-verified against the true
pre-fix baseline (git show HEAD~1:pgdevkit/testdb/schema.py): fails with
psycopg.errors.InsufficientPrivilege without either fix, passes with
this one. Full testdb suite (78 tests, @requires_podman temporarily
bypassed against this sandbox's already-reachable local Postgres) passes;
the one pre-existing failure (test_container.py, needs real Docker/Podman)
is unrelated and expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aqh5NvxzHH2ysU4ziSuwov
@aersam
aersam merged commit fedda22 into main Sep 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant