tenaxum
Tenant-isolation helpers for Axum + sqlx + Postgres apps that use row-level security.
Minimum safe setup
- Connect as a non-superuser Postgres role.
- Add
ENABLE ROW LEVEL SECURITYandFORCE ROW LEVEL SECURITYon tenant tables. - Use
pool::with_tenant_hooks(...)+pool::tenant_scopefor request paths, orbegin_tenant/set_tenantfor explicit transaction paths. - Run
tenaxum::audit::ensure_isolation(&pool)at boot and fail closed if it reports findings.
Two patterns
1. Pool-scoped (recommended)
A configurable Postgres GUC (default app.tenant_id) is set once per connection checkout and reset on release. Every query in the request's async call chain is auto-isolated; handlers don't need any per-call ceremony.
use ;
use PgPoolOptions;
use pool;
let pool = with_tenant_hooks
.connect.await?;
let app = new
.route
.layer;
Your auth layer inserts Extension<TenantId>. The middleware picks it up and scopes a task-local. The pool hooks read the task-local on every checkout. If you fan work out with tokio::spawn, use tenaxum::pool::spawn_with_tenant(...) so the child task keeps the tenant binding.
What you still provide
- Authentication and tenant resolution.
TenantIdinsertion into request extensions beforetenant_scope.- The actual RLS policy SQL in your migrations.
- Explicit tenant binding for jobs, scripts, and spawned tasks that sit outside the request path.
Adoption checklist
- 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
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
tenaxum::audit::ensure_isolation(&pool)at boot. - Keep the smoke path passing:
cargo test -p tenaxum --test smoke.
2. Explicit begin_tenant
For background jobs and admin paths where the pool hooks aren't wired:
use ;
let tenant = from;
let mut tx = pool.begin_tenant.await?;
let rows: =
query_as
.fetch_all.await?;
tx.commit.await?;
Configuration
If your app uses a different GUC name, schema, or tenant-column name, build a Tenancy with the knobs and call its methods:
use Tenancy;
let tenancy = new
.guc
.schema
.tenant_column;
let pool = tenancy.with_tenant_hooks.connect.await?;
let report = tenancy.ensure_isolation.await?;
let mut tx = tenancy.begin_tenant.await?;
Tenant IDs are not restricted to UUIDs — TenantId wraps a String with From impls for Uuid, i32, i64, &str, etc. Bridge to your typed key at the call site.
Boot-time audit
Run tenaxum::audit::ensure_isolation at boot to refuse to start the app on a broken schema — RLS-on-but-no-policy, missing WITH CHECK, fail-open COALESCE(current_setting(...)), or a tenant_id column with no policy attached:
let report = ensure_isolation.await?;
if !report.is_clean
For CI without a database, tenaxum::audit::scan_migrations("migrations") is a lightweight lint that walks *.sql and flags obvious CREATE POLICY statements missing WITH CHECK. It is not equivalent to the live-schema audit and not a security guarantee.
Failure modes and mitigations
- Wrong tenant resolved by auth:
Keep tenant resolution centralized and tested;
tenaxumwill enforce whateverTenantIdyou hand it. - A DB path bypasses the integration:
Wrap the pool once, use
tenant_scopeconsistently, and treat jobs/spawned tasks as first-class integration paths. - Broken RLS policy or deployment config:
Use
FORCE, avoid superuser roles, and runensure_isolationat boot. - Side systems ignore the same contract: Make workers, scripts, and maintenance paths use the same role and tenant-binding helpers.
What's not in the crate
- JWT decoding — your auth layer handles that, then sets
Extension<TenantId>. - RLS policy SQL — see the examples for the
FORCE ROW LEVEL SECURITY+ non-superuser-role +WITH CHECKpattern. - Scope/permission middleware — maybe later.
How it was built
AI-authored under my direction. v0.1 didn't fit the codebase it was extracted from — the mismatch surfaced as soon as I tried to plug it in. What's on crates.io is the second draft. The proof tests are how I keep it honest.
License
MIT.