Skip to content

Policy expression changes are silently ignored — migrate diffs RLS policies by name only #98

Description

@MelbourneDeveloper

Summary

DataProviderMigrate migrate diffs RLS policies by name only. If a policy's
using: / withCheck: expression changes in schema.yaml but the name stays the
same, the tool reports Schema is up to date — no operations needed and the
database keeps the old, wrong predicate — silently, forever.

This is not a cosmetic drift issue. It cost us a full production outage.

Impact (real incident, 2026-09-04)

migrations/schema.yaml declared:

- name: tenants_self_membership_select
  operations: [Select]
  roles: [app_user]
  using: "app_tenant_id() is null and is_member(app_user_id(), id)"

Production actually had the predecessor expression:

(current_setting('app.tenant_id'::text, true) IS NULL) AND is_member(app_user_id(), id)

The corrected expression had been in schema.yaml since PR #275 and make migrate
had been run repeatedly against prod since. Every run said "up to date". The old
predicate is permanently false on any pooled connection (once any transaction on
a backend runs SET LOCAL app.tenant_id, the placeholder's reset value becomes '',
never NULL again), so GET /tenants/me returned [] for every user of the platform.

There was no signal anywhere: exit code 0, no warning, no drift report.

Reproduction

  1. DataProviderMigrate migrate --schema s.yaml --output "$PG" --provider postgres
    with a policy p whose using: is expression A.
  2. Edit s.yaml so policy p keeps its name but its using: becomes expression B.
  3. Re-run the same command.

Expected: the tool detects the changed predicate and replaces the policy
(DROP POLICY + CREATE POLICY, gated behind --allow-destructive if you consider
the drop destructive).

Actual: Schema is up to date — no operations needed. The database still enforces
expression A.

--allow-destructive does not help — the diff is empty before phase filtering, so
there is nothing for it to permit.

Why the workaround is bad

The only way to land the fix through DataProvider was to rename the policy
(tenants_self_membership_select..._v2) so the differ saw it as a new object.
That leaves a dead policy in the database until a separate destructive pass, and it
puts a version suffix into a name that should be stable. Our house rules forbid
hand-written DDL, so there is no clean escape hatch.

Asks

  1. Diff policy USING / WITH CHECK expressions, not just names. Comparing
    pg_get_expr(polqual, polrelid) after normalisation would catch this.
  2. If exact expression comparison is too brittle (Postgres reformats predicates —
    app_tenant_id() comes back as public.app_tenant_id(), literals get ::text
    casts), then at minimum emit a warning listing every policy whose stored
    expression does not match the declaration. Silence is the part that hurt.
  3. Same question applies to other body-bearing objects — functions: bodyLql,
    check constraints. If those are also name-only, they have the same failure mode.

Related, seen in the same run

Every migrate run ends with:

SCHEMA INTEGRITY CHECK FAILED
public.topup.status: default expected 'pending' but found 'pending'::text

The integrity checker is not normalising Postgres' ::text cast on a text column
default, so it reports a false positive on a converged schema. This trains operators
to ignore the tool's final line — which is exactly why the policy drift went unnoticed
for as long as it did. Worth fixing alongside.

Security note

DataProviderMigrate echoes the full connection string, password included, to
stdout on every run:

  Output:   Host=...;Port=5432;Database=postgres;Username=...;Password=<cleartext>;SslMode=Require;...

That lands in CI logs, terminal scrollback, and agent transcripts. Please mask the
password (or drop the line).

Versions: DataProviderMigrate (dotnet tool, current as of 2026-09-04), PostgreSQL 15
(Supabase), connecting through the Supavisor pooler.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions