Skip to main content

Tenant isolation as a Postgres guarantee — force-RLS + least-privilege grants, as reusable SQL.

Project description

pantheon-rls

tests PyPI Python License

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 just ENABLE. Without FORCE, the table owner bypasses the policy — so your migration/maintenance connection (and often your app, if it owns the tables) silently sees everything. FORCE subjects the owner too.
  • A superuser / BYPASSRLS role ignores RLS entirely. So the app must connect at runtime as a NOSUPERUSER NOBYPASSRLS role; migrations use the privileged one. Miss this and every dev box (usually superuser) looks perfectly isolated while production leaks. grant_crud_sql exists 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.1identifiers 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.

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

pantheon_rls-0.1.1.tar.gz (9.7 kB view details)

Uploaded Source

Built Distribution

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

pantheon_rls-0.1.1-py3-none-any.whl (10.1 kB view details)

Uploaded Python 3

File details

Details for the file pantheon_rls-0.1.1.tar.gz.

File metadata

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

File hashes

Hashes for pantheon_rls-0.1.1.tar.gz
Algorithm Hash digest
SHA256 9f255724f067034cc47f4086c766c5125fd4e87c894ef60c4e059c3318be3f37
MD5 3f321a0e2307149fc7842f6ca413ad0b
BLAKE2b-256 e08638ea30a5b8988845b59b3243c768dffc35ccf1bd1420d8e4c9eccfdb3643

See more details on using hashes here.

Provenance

The following attestation bundles were made for pantheon_rls-0.1.1.tar.gz:

Publisher: publish.yml on Igfray/pantheon-rls

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

File details

Details for the file pantheon_rls-0.1.1-py3-none-any.whl.

File metadata

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

File hashes

Hashes for pantheon_rls-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 9baf869fa3ce43d268a11ee7941e314ad5a19a9a86dc8608ea6d51083e01545b
MD5 964dcd5bae4424180a921a3ac070c12a
BLAKE2b-256 3688e6313a1a6846ba2d60252e0c853c5372e2bf37551389be58d6ba6454b396

See more details on using hashes here.

Provenance

The following attestation bundles were made for pantheon_rls-0.1.1-py3-none-any.whl:

Publisher: publish.yml on Igfray/pantheon-rls

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