Skip to main content

Schema-driven Row-Level Security test matrix generator and cross-tenant fuzzer for PostgreSQL and Supabase.

Project description

rlsgrid

Schema-driven Row-Level Security test matrix generator and cross-tenant fuzzer for Postgres and Supabase. Point it at your database, get back a role × table × operation matrix, a pgTAP suite, and a fuzz harness that actively tries to leak one tenant's rows into another tenant's session.

License: MIT Python Status

Built by Matteo Perino while shipping GeoSuite, a multi-tenant Supabase app with agency/account/store isolation layered on top of Postgres RLS. The library is extracted from the patterns that survived production audits.

rlsgrid plan and fuzz output


The problem

Postgres RLS is one of the most powerful access-control primitives in the ecosystem, and one of the easiest to get subtly wrong. A missing WITH CHECK, a FOR ALL policy where you wanted FOR SELECT, a forgotten ENABLE ROW LEVEL SECURITY, or a service_role bypass that quietly leaks into a client surface — every one of these is a tenant-isolation incident waiting to happen, and unit tests against your application code will not catch them.

The existing tools split into three camps:

Tool What it does What it doesn't do
usebasejump/supabase-test-helpers pgTAP helpers you write by hand. Generates nothing.
pgrls Static linter (43 rules). Doesn't execute policies.
supashield Pentest-style scanner from outside. Doesn't read your schema.
AI-based generators LLM writes pgTAP for you. Hallucinates, opaque, drifts.

rlsgrid fills the missing slot: read your real schema, classify what every role can do, emit deterministic tests, then chaos-fuzz for cross-tenant leaks at runtime.

Install

pip install rlsgrid

Quickstart

# 1. Generate a starter config.
rlsgrid init

# 2. Point DATABASE_URL at your DB.
export DATABASE_URL=postgresql://user:pw@host/db

# 3. See what rlsgrid found.
rlsgrid introspect

# 4. See the matrix — every role × table × op cell with expected outcome.
rlsgrid plan

# 5. Emit a pgTAP suite covering every ALLOW/DENY cell.
rlsgrid gen pgtap --out tests/rls/generated.sql
pg_prove -d "$DATABASE_URL" tests/rls/generated.sql

# 6. Chaos-fuzz cross-tenant SELECT leaks.
rlsgrid fuzz --tenants 5

What you get

The matrix

┌────────────────┬─────────────────┬────────┬──────────────┬───────────────────┐
│ Role           │ Table           │ Op     │ Expected     │ Policies          │
├────────────────┼─────────────────┼────────┼──────────────┼───────────────────┤
│ authenticated  │ public.posts    │ SELECT │ conditional  │ posts_owner_all   │
│ authenticated  │ public.posts    │ INSERT │ conditional  │ posts_owner_all   │
│ anon           │ public.posts    │ SELECT │ deny         │ —                 │
│ service_role   │ public.posts    │ SELECT │ unrestricted │ — (BYPASSRLS)     │
└────────────────┴─────────────────┴────────┴──────────────┴───────────────────┘

Every cell is classified by reading pg_policies + pg_class:

  • allow — at least one permissive policy applies and gates nothing.
  • deny — RLS is enabled and no permissive policy matches the role/op.
  • conditional — a policy applies but a USING / WITH CHECK expression gates which rows. Runtime check needed (this is where chaos-fuzz comes in).
  • unrestricted — RLS is off or the role has BYPASSRLS. Surfaced explicitly so you notice when you didn't mean it.

The pgTAP suite

For every ALLOW / DENY cell, rlsgrid emits a probe that exercises the policy without touching real rows:

SET LOCAL ROLE "anon";
SELECT throws_ok(
  $rlsgrid$ SELECT * FROM "public"."posts" LIMIT 0 $rlsgrid$,
  '42501', NULL,
  'anon cannot SELECT public.posts'
);
RESET ROLE;

CONDITIONAL cells aren't asserted at the pgTAP layer — that's chaos-fuzz's job, because the truth requires real per-tenant rows.

The chaos fuzzer

rlsgrid fuzz seeds N synthetic tenants — walking the FK graph in topological order so child rows reference their tenant's parent rows — then repeatedly picks (actor, target, cell) triples drawn from the CONDITIONAL and ALLOW cells of the matrix and runs four probes against the target's data while the actor's session is active:

Probe What it asks
SELECT Can the actor see any of target's rows?
INSERT Can the actor write a row stamped with the target's tenant id?
UPDATE Can the actor mutate a row owned by the target (by PK)?
DELETE Can the actor delete a row owned by the target (by PK)?

Every probe runs in its own transaction that rolls back on completion, so the database state never moves between iterations.

✗ 1 breach(es) detected
  LEAK role=authenticated actor_tenant=a1b2 → target_tenant=c3d4
       on public.posts SELECT: 3 rows visible across tenants

Function mode

When access is not enforced by RLS but by a backend helper — the GeoSuite pattern, where check_user_has_access_to_store(user_id, store_id) is the final gate — set tenancy.mode = "function" and point access_function at the helper with {user_id} and {row_id} placeholders:

[tenancy]
mode = "function"
access_function = "check_user_has_access_to_store({user_id}, {row_id})"

The fuzz harness then iterates every (actor, target_row) pair across the seeded tenants and calls the helper with cross-tenant arguments. If it ever returns true, that is a breach with the same Breach shape as the RLS-mode probes.

JWT shape

Modern Supabase (v2+) stores every claim in a single GUC, request.jwt.claims, as JSON — that's what auth.jwt() reads. Older deployments set one GUC per claim (request.jwt.claim.sub, etc.). rlsgrid defaults to the modern shape; override per project:

[tenancy]
jwt_shape = "json"  # or "individual"
jwt_claims = { sub = "{user_id}", tenant_id = "{tenant_id}", role = "authenticated" }

Both {user_id} and {tenant_id} are filled per actor before each probe.

Prod-guard

The write-capable commands (seed, fuzz) refuse to run when the URL matches any pattern in [safety].forbid_url_patterns. The default list is ["prod", "production"]. To override on purpose, set RLSGRID_I_KNOW_WHAT_IM_DOING=1 — the awkward name is intentional.

JSON output for CI

Every read command (introspect, plan) and every write command (seed, fuzz) accepts --json. The shape is stable enough to drive PR-comment bots, dashboards, or threshold gates without parsing terminal output.

rlsgrid fuzz --json | jq '.breaches | length'

Configuration

Per-stack starting configs (Supabase, Prisma, Drizzle, SQLAlchemy, Rails, and function mode) live in docs/RECIPES.md.

rlsgrid.toml lives at your repo root. The interesting part is the [tenancy] block, which tells rlsgrid how isolation is supposed to work:

[tenancy]
# Supabase-classic: policies read auth.uid() from the JWT.
mode = "jwt"
tenant_column = "tenant_id"
auth_function = "auth.uid()"

# Or: access delegated to a SQL helper (e.g. GeoSuite-style).
# mode = "function"
# access_function = "check_user_has_access_to_store(p_user_id, p_store_id)"

The two modes exist because real production schemas don't all look like the Supabase quickstart. The function-based mode covers the pattern where the application layer asks Postgres "does this user have access to this row?" via a stable function — rlsgrid will still build the matrix and the fuzz will still find cross-tenant leaks.

Verified-RLS badge

fuzz can also write a status badge so projects can advertise that they test cross-tenant isolation in CI. Two formats:

# shields.io endpoint JSON — no asset hosting, commit the JSON file
rlsgrid fuzz --shields-out badge.json

# Static SVG — host wherever you like
rlsgrid fuzz --badge-out badge.svg

Embed in your project README:

[![rlsgrid](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/USER/REPO/main/badge.json)](https://github.com/USER/REPO)

The badge turns green when the fuzz run reports no breaches and red as soon as one shows up.

When to reach for rlsgrid vs the alternatives

usebasejump/supabase-test-helpers is the de-facto Supabase RLS testing toolkit. It ships a set of pgTAP helpers (tests.authenticate_as, tests.rls_enabled, …) so you can hand-write assertions like:

SELECT tests.authenticate_as('alice');
SELECT results_eq(
  'SELECT count(*) FROM posts',
  ARRAY[1::bigint],
  'alice sees only her own posts'
);

That style is excellent when you have a small fixed surface and want to encode bespoke business rules. It struggles when the surface grows: every new role, table or operation is another pgTAP file you write and maintain by hand, and you do not get told about regressions until someone remembers to add the test.

rlsgrid solves the other half of the problem:

supabase-test-helpers rlsgrid
Style Hand-written pgTAP Schema-driven generation + runtime fuzz
New table lands without a test Silent Surfaces immediately in plan / gen pgtap
Cross-tenant write leaks Whatever you remember to test Probed automatically (fuzz SELECT/INSERT/UPDATE/DELETE)
Function-based access checks Not modelled First-class (tenancy.mode = "function")
CI integration You wire it One-step GitHub Action with JSON output
Setup cost Low Low (single rlsgrid.toml)
Best for Encoding "this specific user must see exactly these rows" Catching the broad class of "we forgot to lock this down"

They compose well: keep your high-signal supabase-test-helpers cases for the business rules you care about most, and let rlsgrid watch the floor.

GitHub Action

The repo ships a composite GitHub Action so dropping rlsgrid into a CI workflow is one step:

- uses: matte97p/rlsgrid@v1
  with:
    command: fuzz
    config: rlsgrid.toml
    database-url: ${{ secrets.STAGING_DB_URL }}
    fail-on-breach: true

Inputs: command (introspect/plan/gen-pgtap/seed/fuzz), config, database-url (required), tenants, python-version, version (pin a rlsgrid release), pgtap-out, fail-on-breach.

Outputs: result-json (path to JSON report), breach-count.

Full example workflow that gates a PR on cross-tenant leaks against a disposable Postgres service:

name: rls-fuzz
on:
  pull_request:
    paths: ["supabase/migrations/**", "rlsgrid.toml"]
jobs:
  fuzz:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd="pg_isready -U postgres"
          --health-interval=5s --health-timeout=5s --health-retries=10
    steps:
      - uses: actions/checkout@v4
      - run: psql "$PG" -f supabase/migrations/*.sql
        env:
          PG: postgresql://postgres:postgres@localhost:5432/postgres
      - uses: matte97p/rlsgrid@v1
        with:
          command: fuzz
          database-url: postgresql://postgres:postgres@localhost:5432/postgres
          tenants: "5"

Pin to a specific release in production (matte97p/rlsgrid@v0.1.0).

Status

Alpha. Stable enough to run on real schemas; the pgTAP output and config shape may shift before 1.0. Issues and PRs welcome — see CONTRIBUTING.

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

rlsgrid-0.1.2.tar.gz (50.7 kB view details)

Uploaded Source

Built Distribution

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

rlsgrid-0.1.2-py3-none-any.whl (35.7 kB view details)

Uploaded Python 3

File details

Details for the file rlsgrid-0.1.2.tar.gz.

File metadata

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

File hashes

Hashes for rlsgrid-0.1.2.tar.gz
Algorithm Hash digest
SHA256 f1c824e518b3ede544a53353f57c4a0d85fb53a7b8277d6fd98599dd78bf5393
MD5 37ce93b69c33dc1198d15bb3735ca0ab
BLAKE2b-256 7dbba9ba677381d2520e635c7ab99d553d512cee38296e907f605938f0ee5242

See more details on using hashes here.

Provenance

The following attestation bundles were made for rlsgrid-0.1.2.tar.gz:

Publisher: release.yml on matte97p/rlsgrid

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

File details

Details for the file rlsgrid-0.1.2-py3-none-any.whl.

File metadata

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

File hashes

Hashes for rlsgrid-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 a6803a966b027fd95c431446b31756d0d22abf6c8b5022e4e9267ea05d7e9bff
MD5 c199c4c853561601f1c573f9b290af7e
BLAKE2b-256 0ad3b144eb3850a89912b4aa4960be51323099b5d3420d94b5bf2770765b0555

See more details on using hashes here.

Provenance

The following attestation bundles were made for rlsgrid-0.1.2-py3-none-any.whl:

Publisher: release.yml on matte97p/rlsgrid

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