▶ 23-second demo · Quickstart · Rule reference · Docs site · CHANGELOG · PyPI
Static analyzer for Postgres Row-Level Security. Catches the policy bugs eyeball-review misses — broken row scoping (across tenants and between users in the same tenant), inverted auth checks, write-side holes; 17 of 52 rules mechanically auto-fixable.
pgrls diffclassifies every migration SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS so CI gates on real regressions, not safe schema changes. MIT, framework-agnostic (Supabase, PostgREST, Hasura, Django, raw SQL), CI-native (text / JSON / SARIF / Markdown / GitHub-PR-comment / GitHub annotations / JUnit XML).
Beta — actively maintained. 52 lint rules, 17 mechanically auto-fixable, semantic policy-diff command, pytest plugin for RLS isolation tests. Tested on PostgreSQL 15, 16, 17. Stable JSON / SARIF schema for CI integrations. The CHANGELOG records every release; current build is shown by the PyPI badge above.
- Lint & fix —
pgrls lintchecks a live database against all fifty-two rules and reports findings as text, JSON, SARIF, Markdown, GitHub-PR-comment (--format pr-comment), GitHub Actions annotations (--format github), or JUnit XML (--format junit) for CI.pgrls fixauto-remediates the mechanically-fixable rules (SEC001, SEC002, SEC006, SEC011, SEC015, SEC017, SEC019, SEC020, SEC030, SEC031, SEC032, PERF001, PERF003, PERF004, HYG003, VIEW001, VIEW002) — to stdout or a migration-ready.sqlfile (--output).pgrls lint --baselinerecords existing findings so CI fails only on new ones, letting a team adopt pgrls on a legacy database without clearing the whole backlog first.- Generate —
pgrls generatescaffolds gold-standard RLS for tables that lack it — per-tenant (tenant_id) or per-user (--model owner, incl. the Supabaseauth.uid()form): ENABLE + FORCE, an isolation policy, a restrictive floor, and the index, output designed to lint clean. Don't trust your ORM's RLS; generate correct RLS, then lint it.- Test — the
pgrls.testingpytest plugin for writing RLS tests: role switching, per-test transactions, and tenant-isolation assertions.- Snapshot & diff —
pgrls snapshot/pgrls diffis a semantic RLS-policy diff that classifies every change SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS. Z3-based predicate analysis is built in, plus migration-as-input — apply a migration to an ephemeral Postgres and diff the result (pip install pgrls[diff-apply]), withCREATE EXTENSIONauto-detection and a cached-baseline Docker image for fast re-runs.- TypeScript port —
pgrls-teston npm implements the same RLS-testing contract for JS/TS — bothpgandpostgres.jsdriver adapters, vitest-friendly. Seets/in this repo.- VS Code extension —
pgrls/pgrls-vscodewraps the CLI;pgrls: Lint databasesurfaces findings as diagnostics in the Problems panel, with hover documentation per rule.
pip install pgrlsRequires Python 3.11+ and Postgres 15+. pgrls is tested in CI against PostgreSQL 15–17 (see .github/workflows/test.yml for the matrix).
No database to point at? Hand pgrls your migrations and it builds a throwaway Postgres for you — applies them in order, introspects, lints, and tears it down:
pip install 'pgrls[ephemeral]' # adds testcontainers; needs Docker
pgrls lint --migrations ./migrations # auto-detects Supabase / Prisma / Flyway / sqitch / plain .sql
pgrls lint --supabase # shortcut for ./supabase/migrations + the auth.* stubs and roles--migrations takes a directory or a single .sql file; the layout is auto-detected (override with --migrations-layout / --migrations-glob), and --create-role NAME pre-creates any role your policies reference. It's the zero-setup way to gate RLS in CI — no service container, no DATABASE_URL, just Docker.
The kind of mistake 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());Looks fine — and is structured the way many RLS tutorials show it. But 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 admits every row of public.documents. It's a recurring pattern in multi-tenant Supabase / PostgREST projects — the kind of thing a public CVE write-up names by hindsight.
pgrls flags it as SEC004 (severity error) in milliseconds. With --explain, the rule's reference paragraph is appended underneath the finding (lines hard-wrapped here for the README; the real output is one long line per paragraph):
$ pgrls lint --rule SEC004 --explain
ERROR SEC004 public.documents.tenant_read
Policy 'tenant_read' on public.documents contains a top-level
`auth_func() IS NULL` disjunct in its USING clause. For anonymous
connections that disjunct evaluates to true, satisfying the policy
and exposing every row. Remove the IS NULL disjunct or replace with
an explicit deny.
The pattern: a policy with USING (auth_func() IS NULL OR <real check>).
auth_func() returns NULL for anonymous connections, so the IS NULL
disjunct is true and the OR is satisfied without ever evaluating the
real check. Anonymous clients see all rows.
pgrls: 1 error.
RLS isn't only about keeping tenants apart. The same bug class bites within a single tenant, when rows are meant to be per-user:
CREATE TABLE documents (id uuid, tenant_id int, owner_id uuid, body text);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_scope ON documents
USING (tenant_id = current_setting('app.tenant')::int);Cross-tenant reads are blocked, so this passes a tenant-isolation review. But there's an owner_id column and nothing keys on it, so every user in a tenant reads every other user's documents. If that table holds drafts, DMs, or private uploads, it's a leak. SEC027 (info) flags the table so you decide: add a per-user predicate, or confirm it's intentionally tenant-shared and allowlist it.
Browse the full rule catalogue in AGENTS.md for the other 41 — missing WITH CHECK, BYPASSRLS roles, per-row auth-function evaluation, search-path attacks, view-mediated RLS bypasses, and more.
Scaffold a config (optional — pgrls runs with zero config):
pgrls init # writes a commented pgrls.toml; --force to overwrite
pgrls init --preset supabase # tailor the conventions to a stack--preset (generic · supabase · postgrest · neon) tailors the documented tenancy convention and the exact pgrls generate command for that stack — rules stay at their defaults.
Point pgrls at any Postgres database:
export DATABASE_URL="postgres://user:pass@host:5432/db"
pgrls lintOr pass the URL directly:
pgrls lint --database-url "postgres://user:pass@host:5432/db"Limit the scan to specific schemas:
pgrls lint --schemas public,tenantPoint at a non-default config file, or pick an output format:
pgrls lint --config ./config/pgrls.toml --format text # human-readable (default)
pgrls lint --config ./config/pgrls.toml --format json # machine-readable for CI
pgrls lint --config ./config/pgrls.toml --format sarif # GitHub Code Scanning
pgrls lint --config ./config/pgrls.toml --format markdown # rendered CI reports / runbooks
pgrls lint --config ./config/pgrls.toml --format pr-comment # collapsible per-rule GitHub PR comment
pgrls lint --config ./config/pgrls.toml --format github # GitHub Actions run annotations
pgrls lint --config ./config/pgrls.toml --format junit # JUnit XML for CI test reportsOr run only specific rules — handy when scoping a SARIF report in CI, or
investigating one rule in isolation. --rule is case-insensitive,
repeatable, and overrides [lint] disable in the config so you can pull a
disabled rule back in for one run without editing the config:
pgrls lint --rule SEC001 --rule SEC003Or run everything except certain rules with --exclude-rule (the
complement of --rule; case-insensitive, repeatable):
pgrls lint --exclude-rule SEC022 --exclude-rule PERF002Trim the printed report to the findings you care about with
--min-severity (display-only — the exit code still reflects every finding
per --fail-on, so hiding info noise can't flip CI green), and write the
report to a file instead of stdout with --output/-o:
pgrls lint --min-severity warning # hide info-level nudges from output
pgrls lint --format sarif --output pgrls.sarifPass --explain to append each rule's reference paragraph beneath its
finding in the text output, so a CI log carries the why next to the
where without a separate pgrls explain <RULE> lookup. Text format
only — JSON / SARIF / Markdown / GitHub / JUnit keep their schemas stable.
Text (default):
ERROR SEC001 public.users
Table public.users does not have row-level security enabled.
Add ENABLE ROW LEVEL SECURITY or include the table in
[lint.rules.SEC001].allowlist if it is a public reference table.
pgrls: 1 error.
JSON (--format json):
{
"violations": [
{
"rule_id": "SEC001",
"severity": "error",
"title": "RLS not enabled on table",
"message": "Table public.users does not have row-level security enabled. Add ENABLE ROW LEVEL SECURITY or include the table in [lint.rules.SEC001].allowlist if it is a public reference table.",
"location": "public.users"
}
],
"summary": { "errors": 1, "warnings": 0, "infos": 0, "others": 0, "total": 1 }
}The JSON shape is the public CI contract — top-level keys, per-violation keys, and summary keys are stable across releases. Pipe through jq to filter, count, or transform; ship to a dashboard; upload as a build artifact.
SARIF (--format sarif) emits a SARIF v2.1.0 document. GitHub Code Scanning, Azure DevOps, and other static-analysis aggregators consume it directly — see the GitHub Actions recipe below for the upload step that puts findings inline on PRs.
Exit codes follow the standard linter convention:
0— clean (or findings belowfail_on)1— findings met or exceededfail_on(defaultwarning); your schema has an RLS issue2—pgrlsitself failed to run (bad config, DB unreachable, fixer SQL rolled back, etc.). Distinct from1so CI alerts can route "schema bug" differently from "tool error."
Running pgrls against an existing database for the first time often surfaces a backlog of pre-existing findings. --baseline lets you ratchet: record today's findings and have CI fail only on new ones.
# First run (file absent): records every current finding, exits 0.
pgrls lint --database-url "$DATABASE_URL" --baseline pgrls-baseline.json
# Later runs: report and fail only on findings NOT in the baseline.
pgrls lint --database-url "$DATABASE_URL" --baseline pgrls-baseline.jsonThe first run writes the baseline file and exits 0. Every later run suppresses findings already recorded and exits nonzero only when a new finding appears — so a team can adopt pgrls without fixing the whole backlog up front, then chip away at the baseline over time. Commit the baseline file to the repo.
To re-baseline after deliberately accepting new findings, pass --update-baseline alongside --baseline FILE; the baseline is refreshed in place with the current findings (replace, not merge — stale entries for findings that no longer fire are dropped). No need to delete the file first.
pgrls fix generates SQL for the rules whose remediation is mechanical. Default mode is dry-run — it prints the SQL but does not modify the database. Pass --apply to execute, or --output <file> to write a migration-ready .sql script (a header plus one -- [rule] description comment per statement) instead of printing to stdout.
# Dry-run: print what would change.
pgrls fix --database-url "$DATABASE_URL"
# Apply for real.
pgrls fix --database-url "$DATABASE_URL" --apply
# Only fix one rule.
pgrls fix --database-url "$DATABASE_URL" --rule SEC002 --apply
# Write the SQL to a migration-ready file instead of stdout.
pgrls fix --database-url "$DATABASE_URL" --output migration.sql
# CI gate: exit 1 if any auto-fixable violations exist (no SQL emitted).
pgrls fix --database-url "$DATABASE_URL" --checkCurrently fixable: SEC001 (emits ALTER TABLE … ENABLE ROW LEVEL SECURITY;), SEC002 (emits ALTER TABLE … FORCE ROW LEVEL SECURITY;), SEC006 (emits ALTER POLICY … WITH CHECK (…) mirroring the policy's USING), SEC011 (emits ALTER POLICY … USING (…) / WITH CHECK (…) stripping an OR true debug bypass), SEC015 (emits ALTER FUNCTION …(…) SET search_path = … per SECDEF overload, pinning pg_temp last; abstains on pre-v12 empty signatures and quoted-comma paths), SEC017 (emits ALTER FUNCTION …(…) NOT LEAKPROOF; per overload; abstains on pre-v12 empty signatures), SEC019 (emits ALTER POLICY … USING (…) / WITH CHECK (…) adding the missing_ok = true second argument to one-argument current_setting() calls), SEC020 (emits ALTER POLICY … WITH CHECK (…) replacing a constant-true WITH CHECK with the policy's USING), SEC031 (emits DROP POLICY … ON …; for a no-op restrictive USING (true) floor — it AND-combines to nothing, so dropping it leaves access unchanged), SEC030 (emits ALTER TABLE … ALTER COLUMN … SET NOT NULL; for a nullable tenant discriminator — backfill existing NULLs first, or the apply fails and rolls back the batch), SEC032 (emits ALTER TABLE … ENABLE ROW LEVEL SECURITY; for a table whose policies are dormant because RLS is off), PERF001 (rewrites unwrapped auth calls as (SELECT auth.uid()) and emits ALTER POLICY … USING (…);), PERF003 (emits CREATE INDEX ON … (…); for a policy-predicate column with no leading-column index), PERF004 (emits CREATE INDEX ON … (<expression>); matching a function-wrapped predicate like lower(email) that defeats the plain column index), HYG003 (emits DROP POLICY … ON …; for a policy that exactly duplicates another on the same table), VIEW001 (emits ALTER VIEW … SET (security_invoker = true);), and VIEW002 (emits ALTER VIEW … SET (security_barrier = true);). Other rules need human intent (which role? which column? which policy?) and are not auto-fixed.
pgrls fix repairs RLS you already have; pgrls generate writes it from
scratch for tenant tables that have none. The shoot-out showed ORMs emit
broken or absent row security — the counter is to generate correct RLS and
lint it.
For every table that carries a tenant-discriminator column (default
tenant_id) and has no policies, generate emits the complete
gold-standard setup — ENABLE + FORCE row security, a permissive
tenant-isolation policy, a RESTRICTIVE floor, and the supporting index —
designed to lint clean:
# Dry-run: print the SQL for every unprotected tenant_id table.
pgrls generate --database-url "$DATABASE_URL"
# Write a migration, or apply in one all-or-nothing transaction.
pgrls generate --database-url "$DATABASE_URL" --output rls.sql
pgrls generate --database-url "$DATABASE_URL" --apply
# The round-trip the feature guarantees:
pgrls generate --apply && pgrls lint # → no findingsThe predicate compares the column to a session value, wrapped in
(SELECT …) for per-statement caching and cast to the column's type:
CREATE POLICY posts_tenant_isolation ON public.posts TO authenticated
USING (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid))
WITH CHECK (tenant_id = (SELECT current_setting('app.tenant_id', true)::uuid));--convention postgrest switches the source to
current_setting('request.jwt.claim.tenant_id', true); --setting-name,
--role (default authenticated), and --no-restrictive tune the rest.
(The restrictive floor is what silences the SEC007 "all policies
permissive" advisory — --no-restrictive trades it back for that info
finding.)
For a non-conventional column, name it explicitly:
--table public.orgs:org_id. Tables that already have policies are
skipped — generate never overwrites hand-written policy intent, so
re-running it is a no-op.
Per-user ownership — --model owner scaffolds the other canonical
pattern (rows owned by a user, default column user_id) instead of
per-tenant isolation. With --convention supabase it emits the idiomatic
user_id = (SELECT auth.uid()):
pgrls generate --model owner --convention supabase --apply--convention app-guc / postgrest use
current_setting('app.user_id', …) /
current_setting('request.jwt.claim.sub', …) instead. Scope is the common
single-column case (tenant or owner); per-CRUD and membership-join shapes
stay hand-written.
pgrls lint answers "what's wrong?"; pgrls report answers "what's the posture overall?" — a factual, rule-free snapshot of every table's row-level-security state, for audits and onboarding.
pgrls report --database-url "$DATABASE_URL" # text table + summary
pgrls report --database-url "$DATABASE_URL" --format json # machine-readable
pgrls report --database-url "$DATABASE_URL" --format markdown -o posture.md # write an audit doc
pgrls report --database-url "$DATABASE_URL" --format html -o posture.html # standalone HTML page, print/PDF-readyEach table gets a coarse status — protected (RLS on, FORCE'd, ≥1 permissive policy), not-forced (RLS on with a permissive policy, but owner bypasses), no-policies (RLS on but no permissive policy → default-deny; covers zero policies and restrictive-only tables), covered-by-parent (a partition child whose RLS-enabled parent covers queries routed through it — credited when that parent is among the scanned schemas), or rls-off — plus an aggregate summary. It runs no rules and emits no findings; use pgrls lint for that.
Where pgrls report summarizes each table's posture, pgrls matrix answers the audit question directly: for every role × table × command, can it reach the rows? It collapses table GRANTs, the RLS enabled/forced flags, and the permissive(OR) / restrictive(AND) policy set into one verdict per cell.
pgrls matrix --database-url "$DATABASE_URL" # role × table × command grid
pgrls matrix --database-url "$DATABASE_URL" --roles anon,authenticated # only these role columns
pgrls matrix --database-url "$DATABASE_URL" --format json # full predicates, machine-readable
pgrls matrix --database-url "$DATABASE_URL" --format html -o access.html # standalone audit pageEach cell is one of:
OPEN— every row is reachable: the role is granted the command's privilege and either RLS is off, the role bypasses RLS (BYPASSRLS), or an applicable permissive policy is unconditionallytruewith no restrictive policy narrowing it.DENIED— no table privilege for that command, or RLS is on with no applicable permissive policy (Postgres default-denies).COND— granted, but gated by a row predicate (shown in--format json/html): the OR of applicable permissive clauses, AND-ed with any restrictive ones.
Per command it evaluates the clause Postgres actually applies — WITH CHECK for INSERT, USING for SELECT/UPDATE/DELETE. (For UPDATE, v1 shows the read-side USING — which rows are reachable; the write-side WITH CHECK gate that validates the new row image is not modeled per-cell.) Columns default to PUBLIC, anon, authenticated plus every non-system role named by a grant or policy, or carrying BYPASSRLS (--roles overrides; --include-system-roles adds pg_*). Like report, it runs no rules. Two further caveats it does not model per-cell: a table owner bypasses RLS unless the table is FORCEd, and a superuser bypasses everything.
pgrls lint flags a suspicious policy; pgrls verify proves — with the Z3 SMT solver — a read-isolation property, and hands back the leaking row (not just a warning) when it fails. Two complementary threat models via --mode:
anon(default) — an anonymous session (every auth function —auth.uid()/role()/jwt(),current_setting(...)— returningNULL, the unauthenticated state) cannot read any row.cross-tenant— a session authenticated as one tenant cannot read a different tenant's row, checked against the policy's own<column> = <session identity>scoping equality (the predicatepgrls generateemits).
pgrls verify --database-url "$DATABASE_URL" # anon proof, exits 1 on any leak
pgrls verify --database-url "$DATABASE_URL" --mode cross-tenant # prove no tenant reads another tenant's rows
pgrls verify --database-url "$DATABASE_URL" --format json # per-table / per-policy verdicts + counterexamples
pgrls verify --database-url "$DATABASE_URL" --format sarif # SARIF v2.1.0 for GitHub Code Scanning
pgrls verify --database-url "$DATABASE_URL" --strict # also fail on UNVERIFIED
pgrls verify --database-url "$DATABASE_URL" --auth-function auth.user_id # add a custom auth helper
pgrls verify --database-url "$DATABASE_URL" --emit-repro ./repro # write a runnable repro per leak (any mode)The two modes are complementary. The signature inverted-auth policy auth.uid() IS NULL OR tenant_id = auth.uid() is an anon LEAK (an unauthenticated client sees every row) yet cross-tenant PROVEN (an authenticated tenant only ever sees its own rows — the IS NULL branch is false once authenticated). Conversely a tenant_id = auth.uid() OR is_public policy is anon-LEAK and cross-tenant-LEAK (another tenant's public rows leak), while USING (true) is an anon-LEAK but cross-tenant-UNVERIFIED (no scoping equality to verify against — already caught by anon mode). Run both for full coverage.
Each RLS-enabled table gets one of three honest verdicts (the phrasing below is anon-mode; cross-tenant frames the same verdicts as "readable by a session of a different tenant"):
PROVEN— the read is unsatisfiable under the threat model: Z3 proves no row is ever visible to an anonymous session (anon) / to a session of a different tenant (cross-tenant).LEAK— a row is readable, with a concrete counterexample: a characterizing row (a row with is_public=True is anonymously readable; cross-tenant:a row of another tenant with is_public=True is readable), the unconditional case (anon: every row —USING (true), theauth.uid() IS NULL OR …inversion, now proven rather than guessed; cross-tenant: a row of any other tenant), or — for a conditional leak the prover can't pin to a single row (an opaque/session-dependent bypass) — the leak reported without a characterizing row.UNVERIFIED— Z3 is unavailable, the predicate is outside the decidable fragment, the solver timed out, or (cross-tenant) the policy has no single tenant-scoping equality to verify against. This is the point where the verifier degrades to the linter — no claim is made; runpgrls lintfor the heuristic rules.
--format sarif emits a SARIF v2.1.0 document for GitHub Code Scanning that shares the schema and tool.driver block with pgrls lint --format sarif: each LEAK is an error-level result located at schema.table.policy with the witness phrase as its message; PROVEN tables emit nothing; UNVERIFIED tables are omitted unless --strict, where each becomes a note-level result — so the result-set is non-empty exactly when the run would fail the gate. The prover is one rule per --mode (pgrls-anon-isolation / pgrls-cross-tenant-isolation).
--emit-repro DIR turns a LEAK into something you can run: for each leak it writes a .sql script and a pytest that recreate a throwaway copy of the table from the introspected column types, install the leaking policy, insert the counterexample row, and SELECT it back — as an anonymous session (anon) or, for --mode cross-tenant, as a session authenticated as tenant A with the inserted row belonging to a different tenant B (the session identity is set via the GUC the policy reads — the JWT-claim GUC for an auth.* helper, or a direct current_setting('<guc>')). Either way the SELECT runs as a NOSUPERUSER/NOBYPASSRLS runner, so the reproduction is sound (a fixed policy returns zero rows) — the proof, reproduced and rolled back. The pytest passes while the leak exists and turns red once you fix the policy — a runnable proof of the bug (invert the assertion to keep it as a green regression guard). For a characterized or unconditional leak the inserted row reliably triggers the policy; for a conditional leak (no pinned row) the placeholder row is best-effort and the .sql header flags it for a hand-edit. Re-running won't clobber a hand-edited reproduction unless --force.
It is a soundness proof, not a heuristic: it never reports a leak it cannot exhibit, and never reports PROVEN unless Z3 proves it. pgrls verify exits non-zero on any leak — drop it in CI as a hard tenant-isolation gate, alongside pgrls lint. Scope: both modes reason over each table's permissive SELECT/ALL policies; a leaking permissive policy on a table that also carries a RESTRICTIVE read floor is reported UNVERIFIED (v1 does not combine floors — an already-PROVEN policy stays proven, since a restrictive floor only narrows access), and RLS-disabled tables are out of scope (that is SEC001's job). cross-tenant mode verifies the single <column> = <session identity> scoping equality pgrls generate emits — including when that identity is cast to the tenant column's type (current_setting(...)::uuid, ::bigint/::int, …); a policy with no such equality (or two competing ones) is UNVERIFIED there. --emit-repro works in both modes. Needs the z3-solver dependency (bundled).
Pair a daily cron with pgrls lint --format json -o snapshots/$(date -u +%FT%H%M%SZ).json and ask pgrls history snapshots/ weekly — "are we gaining ground over time, or is the findings count creeping up?"
pgrls history snapshots/ # terminal table
pgrls history snapshots/ --format markdown # paste-ready GFM (for a weekly update / PR comment)
pgrls history snapshots/ --format html -o trend.html # standalone trend page, print/PDF-ready
pgrls history snapshots/ --format json -o trend.json # machine-readable for plottingEach row is one snapshot plus the NEW / FIXED delta vs. the prior snapshot in chronological order (findings keyed by (rule_id, location) so a schema-wide finding stays PERSISTENT rather than NEW+FIXED on every comparison). A trailing summary line names the net change over the full series.
PERF003 predicts a missing index statically (the RLS predicate column has no usable index, so the planner will seq-scan). pgrls perf reads what the database actually did — Postgres's cumulative table statistics (pg_stat_user_tables) — and ranks RLS-enabled tables by rows read sequentially, cross-referencing each against PERF003:
pgrls perf --database-url "$DATABASE_URL" # text table + summary
pgrls perf --database-url "$DATABASE_URL" --format json # also markdown / html
pgrls perf --database-url "$DATABASE_URL" --fail-on-findings # CI gate
pgrls perf --database-url "$DATABASE_URL" --statements # blame specific queries (pg_stat_statements)A table PERF003 flagged that is also observed seq-scanning is a confirmed missing-index candidate; a table PERF003 thought was indexed that still seq-scans means the index isn't being used — poor selectivity or stale statistics, which no amount of schema reading would catch. Tune --min-rows / --min-seq-scans / --min-seq-pct to set what counts as pressure (defaults are conservative — small tables seq-scan by the planner's choice).
To gate CI inside your normal lint run, persist a snapshot and point lint at it — pgrls perf --snapshot .pgrls-perf.json writes the raw counters, then pgrls lint --perf .pgrls-perf.json fires the opt-in PERF005 rule for each RLS table under pressure (inert without the artifact, exactly like HYG004 with a coverage artifact).
When pg_stat_statements is installed, --statements turns "this table seq-scans" into "this query seq-scans it": it parses each recorded statement, keeps those touching a pressured table, and lists the costliest by total execution time — the precise query to fix. It degrades cleanly (a note, base report unchanged) when the extension isn't available.
Honest scope: pg_stat_user_tables counts every sequential scan on a table, not only those an RLS predicate drove, so the table-level view prioritises where to look rather than proving RLS is the cause (that's what --statements resolves). Partitioned tables are under-covered in this release — a partitioned parent records no direct scans (queries hit the children) and partition children don't carry the parent's RLS flag — so their scans may not surface (a false negative, never a false positive). Warm the planner's statistics first (exercise the workload, then ANALYZE).
Drop a pgrls.toml next to your project. See pgrls.example.toml in the repo for a fully commented version.
[database]
url = "$DATABASE_URL"
schemas = ["public"]
[lint]
disable = []
fail_on = "warning"
[lint.rules.SEC001]
allowlist = ["countries", "currencies"]
# `severity` re-tiers a rule's findings without disabling it —
# "error" | "warning" | "info". SEC019 is an info rule; promoting
# it to error makes a one-arg current_setting() call fail CI.
[lint.rules.SEC019]
severity = "error"A config can layer on top of a shared base with a top-level extends
(a path, or a list of paths resolved relative to the file that declares
it) — handy for a monorepo or an org-wide ruleset:
extends = "../pgrls.base.toml" # or ["../base.toml", "./team.toml"]
[lint]
fail_on = "error" # override one key; inherit the restTables deep-merge key-by-key (a child can set [lint.rules.SEC001].severity
while inheriting the base's allowlist); scalars and arrays are replaced,
not appended (a child disable list wins wholesale). For a list, later
entries override earlier ones, and the declaring file overrides every base.
A cycle in the extends chain is an error.
pgrls ships a JSON Schema for pgrls.toml (pgrls.schema.json) so editors autocomplete keys and flag typos and invalid values (a misspelled [lint.rles], a bad fail_on). pgrls init writes a #:schema directive on the first line, which the Even Better TOML VS Code extension applies automatically:
#:schema https://raw.githubusercontent.com/pgrls/pgrls/main/pgrls.schema.jsonPoint any JSON-Schema-aware TOML tooling at that URL for the same validation.
Install with pip install pgrls[testing] to pull in pytest alongside pgrls.
pgrls.testing is a pytest plugin that lets you write RLS tests with idiomatic pytest ergonomics. The pgrls_db fixture opens a connection, starts a per-test transaction, lets you switch roles + claims for each scenario, and rolls back at end so nothing persists between tests.
def test_user_a_cannot_see_user_bs_invoices(pgrls_db):
pgrls_db.seed("public.invoices", [
{"id": "1", "tenant_id": "tenant-a", "amount": 100},
{"id": "2", "tenant_id": "tenant-b", "amount": 200},
])
with pgrls_db.as_role(
"authenticated",
claims={"sub": "user-a", "tenant_id": "tenant-a"},
):
pgrls_db.assert_rows("SELECT id FROM invoices", count=1)
pgrls_db.assert_invisible(
"SELECT id FROM invoices WHERE tenant_id = 'tenant-b'"
)
pgrls_db.assert_rejected(
"INSERT INTO invoices (tenant_id, amount) VALUES ('tenant-b', 999)"
)The plugin assumes the standard PostgREST conventions (SET LOCAL ROLE + request.jwt.claims GUC). Configure the connection string via one of the following — the first one defined wins:
- A
pgrls_test_database_urlfixture in yourconftest.py. This replaces the plugin's default fixture (pytest fixture shadowing); when you supply one, the env-var fallback below is not consulted. Useful for per-session testcontainers. - The
PGRLS_TEST_DATABASE_URLenvironment variable. - The
DATABASE_URLenvironment variable (fallback).
Setting none of the three causes pgrls_db to raise PgrlsTestConfigError.
The cross-language contract is documented at docs/pgrls-test-protocol.md. The TypeScript port ships as pgrls-test on npm — same Layer 1 protocol, same wire-level behaviour, idiomatic JS/TS surface (camelCase API, pg and postgres.js adapters). Source under ts/ in this repo. The Go port is shipping in stages at go/ (module github.com/pgrls/pgrls/go, versioned independently as go/v0.7.x); step 1 (scaffold + ProtocolVersion constant + error types) shipped in go/v0.7.0, with steps 2–7 (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite, release tag) tracked in go/CHANGELOG.md.
When your pgrls.testing suite runs, the plugin records which
(table, role, command) tuples each test exercised and writes them to
.pgrls-coverage.json on session finish (gitignored; disable with
pgrls_coverage = false in your pytest config or PGRLS_COVERAGE=off).
pgrls coverage cross-references that artifact against the live schema
and reports which policies a test exercised and which were never
touched — the cross-tenant DELETE nobody wrote a test for. A policy
is covered when a test queried its table, under a role it targets
(or PUBLIC), with a matching command.
pgrls coverage # text report (text/json/markdown/html)
pgrls coverage --fail-under 80 # exit 1 if coverage < 80% (CI gate)
pgrls lint --coverage .pgrls-coverage.json # enables HYG004 for uncovered policiespgrls diff is the semantic policy diff command. Point it at any two
Postgres sources — two snapshot files, a snapshot and a live DB, or two
live DBs — and it classifies every RLS change as SAFE, BREAKING,
REQUIRES_REVIEW, or DANGEROUS. Use it in CI to gate deployments on
actual security regressions without blocking safe migrations.
# Capture a baseline from the current branch (filter to a schema list
# to keep snapshots small and stable).
pgrls snapshot --database-url "$DATABASE_URL" --schemas app -o base.json
# After applying a migration, compare live DB to the baseline. The
# --schemas filter applies to the URL side only (the snapshot file
# already carries the filter from capture time).
pgrls diff base.json --database-url "$DATABASE_URL" --schemas appThe default --fail-on dangerous threshold means CI only fails when a
genuinely dangerous change is detected (RLS toggled off, a permissive
policy added, a predicate widened, etc.). Pass --fail-on requires-review
for a stricter gate, or set [diff].fail_on in pgrls.toml to make
the choice persistent (CLI flag → [diff].fail_on → built-in
dangerous). Output is git-diff-style by default (--format text);
use --format json or --format sarif for CI integrations that
already parse pgrls lint output (same Violation shape),
--format markdown for a paste-ready PR-comment table with
classification badges, or --format html for a standalone audit
page (embedded CSS, opens offline, prints to PDF) — same shape
pgrls report --format html and pgrls history --format html use.
Pass --explain to append a one-paragraph rationale beneath each
classified Change in the text output — why a dropped PERMISSIVE
policy is BREAKING rather than DANGEROUS, why a column drop is
REQUIRES_REVIEW, etc. Text format only; JSON / SARIF already carry
the classification tag. (The leaking-row counterexample described
below is separate: it is emitted unconditionally on the Z3 path, not
gated behind --explain.)
| Change category | Default classification |
|---|---|
| RLS toggled off | DANGEROUS |
| Table dropped | BREAKING |
| Permissive policy added | DANGEROUS |
| Restrictive policy dropped | DANGEROUS |
| USING predicate widened (OR added) | DANGEROUS |
| USING predicate tightened (AND added) | SAFE |
| Roles widened (PUBLIC or new role) | DANGEROUS |
| Column dropped (still referenced) | REQUIRES_REVIEW |
| GRANT added on non-RLS table to PUBLIC | DANGEROUS |
A DANGEROUS semantic-loosening verdict — the new predicate admits a
strict superset of the old one's rows — also prints a concrete
leaking row: a row the new policy admits but the old one rejected
(e.g. example leaking row: {tenant_id=2}), in both text and JSON
output. The row is only emitted when its column values are a sound,
self-sufficient witness; when the leak depends on a NULL test or an
opaque value (a function call, a current_setting(...) GUC, COALESCE,
or CASE), pgrls prints the label-only DANGEROUS verdict rather than a
row that would not actually leak. Without the extra, the verdict is
unchanged and no row is printed.
See AGENTS.md for the full classification table and AST pattern documentation.
pgrls lint ships these rules:
| ID | Severity | Catches |
|---|---|---|
| SEC001 | error | Tables in scanned schemas with RLS disabled and no policies (a table with policies but RLS off is SEC032) |
| SEC002 | error | Tables with RLS enabled but FORCE ROW LEVEL SECURITY off |
| SEC003 | error | Permissive policies granted to PUBLIC |
| SEC004 | error | Inverted auth check (Lovable CVE pattern) in USING |
| SEC005 | warning | Policy expression has no own-column reference |
| SEC006 | error | INSERT/UPDATE/ALL policies with no WITH CHECK |
| SEC007 | info | All policies on a table are permissive (no RESTRICTIVE floor) |
| SEC008 | warning | Permissive policy USING clause is constant true (admits every row) |
| SEC009 | warning | RLS enabled but no policies defined (silent deny-all) |
| SEC010 | warning | Policy USING/WITH CHECK clause is constant false (deny-all anti-pattern) |
| SEC011 | warning | Policy expression has an OR true branch (debug bypass left in) |
| SEC012 | warning | Table has only RESTRICTIVE policies (silent deny-all — needs at least one PERMISSIVE) |
| SEC013 | warning | Trigger on RLS-protected table can bypass policies (triggers fire as table owner) |
| SEC014 | warning | SECURITY DEFINER function bypasses caller's RLS (audit every SECDEF function) |
| SEC015 | warning | SECURITY DEFINER function exposed to pg_temp search-path shadowing |
| SEC016 | warning | Role with the BYPASSRLS attribute bypasses every RLS policy |
| SEC017 | warning | Function with the LEAKPROOF attribute is evaluated below the RLS barrier |
| SEC018 | warning | Policy compares a column against current_user / session_user (no isolation under a shared pool role) |
| SEC019 | info | Policy calls current_setting() without the missing_ok argument (raises on an unset GUC) |
| SEC020 | warning | Policy WITH CHECK is constant true while USING restricts (writes accept rows reads never would) |
| SEC021 | info | Policy compares an identity column against a hardcoded literal (e.g. tenant_id = 1) |
| SEC022 | info | RLS-enabled table whose policies are all FOR SELECT — no write-side policy, so INSERT/UPDATE/DELETE are denied |
| SEC023 | warning | Policy granted to a role carrying BYPASSRLS — the role skips the policy entirely, so its TO clause is inert |
| SEC024 | info | Policy calls current_setting() with an unqualified parameter name (a dropped prefix the application cannot SET) |
| SEC025 | warning | Policy predicate references another table whose RLS is disabled — the cross-table read is only as strong as the referenced table's isolation |
| SEC026 | warning | Policy predicate uses LIKE / ILIKE / SIMILAR TO / POSIX regex against an auth-context value (a wildcard-shape GUC matches every row) |
| SEC027 | info | RLS table has an owner / user-identity column that no policy scopes by — rows may be visible across users within the same tenant |
| SEC028 | warning | Permissive write policy (INSERT/UPDATE/ALL) whose WITH CHECK is constant true — accepts every write; the TO clause gates who, not what |
| SEC029 | warning | Role can SET ROLE to a BYPASSRLS role through membership — escalation path that silently disables every policy (BYPASSRLS is not inherited, but reachable) |
| SEC030 | info | Policy scopes by a nullable discriminator column (tenant_id = current_setting(…) where the column allows NULL) — NULL rows escape scoping today and leak the moment a NULL-tolerant predicate appears |
| SEC031 | warning | RESTRICTIVE policy whose USING is constant true — AND-combines to a no-op, so it looks like a security floor but enforces none |
| SEC032 | error | Table has policies but RLS is not enabled — the policies are dormant (Postgres ignores them) and the table is wide open despite looking RLS-managed |
| SEC033 | error | Policy scopes by a user-modifiable JWT claim (user_metadata / raw_user_meta_data) — the authenticated user can rewrite the value via the auth API, bypassing the check; use app_metadata (service-role-only) instead |
| SEC034 | warning | Policy gates rows on auth.email() — silent denial-of-service-to-self when the user changes email, when SQL = is case-sensitive but emails aren't, or when plus-addressing means x+y@host ≠ x@host; scope by auth.uid() instead |
| SEC035 | warning | UNIQUE constraint not scoped to the tenant discriminator — a global UNIQUE(email) instead of UNIQUE(tenant_id, email) leaks cross-tenant existence via duplicate-key errors (the PRIMARY KEY and all-uuid uniques are excluded) |
| SEC036 | error | Policy EXISTS (SELECT FROM auth.users WHERE …) sub-select with no caller binding — checks "is there any admin at all" instead of "is THIS user an admin", so every authenticated user passes once any matching row exists |
| SEC037 | warning | Policy compares auth.role() to a value outside the known role set (anon / authenticated / service_role) — comparison never matches and silently denies every row, masking the broken policy |
| SEC038 | error | Semantic anonymous-read leak — Z3 proves the USING predicate is unconditionally TRUE for an unauthenticated session (all auth functions NULL), catching inverted-auth variants SEC004's syntactic match misses. |
| SEC039 | error | Permissive write policy (INSERT/UPDATE/DELETE/ALL) grants the unauthenticated anon role — anonymous PostgREST/Supabase clients can modify rows; the write-side analog of SEC003 for the named anon role (SELECT-only anon policies, the public-read pattern, are not flagged) |
| PERF001 | warning | Auth function called per-row in policy USING (unwrapped) |
| PERF002 | warning | Policy expression uses a VOLATILE function (random(), clock_timestamp(), …) |
| PERF003 | warning | Policy predicate column without a leading-column index (sequential scan on every query) |
| PERF004 | warning | Policy predicate wraps an indexed column in a function (e.g. lower(email)) so the plain index can't serve it — Postgres seq-scans; needs an expression index |
| PERF005 | info | RLS table observed to sequentially scan in production (opt-in; fed by pgrls perf --snapshot via pgrls lint --perf) |
| HYG001 | error | Policies referencing columns that don't exist on the table |
| HYG002 | warning | Policy named like a placeholder (todo, fixme, tmp, …) |
| HYG003 | info | Policy is an exact duplicate of another policy on the same table |
| HYG004 | info | Policy has no behavioral test exercising it (needs pgrls lint --coverage) |
| VIEW001 | error | View over RLS-protected table without WITH (security_invoker = true) |
| VIEW002 | warning | View over RLS-protected table without WITH (security_barrier = true) |
| VIEW003 | warning | Materialized view over RLS-protected table (RLS not honored at query time) |
| VIEW004 | warning | View calls SECURITY DEFINER function that reads an RLS-protected table |
Run pgrls explain <RULE> (for example pgrls explain SEC023) to print any
rule's full rationale — what it flags, why it matters, how detection works,
and how to allowlist a false positive — on the command line. Bare
pgrls explain (no argument) lists the catalog: one line per rule with its
severity and title. Pass --format markdown to either form (pgrls explain SEC023 --format markdown, pgrls explain --format markdown) for a
paste-ready Markdown document — an ## SEC023 — … heading + the body, or a
Markdown table of the catalog. --format json emits machine-readable rule
metadata (id, severity, title, a fixable flag, and — for a single rule —
the full reference body) for IDE / tooling integrations. All forms read only
pgrls's built-in rule catalog, so they need no database connection.
For canonical SQL fixes per rule, see AGENTS.md. For per-rule
configuration options (allowlists, etc.), see pgrls.example.toml.
A linter is only useful if you can trust it not to cry wolf. pgrls ships an
adjudicated precision corpus — small, self-contained schemas, each
labeled with exactly which rules should fire, including deliberately
adversarial near-misses (a coalesce()-wrapped auth check, an IS NULL
buried in a subquery, a predicate hidden in a SubLink) that look like
violations but are safe. The full rule set runs over every case against a
real Postgres; the result is published in
docs/PRECISION.md, and a CI job re-measures on every
push and fails if any rule fires where it shouldn't. Regenerate with
python -m corpus.measure; see corpus/README.md to
add cases.
For per-release changes, see CHANGELOG.md.
pgrls is designed to live in your CI alongside any other linter. By
default it connects to a Postgres database with your schema applied,
introspects, and exits non-zero if any rule at or above
fail_on (default warning) fires. No database to point at? pgrls lint --migrations builds an ephemeral one from your migration files (see
Lint without a live database).
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pgrls/pgrls
rev: v0.5.7
hooks:
- id: pgrls-lint
# pgrls hits a real database, so most teams scope this to
# `pre-push` rather than every commit.
stages: [pre-push]
args:
- --database-url=$DATABASE_URL
- --config=pgrls.tomlThe quickest path is the published Action (pgrls/pgrls-action on the GitHub Marketplace) — it installs pgrls from PyPI and runs pgrls lint against a reachable database:
- uses: pgrls/pgrls-action@v1
with:
database-url: ${{ secrets.PGRLS_DATABASE_URL }}
schemas: public
fail-on: errorIt exposes every flag pgrls lint does (--format, --rule, --exclude-rule, --baseline, --output, --min-severity, …); see the Marketplace listing for the full input table.
Or run pgrls directly — useful when you want to spin up an ephemeral Postgres as a job service:
# .github/workflows/pgrls.yml
name: pgrls
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: ci
POSTGRES_PASSWORD: ci
POSTGRES_DB: ci
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
env:
DATABASE_URL: postgres://ci:ci@localhost:5432/ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pgrls
- name: Apply schema
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/all.sql
- name: Lint RLS
run: pgrls lint --format sarif > pgrls.sarif
- name: Upload SARIF for code scanning
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: pgrls.sarifThe SARIF upload puts findings inline on the PR as code-scanning
alerts — no extra dashboard plumbing. Use --format json instead
of --format sarif if you want to pipe to jq, build your own
dashboard, or keep the report as a build artifact.
- More lint rules. Continued expansion of the SEC / PERF / HYG / VIEW catalog. Polished error messages.
TypeScript port of— shipped as thepgrls.testingpgrls-testnpm package, versioned independently of the Python package (taggedts-v0.6.0). Source:ts/.- Go port of
pgrls.testingfollowing the same Layer 1 protocol — versioned independently of the Python package as thego/v0.7.xsequence (Go module tag prefixgo/, distinct from the Python package's tags). Step 1 (scaffold + protocol-version constant + error types) landed ingo/v0.7.0; subsequent steps (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite) tracked ingo/CHANGELOG.md. SAT-based predicate implication checking.Z3-driven semantic predicate analysis landed in v0.4.x.Migration-as-input.pgrls diff --apply migration.sqlshipped in v0.5.0; baseline cache + extension auto-detect in v0.5.1–v0.5.2.
MIT — see LICENSE.