tenaxum 0.2.0

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

[![crates.io](https://img.shields.io/crates/v/tenaxum.svg)](https://crates.io/crates/tenaxum)
[![docs.rs](https://docs.rs/tenaxum/badge.svg)](https://docs.rs/tenaxum)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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.