Skip to main content

Crate tenaxum

Crate tenaxum 

Source
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:

  1. pool — request-scoped pool hooks for the common Axum/sqlx path
  2. PgPoolExt / set_tenant — explicit transaction-scoped binding for jobs, scripts, or admin paths
  3. audit — boot-time and CI-time checks for common RLS mistakes

§Minimum safe setup

  1. Connect as a non-superuser Postgres role.
  2. Add ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY to your tenant-scoped tables.
  3. Use either pool::with_tenant_hooks + pool::tenant_scope or PgPoolExt::begin_tenant / set_tenant on every DB path.
  4. Run audit::ensure_isolation at 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.
  • TenantId insertion. For request-scoped usage, your middleware inserts TenantId into request extensions before pool::tenant_scope runs.
  • RLS policy SQL. Your migrations still define the actual USING / WITH CHECK predicates.
  • Non-request wiring. Jobs, scripts, queue consumers, and spawned tasks still need PgPoolExt::begin_tenant, set_tenant, or pool::spawn_with_tenant on 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:

  1. Wrap your pool builder with pool::with_tenant_hooks.
  2. Insert TenantId only after auth has resolved the correct tenant for the caller.
  3. Add pool::tenant_scope to the request path before handlers that touch tenant-scoped data.
  4. Use pool::spawn_with_tenant for spawned child tasks, and PgPoolExt::begin_tenant / set_tenant for jobs and scripts.
  5. Add ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY, and a correct tenant policy to every tenant-scoped table.
  6. Connect as a non-superuser role in every environment.
  7. Run audit::ensure_isolation at boot and fail closed on findings.
  8. 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 TenantId after auth has verified membership.
  • A DB path bypasses the integration. Mitigation: wrap the pool once at construction time, use pool::tenant_scope consistently, 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 run audit::ensure_isolation on 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_migrations equivalent to a live-schema audit.
  • It does not authenticate users or derive tenant identity for you. It assumes the TenantId you 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.

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/rls crate in this repo for the full pattern, including the FORCE ROW LEVEL SECURITY + non-superuser-role + WITH CHECK gotchas.
  • 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, public schema, tenant_id column).
TenantId
A tenant identifier, serialized to a string.

Traits§

PgPoolExt
Extension trait on sqlx::PgPool adding tenant-scoped transaction helpers using the default Tenancy.

Functions§

set_tenant
Set the default GUC (app.tenant_id) on an open transaction via SET LOCAL.