Row-Level Security is the one Postgres feature that fails silently. A wrong GRANT throws; a wrong RLS policy just returns the wrong rows — sometimes another tenant’s rows — and nothing in the database complains. Review catches the obvious mistakes and misses the structural ones, because the bug is usually a boolean that reads correctly and evaluates wrong.

pgrls is a static analyzer for exactly that class of bug, and over the last couple of months I landed a few patches upstream. This is the write-up I wish I’d had going in. Two parts: first the map — what pgrls is and how it fits CI — then the contribution I care most about, an offline lint path that makes gating RLS in CI a zero-dependency step.


Part 1 — The tool

What it does

pgrls reads the RLS in a Postgres schema and checks it against a catalog of 61 rules — 20 of them mechanically auto-fixable — reporting the kind of policy bug eyeball review misses: broken row scoping across tenants and between users in the same tenant, inverted auth checks, write-side holes where the USING clause is tight but the WITH CHECK is missing. It’s MIT-licensed, framework-agnostic (Supabase, PostgREST, Hasura, Django, raw SQL all lint the same), and built to live in CI: findings come out as text, JSON, SARIF, Markdown, a GitHub PR comment, GitHub Actions annotations, or JUnit XML.

The canonical example — the bug that ships to prod despite policy review:

CREATE POLICY tenant_read ON public.documents
    FOR SELECT TO authenticated
    USING (auth.uid() IS NULL OR owner = auth.uid());

It’s structured the way half the RLS tutorials show it, and it’s wrong. auth.uid() returns NULL for any connection without a session JWT; for those connections the IS NULL branch is true, the OR short-circuits, and the policy hands back every row of public.documents. pgrls flags it as SEC004 in milliseconds, and with --explain prints the reference paragraph underneath. A human reviewer reads that line as “handles the anonymous case” and moves on.

The surface

pgrls is more than a linter; the rule engine is the center of a small constellation:

  • lint / fix — check a schema against all 61 rules; fix auto-remediates the mechanically-fixable ones to stdout or a migration-ready .sql file. --baseline records existing findings so CI fails only on new ones — the thing that lets a team adopt pgrls on a legacy database without clearing the whole backlog first.
  • generate — scaffold gold-standard RLS (ENABLE + FORCE, an isolation policy, a restrictive floor, the supporting index) for tables that lack it, per-tenant or per-user. Don’t trust your ORM’s RLS; generate correct RLS, then lint it.
  • snapshot / diff — a semantic policy diff that classifies every migration SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS, so CI gates on real regressions and waves through safe schema churn. The predicate comparison is Z3-backed: a AND b and b AND a are equal, and so are changes a solver can prove equivalent.
  • verify — a Z3 isolation prover that either proves cross-tenant isolation or hands you a concrete counterexample row.
  • A pytest plugin for RLS isolation tests, an MCP server so AI agents can lint the DDL they just wrote, plus TypeScript and Go ports of the testing contract and a VS Code extension.

The through-line is that RLS deserves the same CI treatment as everything else in the repo: a fast check that fails the build on a real regression and stays quiet otherwise.


Part 2 — The contribution: linting with no database

The friction

For all of that, until recently every pgrls command needed a schema to point at, and getting one in CI meant one of two things: stand up a service- container Postgres, apply your migrations, and hand pgrls a DATABASE_URL; or use pgrls lint --migrations, which boots a throwaway Postgres via testcontainers, applies the files, introspects, and tears it down. The second is lovely on a laptop and a genuine improvement, but it needs Docker in the runner, and plenty of CI environments — locked- down GitHub Actions, shared build fleets, pre-merge hooks — don’t have a Docker daemon to give.

Meanwhile the most common thing a team actually wants to gate is narrow: “lint the RLS in this pull request’s .sql diff.” That shouldn’t require a database at all. And in fact pgrls already knew how to do it — the MCP server added in 0.40.0 lints raw DDL with no database, building a schema with populated policy ASTs straight from the CREATE TABLE / CREATE POLICY SQL via pglast. The engine existed; it just wasn’t wired to the CLI.

What I built

PR #231 promotes that offline engine up to lint, fix, and generate with two new, mutually-exclusive schema sources:

# lint the RLS in a PR's SQL diff — no Postgres, no Docker
pgrls lint --sql-file migrations/0007_add_policies.sql

# repeatable and stdin-friendly
git diff --name-only main -- '*.sql' | xargs -I{} cat {} \
  | pgrls lint --sql-file -

# or point it at a pgrls snapshot artifact
pgrls lint --snapshot .pgrls-snapshot.json

--sql-file takes raw DDL (repeatable, concatenated in order; - reads stdin); --snapshot takes a pgrls snapshot artifact. Both make the common CI gate a genuinely zero-dependency step — no service container, no Docker socket, no DATABASE_URL. Just the SQL that’s already in the diff.

The interesting part: staying honest offline

The easy version of this feature is a trap. Some pgrls rules are catalog-only — they need facts that don’t live in the DDL text: which roles hold BYPASSRLS, which functions are SECURITY DEFINER, triggers, foreign keys, default privileges, views, and — the one that bit me — indexes. If offline mode just ran the rules it could and reported a clean exit, an offline “0 findings” would quietly mean “0 of the rules that happen to work without a catalog,” and a team would read a green check as a security guarantee it never made.

So the whole feature is built around one contract: offline analysis may only ever under-report, and it must say so.

  • Catalog-only rules are skipped, counted, and surfaced — never silently treated as passing.
  • --format json grows two additive keys on an offline run, schema_source and skipped_rules, so the partial-coverage fact reaches the CI gate as data. (Live-database JSON is byte-identical — no consumer breaks.)
  • pgrls lint --require-full-coverage fails a run that couldn’t evaluate every rule. An offline exit 0 is not a clean bill of health unless you pair it with that flag — and the docs say so in as many words.
  • --snapshot is treated as conservatively as --sql-file, so an older or partial snapshot can’t silently no-op a catalog rule.
  • fix and generate are emit-only offline (--apply is rejected), and every emitted statement — including --output migration files — carries an offline-provenance header so a reviewer knows it was generated without a live catalog.

verify was deliberately left out. Offline, its Z3 prover could produce a false-positive LEAK — a counterexample that a fuller, un-passed schema would prove safe — which would contradict its core guarantee that it never reports a leak it can’t exhibit. Better to return under its own design than to weaken the promise.

The bug the contract caught

The reason a soundness contract earns its keep is that it turns a silent correctness break into a visible one. During a multi-role review of the PR, one reviewer found that PERF003 (the missing-index rule) fired on every offline RLS policy. The cause was exactly the catalog-blindness the contract is about: the DDL parser doesn’t model CREATE INDEX, and PERF003 fires on the absence of an index — so offline, every policy looked un-indexed. Left in, it would have buried the real findings under noise and trained everyone to ignore the output.

The fix followed the contract rather than fighting it: PERF003 is inerted offline (it’s a catalog-only fact, so it joins the skipped-and-counted set), still fires against a live database, and its auto-fixer is suppressed offline so it can’t emit a spurious CREATE INDEX. The full suite — 3332 tests — passes clean.


None of this is a large diff. That’s rather the point: the offline engine was already there for the MCP server, the CLI already had a schema-source abstraction, and the work was mostly in the seams — deciding what “no database” is allowed to claim, and making the tool refuse to overclaim. RLS bugs are quiet enough on their own; a linter that gates them shouldn’t add a second layer of quiet. If you run Postgres RLS and gate migrations in CI, pgrls lint --sql-file is now a check you can add without asking your runner for a database.