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;fixauto-remediates the mechanically-fixable ones to stdout or a migration-ready.sqlfile.--baselinerecords 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 bandb AND aare 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 jsongrows two additive keys on an offline run,schema_sourceandskipped_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-coveragefails 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.--snapshotis treated as conservatively as--sql-file, so an older or partial snapshot can’t silently no-op a catalog rule.fixandgenerateare emit-only offline (--applyis rejected), and every emitted statement — including--outputmigration 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.