Expand description
§tenaxum
Tenant-scoped helpers for Axum + sqlx + Postgres. Tenacious about row-level isolation.
tenaxum exists for one narrow job: carry a tenant identifier from
your Rust request/job context into Postgres so row-level security
policies can enforce tenant isolation at the database boundary.
The crate has three main pieces:
pool— request-scoped pool hooks for the common Axum/sqlx pathPgPoolExt/set_tenant— explicit transaction-scoped binding for jobs, scripts, or admin pathsaudit— boot-time and CI-time checks for common RLS mistakes
§Minimum safe setup
- Connect as a non-superuser Postgres role.
- Add
ENABLE ROW LEVEL SECURITYandFORCE ROW LEVEL SECURITYto your tenant-scoped tables. - Use either
pool::with_tenant_hooks+pool::tenant_scopeorPgPoolExt::begin_tenant/set_tenanton every DB path. - Run
audit::ensure_isolationat boot and fail closed if it returns findings.
§Quick start
use axum::{middleware, Router};
use sqlx::postgres::PgPoolOptions;
use tenaxum::{audit, pool};
let pool = pool::with_tenant_hooks(PgPoolOptions::new().max_connections(8))
.connect("postgres://...").await?;
let report = audit::ensure_isolation(&pool).await?;
if !report.is_clean() {
panic!("RLS invariants broken at boot:\n{report}");
}
let app = Router::new()
.route("/", axum::routing::get(|| async { "ok" }))
.layer(middleware::from_fn(pool::tenant_scope))
.with_state(pool);Your auth layer inserts TenantId into request extensions:
req.extensions_mut().insert(TenantId::from(...)). The middleware
scopes that value for the async call chain; the pool hooks read it and
set the configured Postgres GUC.
§What you still provide
tenaxum removes the repetitive tenant-plumbing around sqlx and
Postgres RLS, but it still assumes your app supplies four things:
- Authentication and tenant resolution. Your auth/session layer must decide which tenant the caller belongs to.
TenantIdinsertion. For request-scoped usage, your middleware insertsTenantIdinto request extensions beforepool::tenant_scoperuns.- RLS policy SQL. Your migrations still define the actual
USING/WITH CHECKpredicates. - Non-request wiring. Jobs, scripts, queue consumers, and spawned
tasks still need
PgPoolExt::begin_tenant,set_tenant, orpool::spawn_with_tenanton the DB paths that should be scoped.
§Adoption checklist
If you want the “install it and stop thinking about tenant plumbing” path, this is the checklist:
- Wrap your pool builder with
pool::with_tenant_hooks. - Insert
TenantIdonly after auth has resolved the correct tenant for the caller. - Add
pool::tenant_scopeto the request path before handlers that touch tenant-scoped data. - Use
pool::spawn_with_tenantfor spawned child tasks, andPgPoolExt::begin_tenant/set_tenantfor jobs and scripts. - Add
ENABLE ROW LEVEL SECURITY,FORCE ROW LEVEL SECURITY, and a correct tenant policy to every tenant-scoped table. - Connect as a non-superuser role in every environment.
- Run
audit::ensure_isolationat boot and fail closed on findings. - Keep the smoke path passing:
cargo test -p tenaxum --test smoke.
§Async model
Tenant binding is stored in a Tokio task-local. That means it flows
through ordinary async calls, but does not automatically cross
tokio::spawn boundaries. For spawned child tasks, use
pool::spawn_with_tenant or manually wrap the future with
pool::scope_tenant.
use axum::extract::State;
use tenaxum::pool;
async fn fan_out(State(pool): State<sqlx::PgPool>) -> sqlx::Result<()> {
let child_pool = pool.clone();
pool::spawn_with_tenant(async move {
sqlx::query("SELECT 1").execute(&child_pool).await
})
.await??;
Ok(())
}§Audit model
audit::ensure_isolation is the real safety check: it inspects the
live schema and reports common tenant-isolation mistakes.
audit::scan_migrations is intentionally weaker. It is a
lightweight CI lint for obvious CREATE POLICY ... omissions, not a
full SQL parser and not a security guarantee.
§Failure modes and mitigations
The common places an app can still fail are:
- Wrong tenant resolved by auth.
Mitigation: keep tenant resolution in one place, test it directly,
and only insert
TenantIdafter auth has verified membership. - A DB path bypasses the integration.
Mitigation: wrap the pool once at construction time, use
pool::tenant_scopeconsistently, and treat jobs/spawned tasks as first-class integration points rather than exceptions. - Broken RLS policy or deployment config.
Mitigation: use
FORCE, avoid superuser roles, and runaudit::ensure_isolationon boot. - Side systems ignore the same contract. Mitigation: make workers, scripts, and maintenance paths use the same non-superuser role and the same tenant-binding helpers.
§What tenaxum cannot guarantee
- It does not prove your RLS predicates are semantically correct. A syntactically valid policy can still be wrong.
- It does not make superuser connections safe. Postgres superusers bypass RLS unconditionally.
- It does not make
audit::scan_migrationsequivalent to a live-schema audit. - It does not authenticate users or derive tenant identity for
you. It assumes the
TenantIdyou provide is already correct.
§Configuration
Every assumption is configurable through Tenancy — the GUC name
(app.tenant_id), schema list (public), and tenant-column name
(tenant_id) all default to common values, and any can be overridden.
Tenancy::default reproduces the v0.1 behaviour.
§Pattern 1 — pool-scoped (recommended for production apps)
Set the GUC once when a connection is checked out of the pool, reset it on release. Every query in the scoped async call chain is auto-isolated. Zero per-call-site boilerplate.
See pool for the pool::with_tenant_hooks free fn (default
config) and Tenancy::with_tenant_hooks for the configured form, plus
the pool::tenant_scope Axum middleware.
§Pattern 2 — explicit begin_tenant
Open a transaction and SET LOCAL the GUC inside it. Useful for
background jobs, one-off scripts, or admin paths where the pool hooks
aren’t wired.
See PgPoolExt::begin_tenant (default config), Tenancy::begin_tenant
(configured), and set_tenant / Tenancy::set_tenant.
§Pattern 3 — boot-time invariant audit
Refuse to start the app on a broken schema. See audit::ensure_isolation
/ Tenancy::ensure_isolation and audit::scan_migrations /
Tenancy::scan_migrations.
§What tenaxum deliberately does not do
- JWT decoding. Every app does this differently. Decode in your own
middleware, then
req.extensions_mut().insert(TenantId::from(...)). - RLS policy generation. The policy is one line of SQL; see the
examples/rlscrate in this repo for the full pattern, including theFORCE ROW LEVEL SECURITY+ non-superuser-role +WITH CHECKgotchas. - Scope/permission middleware. Maybe later, once the design has been battle-tested in a real codebase.
Modules§
- audit
- Boot-time invariant checks for tenant isolation.
- pool
- Pool-scoped tenant isolation: set the configured GUC (default
app.tenant_id) once when a connection is checked out of the pool, reset it when it goes back. Every query made in the scoped async call chain is auto-isolated, with no per-call-site boilerplate.
Structs§
- Tenancy
- Run-time tenancy configuration. Default values match v0.1 behaviour
(
app.tenant_id,publicschema,tenant_idcolumn). - Tenant
Id - A tenant identifier, serialized to a string.
Traits§
- PgPool
Ext - Extension trait on
sqlx::PgPooladding tenant-scoped transaction helpers using the defaultTenancy.
Functions§
- set_
tenant - Set the default GUC (
app.tenant_id) on an open transaction viaSET LOCAL.