Skip to main content

Static analyzer for Postgres Row-Level Security — 49 lint rules covering tenant and per-user row-scoping bugs, performance traps, and hygiene; 17 mechanically auto-fixable; gold-standard RLS scaffolding (pgrls generate); semantic policy-diff command for CI gating; pytest plugin for RLS isolation tests with coverage.

Project description

pgrls

PyPI version Python versions License: MIT CI Downloads

▶ 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 49 rules mechanically auto-fixable. pgrls diff classifies 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).

pgrls 60-second tour

Beta — actively maintained. 49 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 & fixpgrls lint checks a live database against all forty-nine 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 fix auto-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 .sql file (--output). pgrls lint --baseline records existing findings so CI fails only on new ones, letting a team adopt pgrls on a legacy database without clearing the whole backlog first.
  • Generatepgrls generate scaffolds gold-standard RLS for tables that lack it — per-tenant (tenant_id) or per-user (--model owner, incl. the Supabase auth.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.testing pytest plugin for writing RLS tests: role switching, per-test transactions, and tenant-isolation assertions.
  • Snapshot & diffpgrls snapshot / pgrls diff is a semantic RLS-policy diff that classifies every change SAFE / BREAKING / REQUIRES_REVIEW / DANGEROUS. Optional Z3-based predicate analysis (pip install pgrls[diff-z3]), plus migration-as-input — apply a migration to an ephemeral Postgres and diff the result (pip install pgrls[diff-apply]), with CREATE EXTENSION auto-detection and a cached-baseline Docker image for fast re-runs.
  • TypeScript portpgrls-test on npm implements the same RLS-testing contract for JS/TS — both pg and postgres.js driver adapters, vitest-friendly. See ts/ in this repo.
  • VS Code extensionpgrls/pgrls-vscode wraps the CLI; pgrls: Lint database surfaces findings as diagnostics in the Problems panel, with hover documentation per rule.

Install

pip install pgrls

Requires Python 3.11+ and Postgres 15+. pgrls is tested in CI against PostgreSQL 15–17 (see .github/workflows/test.yml for the matrix).

Real-world bugs pgrls catches

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.

Usage

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 lint

Or pass the URL directly:

pgrls lint --database-url "postgres://user:pass@host:5432/db"

Limit the scan to specific schemas:

pgrls lint --schemas public,tenant

Point 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 reports

Or 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 SEC003

Or run everything except certain rules with --exclude-rule (the complement of --rule; case-insensitive, repeatable):

pgrls lint --exclude-rule SEC022 --exclude-rule PERF002

Trim 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.sarif

Pass --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.

Example output

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, "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 below fail_on)
  • 1 — findings met or exceeded fail_on (default warning); your schema has an RLS issue
  • 2pgrls itself failed to run (bad config, DB unreachable, fixer SQL rolled back, etc.). Distinct from 1 so CI alerts can route "schema bug" differently from "tool error."

Baseline — adopt pgrls on a legacy database

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.json

The 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.

Auto-remediation: pgrls fix

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" --check

Currently 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), 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), 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), 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.

Scaffold RLS — pgrls generate

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 findings

The 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 skippedgenerate 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.

RLS posture — pgrls report

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-ready

Each 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.

Tracking trends — pgrls history

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 plotting

Each 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.

Configuration

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"

Sharing config across projects — extends

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 rest

Tables 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.

Editor support — JSON Schema

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.json

Point any JSON-Schema-aware TOML tooling at that URL for the same validation.

Testing your RLS — pgrls.testing

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_url fixture in your conftest.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_URL environment variable.
  • The DATABASE_URL environment 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.

Coverage — which policies are actually tested

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 policies

Diff — pgrls snapshot + pgrls diff

pgrls 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 app

The 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.

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

See AGENTS.md for the full classification table and AST pattern documentation.

Rules

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@hostx@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
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
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.

For per-release changes, see CHANGELOG.md.

CI integration

pgrls is designed to live in your CI alongside any other linter. It needs a Postgres database with your schema applied; it then connects, introspects, and exits non-zero if any rule at or above fail_on (default warning) fires.

pre-commit

# .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.toml

GitHub Actions

The 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: error

It 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.sarif

The 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.

Roadmap

  • More lint rules. Continued expansion of the SEC / PERF / HYG / VIEW catalog. Polished error messages.
  • TypeScript port of pgrls.testing — shipped as the pgrls-test npm package, versioned independently of the Python package (tagged ts-v0.6.0). Source: ts/.
  • Go port of pgrls.testing following the same Layer 1 protocol — versioned independently of the Python package as the go/v0.7.x sequence (Go module tag prefix go/, distinct from the Python package's tags). Step 1 (scaffold + protocol-version constant + error types) landed in go/v0.7.0; subsequent steps (Driver interface, pgx + lib/pq adapters, Client API, assertion helpers, conformance suite) tracked in go/CHANGELOG.md.
  • SAT-based predicate implication checking. Z3-driven semantic predicate analysis landed in v0.4.x.
  • Migration-as-input. pgrls diff --apply migration.sql shipped in v0.5.0; baseline cache + extension auto-detect in v0.5.1–v0.5.2.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pgrls-0.11.0.tar.gz (966.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pgrls-0.11.0-py3-none-any.whl (370.0 kB view details)

Uploaded Python 3

File details

Details for the file pgrls-0.11.0.tar.gz.

File metadata

  • Download URL: pgrls-0.11.0.tar.gz
  • Upload date:
  • Size: 966.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pgrls-0.11.0.tar.gz
Algorithm Hash digest
SHA256 cf7e373625b302f2cf0ee887a6402354985e053262b1dd99a8e2e2e4605773d8
MD5 602abfa6398f4dc289b7eb709bc8ed5d
BLAKE2b-256 baad0e717372954a00becfcbd9d2b52c0693a3319a75f984d3f69b3acd6faac5

See more details on using hashes here.

Provenance

The following attestation bundles were made for pgrls-0.11.0.tar.gz:

Publisher: publish.yml on pgrls/pgrls

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pgrls-0.11.0-py3-none-any.whl.

File metadata

  • Download URL: pgrls-0.11.0-py3-none-any.whl
  • Upload date:
  • Size: 370.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pgrls-0.11.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a71e76cbd9dd67e9a90faf575a15f88e69e151c5ad1919c5105fb852e6c4d7fe
MD5 644606e05fff886c33ab6c1598973114
BLAKE2b-256 62eb3700125782344975c9f85a8a6fee04923e0f9d8da449ebccba29db863401

See more details on using hashes here.

Provenance

The following attestation bundles were made for pgrls-0.11.0-py3-none-any.whl:

Publisher: publish.yml on pgrls/pgrls

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page