pantheon-rls
Tenant isolation as a Postgres guarantee — force-RLS + least-privilege grants, as reusable SQL. Zero required dependencies.
The guarantee: tenant A cannot read or write tenant B's rows — enforced by the database, not by a
WHERE tenant_id = ?you have to remember on every query. Forget one query and you leak; with this, the boundary is in the engine, below the app.
Extracted from PANTHEON, a multi-tenant AI substrate, where it's the isolation boundary under every scoped table. Companion to pantheon-credit-ledger (which uses exactly this).
What it does
Three statements per scoped table make isolation real:
from pantheon_rls import enable_rls_sql, grant_crud_sql, bind_tenant
# 1. Once, in your migration, as the privileged (table-owning) role:
for stmt in enable_rls_sql("invoices"):
conn.execute(stmt)
conn.execute(grant_crud_sql("invoices", "app_role")) # your NON-superuser runtime role
# 2. Per request, on your app connection:
bind_tenant(session, tenant_id) # every statement after this sees only this tenant's rows
enable_rls_sql emits ENABLE + FORCE row-level security + a fail-closed policy:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation ON invoices
USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid);
The two things people get wrong
FORCE, not justENABLE. WithoutFORCE, the table owner bypasses the policy — so your migration/maintenance connection (and often your app, if it owns the tables) silently sees everything.FORCEsubjects the owner too.- A superuser /
BYPASSRLSrole ignores RLS entirely. So the app must connect at runtime as aNOSUPERUSER NOBYPASSRLSrole; migrations use the privileged one. Miss this and every dev box (usually superuser) looks perfectly isolated while production leaks.grant_crud_sqlexists to point the four CRUD verbs at that non-privileged role.
And it's fail-closed: current_setting(name, true) is NULL when unset, NULLIF maps '' → NULL, and col = NULL is never true — so no tenant context means zero rows, never "all rows".
Safe to call with non-constant identifiers
Table / column / role / GUC names are validated to be plain [schema.]identifiers (anything else raises ValueError), and the tenant id at bind time is passed as a bind parameter, never interpolated — so these helpers don't become a SQL-injection vector even if a name comes from config.
Install
pip install pantheon-rls # SQL helpers only, zero dependencies
pip install "pantheon-rls[sqlalchemy]" # + bind_tenant() convenience (else run set_config with your own driver)
# or copy the single pantheon_rls.py file
Scope
This is the isolation primitive — the RLS DDL + grant helpers + the per-transaction tenant bind. It is not an ORM, a migration tool, or a full multi-tenancy framework; it's the ~60 lines that make Postgres itself refuse cross-tenant access, done right (FORCE + non-privileged role + fail-closed).
Changelog
- 0.1.1 — identifiers are now double-quoted in the emitted DDL, so a reserved-word table or role (
order,user) is valid SQL instead of a syntax error. Validation is unchanged (it already forbids the"needed to break out — quoting is not the injection guard, it just makes reserved words work). Names are quoted verbatim, so pass identifiers in the exact case they exist. - 0.1.0 — initial release.
License
Apache-2.0. See LICENSE.
Release files for pantheon-rls 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pantheon_rls-0.1.1.tar.gz | 9.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pantheon_rls-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 19.9 kB
Release files / pantheon_rls-0.1.1.tar.gz
| Download URL | pantheon_rls-0.1.1.tar.gz |
|---|---|
| Size | 9.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9f255724f067034cc47f4086c766c5125fd4e87c894ef60c4e059c3318be3f37
|
|
BLAKE2b-256 checksum How to use checksums |
e08638ea30a5b8988845b59b3243c768dffc35ccf1bd1420d8e4c9eccfdb3643
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 13, 2026.
Transparency logRelease files / pantheon_rls-0.1.1-py3-none-any.whl
| Download URL | pantheon_rls-0.1.1-py3-none-any.whl |
|---|---|
| Size | 10.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9baf869fa3ce43d268a11ee7941e314ad5a19a9a86dc8608ea6d51083e01545b
|
|
BLAKE2b-256 checksum How to use checksums |
3688e6313a1a6846ba2d60252e0c853c5372e2bf37551389be58d6ba6454b396
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 13, 2026.
Transparency log