# tenaxum
[](https://crates.io/crates/tenaxum)
[](https://docs.rs/tenaxum)
[](https://opensource.org/licenses/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.
```rust
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:
```rust
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:
```rust
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:
```rust
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](https://github.com/anfocic/tenaxum/tree/main/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](https://github.com/anfocic/tenaxum/tree/main/examples/rls/tests) are how I keep it honest.
## License
MIT.