> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kataven.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-tenancy

> How accounts, databases, and the X-Account-ID header fit together.

Kataven separates tenants by **database isolation**, not row-level
filtering.

## One account = one database

Each account has a dedicated PostgreSQL database. The account slug
(`acme`) and the database name are the same string.

There's no `account_id` column in any per-account table because there
can be no cross-tenant query — the database is the boundary.

## How requests get routed

Every protected request carries an `X-Account-ID` header. The handler:

1. Reads the header.
2. Validates the slug against `^[a-z0-9][a-z0-9_-]{0,62}$`.
3. Calls `GetDBForAccount(slug)`, which returns a pooled
   `*sql.DB` for the per-account database (DSN:
   `postgres://user:pw@host:port/<slug>`).
4. Runs the query on that connection.

If the header is missing → `400`. If the slug fails validation or the
database doesn't exist → `400 "Invalid account"`.

## Where the header comes from

Two paths set `X-Account-ID`:

1. **Explicit** — the client sends it (the SDKs do this with the
   `account_id` you configure).
2. **Auto-injected from JWT claims** — the auth middleware extracts
   the account slug from the bearer token's Zitadel org claims and
   sets the header before passing to handlers.

Both paths converge on the same handler-side header read. The auto-
injection path means a logged-in user doesn't need to know their own
account slug — the JWT carries it.

## Reserved slug

`kataven-admin` is rejected with `403`. It's a system administration
account, not a tenant.

## Why this design

* **Hard isolation.** A bug that mis-routes a query just gets you a
  different DB connection — no possibility of seeing another
  tenant's row.
* **Per-tenant migrations.** Schema changes can roll out per-account.
* **Per-tenant performance isolation.** A tenant doing a slow query
  only stresses their own database.
* **Backup / restore is a tenant-level operation.** Restoring one
  account doesn't touch any other.

The trade-off is connection pool count — one pool per active account.
The current `accounts.go` pool manager caches and reaps idle pools to
keep this manageable.

## What lives in the system DB

A separate `system` database holds the **accounts table** —
`account_id`, `account_name`, `database_name`, `status`. The public
`/api/accounts/validate/{account}` endpoint reads from this DB to
answer "does this account exist and is it active?" before any tenant
DB lookup.
