Multi-tenancy & auth
Physical dir-per-tenant isolation with a process-per-tenant model, a verify-only JWT tenant guard, and the forgedb tenant CLI — plus the honest single-tenant-per-process limit.
ForgeDB's multi-tenancy is physical: one tenant per data directory, one server process per tenant, N processes behind a plain host/subdomain proxy. Generated code stays tenant-oblivious — it opens the data root it is handed. A verify-only JWT guard checks that the caller's token names the tenant this process serves.
The model: process-per-tenant#
Each tenant is its own data directory. A process is pointed at one root and serves only that tenant; the multiplexer is a reverse proxy out in front, not code inside ForgeDB. Turning tenancy on or off changes nothing about the generated code.
The scaffolded main.rs is an env-driven server. Resolve the tenant, data root, and JWT config once, and it feeds both the data-directory open and the auth cross-check:
# run the compiled server binary (e.g. ./target/release/<app>), not a forgedb subcommand
FORGEDB_TENANT=acme \
FORGEDB_DATA=/srv/tenants/acme \
FORGEDB_JWT_ISSUER=https://auth.example.com \
FORGEDB_JWT_AUDIENCE=forgedb \
./target/release/myappThere is no in-process registry demultiplexing rows by tenant id. The process opens one root, and every column, tombstone, and manifest path hangs off it, threaded through the generated Database::open_at(root) (new() is byte-identical to open_at(".")). That is the entire isolation boundary: two tenants can never collide because they never share a directory, a lock, or a process. An in-process multi-tenant registry would be a strict superset built on this same open_at seam — deferred, not designed out.
The tenant guard — verify-only JWT#
When configured, the forgedb-auth substrate crate wraps every REST and WebSocket data route. It verifies the caller's asymmetric JWT, extracts the configured tenant claim, and cross-checks it against this process's tenant — a mismatch is a 403.
The guard is a schema-agnostic axum extractor and middleware. It verifies an asymmetric JWT — JWKS or static PEM, algorithm-pinned — checking exp/nbf/iss/aud with a clock-skew leeway. It then extracts the configured tenant claim and compares it to this process's tenant as opaque string equality: equal passes, unequal is a 403. On success it injects an opaque Principal for downstream handlers. It decodes no field and knows nothing of models or rows — the same class of substrate as the change feed.
Ops routes stay unauthenticated
The tenant guard wraps only the data routes. The ops routes (/health, /ready, /metrics,
/snapshot) are merged in after the guard, so k8s/load-balancer probes need no JWT.
See tenant & auth configuration for the [tenant] / [auth] config tables (these live in forgedb.toml, never in your .forge schema).
The forgedb tenant CLI#
Manage the per-tenant data directories under a root:
forgedb tenant create acme
forgedb tenant list
forgedb tenant drop acmeLimits#
- Single tenant per process. A cross-tenant read is a fan-out across processes; there is
no in-process registry (that model is a deferred strict superset on the same
open_atseam). - WebSocket clients must send the token in the
Authorizationheader (there is no query-param token path). - JWKS-over-HTTP fetch is not yet wired into the scaffold — the crate parses JWKS offline, and static PEM is the wired path today.
- Verify-only: no token issuance and no per-principal (RLS-style) authorization.
Migrations sweep all tenants under a root in one command — see Migrations.