Tenant isolation as a Postgres guarantee — force-RLS + least-privilege grants, as reusable SQL.
Project description
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).
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pantheon_rls-0.1.0.tar.gz.
File metadata
- Download URL: pantheon_rls-0.1.0.tar.gz
- Upload date:
- Size: 9.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
616197bb8d645edcd417e41027e1eac16bfd71242a2d1e9abd0dc09d35e990e1
|
|
| MD5 |
64369fcde9b78f817f2506f7ac814103
|
|
| BLAKE2b-256 |
d456001ed9b64365684529b58959770427020e71768653f8ba3abca82fe851bc
|
Provenance
The following attestation bundles were made for pantheon_rls-0.1.0.tar.gz:
Publisher:
publish.yml on Igfray/pantheon-rls
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pantheon_rls-0.1.0.tar.gz -
Subject digest:
616197bb8d645edcd417e41027e1eac16bfd71242a2d1e9abd0dc09d35e990e1 - Sigstore transparency entry: 2156746019
- Sigstore integration time:
-
Permalink:
Igfray/pantheon-rls@9c7d1caa99d2fd6bfeec20bc7a5ffe4fb16fa551 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Igfray
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9c7d1caa99d2fd6bfeec20bc7a5ffe4fb16fa551 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pantheon_rls-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pantheon_rls-0.1.0-py3-none-any.whl
- Upload date:
- Size: 9.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ce537b100344baa27fb1eeeb29fc6c79e91d71937b58ba245ff86627eeeaf9e
|
|
| MD5 |
64a09ffcaa3aac8ad83e97c92979875f
|
|
| BLAKE2b-256 |
931cdd90e42e4026669a4776ca2b59801e5b12722f26eb178f23911b4dad0e58
|
Provenance
The following attestation bundles were made for pantheon_rls-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on Igfray/pantheon-rls
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pantheon_rls-0.1.0-py3-none-any.whl -
Subject digest:
0ce537b100344baa27fb1eeeb29fc6c79e91d71937b58ba245ff86627eeeaf9e - Sigstore transparency entry: 2156746180
- Sigstore integration time:
-
Permalink:
Igfray/pantheon-rls@9c7d1caa99d2fd6bfeec20bc7a5ffe4fb16fa551 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Igfray
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9c7d1caa99d2fd6bfeec20bc7a5ffe4fb16fa551 -
Trigger Event:
push
-
Statement type: