Skip to content

fix(replica): re-apply extra user schema grants after schema migration - #136

Open
julianam-w wants to merge 2 commits into
mainfrom
fix/extra-user-grants-after-migration
Open

fix(replica): re-apply extra user schema grants after schema migration#136
julianam-w wants to merge 2 commits into
mainfrom
fix/extra-user-grants-after-migration

Conversation

@julianam-w

Copy link
Copy Markdown
Contributor

Problem

Extra users scoped to a persistentSchemas schema were left with no USAGE on it — the configured grants silently never took effect.

The grants only ever ran in the restore's init script, filtered by JOIN pg_namespace n ON n.nspname = trim(s.name). For a persistentSchemas replica that filter matches nothing: dbt doesn't exist in the restored snapshot yet, it arrives later via the migration Job. And where the schema does exist in the snapshot, the operator drops it before the migration writes the canonical copy — DROP SCHEMA … CASCADE takes every grant on it, plus the schema-scoped pg_default_acl row behind ALTER DEFAULT PRIVILEGES … IN SCHEMA, and the migration then recreates it with --no-privileges.

Either path ends with the extra user unable to read the schema it was configured for, with nothing in the logs.

Fix

Re-apply the grants from the operator (per AGENTS.md: prefer the operator over job-side SQL), once redaction and the migration have settled and before the Service selector moves — so no client ever sees the restore ungranted.

  • postgres::schema_read_grants (pure, testable), grant_schema_read_on, is_superuser_on, connectable_databases_on
  • replica::reconcile_extra_user_grants, called from reconcile ahead of the switchover block

The init-script pass stays: it's the only pass for read-only restores, where the analytics credential holds pg_read_all_data rather than SUPERUSER and so can't grant on schemas it doesn't own. The operator step probes for that and skips — on a restore already locked read-only nothing has rewritten a schema since init, so init's grants stand.

Users with an empty schemas list are deliberately ungranted and stay that way.

Tests

Six unit tests: generated SQL, identifier quoting (role and schema names come from the CRD), read-only-ness of the grants, and the user-list gating.

New integration test tests/extra_user_grants.rs (+ matrix entry) — needs CI, doesn't run locally. It asserts, on the restore the migration wrote:

  • USAGE on the migrated schema
  • the scoped user can actually SELECT the migrated rows
  • default privileges cover a table the analytics role creates after the migration (the pg_default_acl half)
  • public stays ungranted, so the grant is still scoped to what was declared

Note

cargo check can't build this repo on Windows — reqwest::ClientBuilder::unix_socket, tokio::signal::unix, and two canopy/verification.rs test assertions. All pre-existing on main; those files are untouched here.

🤖 Generated with Claude Code

julianam-w and others added 2 commits September 7, 2026 15:45
The restore init script grants each extra user its declared schemas while
the temporary postgres is up, filtered by a JOIN on pg_namespace. A
persistent_schemas schema does not exist in the restored snapshot at that
point, so the filter matches nothing and the grants are silently skipped;
where it does exist, the operator drops it before the migration Job
rewrites the canonical copy with --no-privileges, taking the schema grant
and the schema-scoped pg_default_acl row with it. Either way the extra
user ended up with no USAGE on the schema it was configured to read.

Re-apply the grants from the operator once redaction and the migration
have settled and before the Service selector moves, so no client sees the
restore ungranted. Skipped when the connected role is no longer a
superuser: on a restore already locked read-only nothing has rewritten a
schema since init, so init's own grants stand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fake_first_name() draws from anon's dictionary, which contains the names
the fixture starts with, so a correctly masked row can land on a name
another row began with. The assertion checked whether any original name
appeared anywhere in the column, so that legitimate outcome failed the
test: Eve masked to Alice and tripped the check on row 1's original.

Compare each row against its own original instead, tolerating a single
self-collision, and assert the CASE-WHEN branch directly - a single name
must come back without a space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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