Skip to main content

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.

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 tableaccount_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.