Tenant-isolation helpers for Axum + sqlx + Postgres apps that use row-level security for multi-tenancy.
| your stack | works? |
|---|---|
| Axum 0.8 + sqlx 0.8 + Postgres + RLS | yes — drop-in |
same, but tenant ID is i64 / slug / String |
yes — TenantId::from(your_id) |
same, but GUC name is app.org_id, schema is app, column is org_id |
yes — Tenancy::new().guc("app.org_id").schema("app").tenant_column("org_id") |
| Actix / warp / Rocket | the pool and audit modules are framework-agnostic; you'll write your own middleware (≈ 15 LOC) |
| Diesel, SeaORM, or any non-sqlx | no — sqlx-specific |
| MySQL, SQLite, anything not Postgres | no — RLS is a Postgres feature |
| application-level isolation (no RLS) | no — this crate is specifically for RLS |
TenantId—String-wrapping newtype with an Axum extractor that reads from a requestExtension. Construct from anything viaFrom<Uuid>,From<i64>,From<&str>, etc. Your auth layer fills the extension in.- Pool builder + middleware that scope a Postgres GUC (default
app.tenant_id) across a request's async call chain automatically. For background jobs and admin paths,pool.begin_tenant(&tenant)does the same per-transaction. Tenancyconfig struct —.guc(...),.schema(...),.schemas(...),.tenant_column(...). Defaults toapp.tenant_id,public,tenant_id; override any to fit your codebase.audit::ensure_isolation(&pool)— boot-time invariant check. Reports tables with RLS-on-but-no-policy, policies missingWITH CHECK, fail-openCOALESCEpatterns, and tenant-tagged tables with no policy.audit::scan_migrations(path)is a lightweight CI lint that runs without a database and flagsCREATE POLICYstatements missingWITH CHECK.Tenancy::policy_template(table)— emits the canonicalENABLE/FORCE/CREATE POLICY ... USING (...) WITH CHECK (...)SQL sourced from yourTenancyconfig. The output is guaranteed to passensure_isolationclean, so you can stop hand-writing (and mis-writing) policy DDL in migrations..cast("uuid")for non-text tenant columns.
JWT decoding and scope/permission middleware are deliberately out of scope — every app gets those wrong differently.
- Authentication and tenant resolution logic.
TenantIdinsertion into request extensions beforetenant_scope.- The actual RLS policy SQL in your migrations — or call
Tenancy::policy_template(table).cast("uuid").full_sql()to emit the canonical shape and paste that into the migration. - Explicit tenant binding on jobs, scripts, and spawned tasks outside the request path.
use sqlx::postgres::PgPoolOptions;
use pg_rls::pool;
let pool = pool::with_tenant_hooks(PgPoolOptions::new().max_connections(8))
.connect(&database_url).await?;
// On boot, refuse to start if the schema is broken:
let report = pg_rls::audit::ensure_isolation(&pool).await?;
assert!(report.is_clean(), "RLS invariants broken:\n{report}");If your stack uses different names:
use pg_rls::Tenancy;
let tenancy = Tenancy::new()
.guc("app.org_id")
.schema("app")
.tenant_column("org_id");
let pool = tenancy
.with_tenant_hooks(PgPoolOptions::new())
.connect(&database_url).await?;
let report = tenancy.ensure_isolation(&pool).await?;- Wrap your pool with
pool::with_tenant_hooks(...). - Insert
TenantIdonly after auth has resolved the correct tenant. - Add
pool::tenant_scopeon request paths that hit tenant data. - Use
pool::spawn_with_tenantfor spawned child tasks andbegin_tenant/set_tenantfor jobs. - Add
ENABLE,FORCE, and a correct tenant policy to every tenant-scoped table. - Connect as a non-superuser role.
- Run
pg_rls::audit::ensure_isolation(&pool)at boot. - Keep the smoke path passing:
cargo test -p pg-rls --test smoke.
examples/rls covers the three RLS gotchas — FORCE ROW LEVEL SECURITY, non-superuser app role, and WITH CHECK — with three integration tests. One asserts the bug passes (leak demo); the others assert each fix.
docker compose up -d
export DATABASE_URL=postgres://mtap_app:mtap_app@localhost:5433/mtap
cargo test -p pg-rls-exampleSee SECURITY.md. Tenant-isolation bugs in this crate are in scope; bugs in your auth layer or your RLS policies are not.
- Wrong tenant resolved by auth:
pg-rlswill enforce whateverTenantIdyour auth layer supplies. - A DB path bypasses the integration:
raw pools, missing
tenant_scope, plaintokio::spawn, and unscoped jobs can all skip the intended model. - Broken RLS policy or deployment config:
missing
FORCE, disabled RLS, superuser roles, or semantically wrong predicates are still app/database failures. - Side systems ignore the same contract: workers, scripts, or other services touching the same DB need the same tenancy discipline.
See crates/pg-rls/CHANGELOG.md.
MIT.