tenaxum 0.2.0

Tenant-scoped helpers for Axum + sqlx + Postgres. Tenacious about row-level isolation.
Documentation

tenaxum

crates.io docs.rs License: MIT

Tenant-isolation helpers for Axum + sqlx + Postgres apps that use row-level security.

Minimum safe setup

  1. Connect as a non-superuser Postgres role.
  2. Add ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY on tenant tables.
  3. Use pool::with_tenant_hooks(...) + pool::tenant_scope for request paths, or begin_tenant / set_tenant for explicit transaction paths.
  4. 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 axum::{middleware, Router};
use sqlx::postgres::PgPoolOptions;
use tenaxum::pool;

let pool = pool::with_tenant_hooks(PgPoolOptions::new().max_connections(8))
    .connect("postgres://...").await?;

let app = Router::new()
    .route(/* ... */)
    .layer(middleware::from_fn(pool::tenant_scope));

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.
  • TenantId insertion into request extensions before tenant_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

  1. Wrap your pool with pool::with_tenant_hooks(...).
  2. Insert TenantId only after auth has resolved the correct tenant.
  3. Add pool::tenant_scope on request paths that hit tenant data.
  4. Use spawn_with_tenant for spawned child tasks and begin_tenant / set_tenant for jobs.
  5. Add ENABLE, FORCE, and a correct tenant policy to every tenant-scoped table.
  6. Connect as a non-superuser role.
  7. Run tenaxum::audit::ensure_isolation(&pool) at boot.
  8. 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 tenaxum::{TenantId, PgPoolExt};

let tenant = TenantId::from(uuid);
let mut tx = pool.begin_tenant(&tenant).await?;
let rows: Vec<(uuid::Uuid, String)> =
    sqlx::query_as("SELECT id, body FROM notes")
        .fetch_all(&mut *tx).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 tenaxum::Tenancy;

let tenancy = Tenancy::new()
    .guc("app.org_id")
    .schema("app")
    .tenant_column("org_id");

let pool = tenancy.with_tenant_hooks(PgPoolOptions::new()).connect("postgres://...").await?;
let report = tenancy.ensure_isolation(&pool).await?;
let mut tx = tenancy.begin_tenant(&pool, &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 = tenaxum::audit::ensure_isolation(&pool).await?;
if !report.is_clean() {
    panic!("RLS invariants broken at boot:\n{report}");
}

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; tenaxum will enforce whatever TenantId you hand it.
  • A DB path bypasses the integration: Wrap the pool once, use tenant_scope consistently, and treat jobs/spawned tasks as first-class integration paths.
  • Broken RLS policy or deployment config: Use FORCE, avoid superuser roles, and run ensure_isolation at 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 CHECK pattern.
  • 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.