tenant-kit
Synced from tenant-kit/README.md — the repo is canonical.
Multi-tenancy as a library, for the app you already run.
flowchart LR
req(["request"])
subgraph TK["tenant-kit — Apache-2.0"]
direction LR
ex["extract<br/>claim, untrusted"]
az["authorize<br/>directory + membership"]
ctx["context<br/>ambient tenant"]
iso["isolation<br/>RLS-scoped executor"]
ex -->|TenantClaim| az
az -->|ResolvedTenant| ctx
ctx --> iso
end
subgraph HOST["your app"]
h["handlers"]
db[("your tables<br/>tenancy.protect()")]
end
req --> ex
iso -->|"SET LOCAL, per txn"| db
ctx --> h
h --> iso
classDef own fill:#0d9488,stroke:#0f766e,color:#ffffff;
classDef host fill:#1e293b,stroke:#0f172a,color:#e2e8f0;
class ex,az,ctx,iso own;
class h,db host;
class req host;
tenant-kit owns the teal boxes: what a tenant is (a directory of tenants and memberships), how a request becomes one (extraction, then authorization — never one without the other), how the current tenant travels through your code (AsyncLocalStorage), and how the database refuses to serve anyone else's rows (row-level security you turn on per table). Your app owns everything else — its users, its auth, its tables, its permissions.
Apache-2.0, sibling to billing-kit:
same license, same executor interface, same design rules. A tenant resolved
here is the tenantId on every usage event there — see
docs/BILLING_KIT.md.
The problem it solves
Multi-tenancy usually arrives in one of two shapes, and both put the tenant boundary somewhere you don't control:
- A hosted identity platform — Clerk Organizations, WorkOS, Auth0 Organizations — that owns your org model, your user model, and a slice of your login flow, priced per active user, reached over the network on every request.
- Hand-rolled middleware — a
req.tenantset from the subdomain, aWHERE tenant_id = $1appended to five hundred queries by convention, and an incident review the week one of them forgets.
tenant-kit is a third shape: a library you embed. You import the tenant
directory, resolution, context and isolation into the app you already run, on
the Postgres and the auth you already use. No per-seat pricing on your own
customers, and no convention where an invariant should be.
| What it costs you | Where the tenant boundary lives | |
|---|---|---|
| Hosted orgs (Clerk, WorkOS, Auth0) | Per-user pricing, forever | Their API, their user model |
| Hand-rolled | Every forgotten WHERE clause | Convention and code review |
| tenant-kit | A schema and two imports | Your database, enforced by it |
What it refuses to be
The scope line is drawn once, here, and every module comment in src/
defends a piece of it:
- Not an auth product.
UserIdis opaque. Whoever your auth layer says is calling — a Clerk id, an Auth0 sub, your sessions table's primary key — tenant-kit checks membership, never identity. There is no users table in this schema and there never will be. - Not an RBAC engine. Three roles —
owner,admin,member— and one comparison,atLeast. Permissions-on-resources is your application's vocabulary, built on top. - Not billing. Tenants get billed by billing-kit,
which consumes the
tenantIdthis library produces. Neither imports the other.
The systems on the other side of that line plug in through tenant-kit-adapters: enterprise SSO over any OIDC IdP (per-tenant connections, group→role mapping, JIT provisioning), SCIM 2.0 directory provisioning, and role-mirroring bridges into RBAC engines like OpenFGA — each one an adapter conforming to a small contract, none of them moving the scope line.
Quickstart
pnpm add tenant-kit
psql "$DATABASE_URL" -f node_modules/tenant-kit/sql/001_core.sql
psql "$DATABASE_URL" -f node_modules/tenant-kit/sql/002_rls.sql
Wire it to any pg.Pool (the ~10-line adapter is in
test/pg-executor.ts; it satisfies billing-kit's
executor too):
import { createTenancy, firstOf, fromSubdomain, fromHeader } from 'tenant-kit';
const tenancy = createTenancy({ db });
// Once: a tenant and its first owner.
const acme = await tenancy.createTenant({ slug: 'acme', name: 'Acme Corp' });
await tenancy.addMember({ tenantId: acme.id, userId: user.id, role: 'owner' });
// Every request: extract a claim, authorize it, make the tenant ambient.
const extract = firstOf(fromSubdomain({ baseDomain: 'example.com' }), fromHeader());
app.use(async (req, res, next) => {
const resolved = await tenancy.resolve(
{ hostname: req.hostname, path: req.path, headers: req.headers },
{ userId: req.user.id, extract }, // req.user: your auth layer's verdict
);
tenancy.run(resolved, next);
});
// Handlers: the ambient tenant, and a database that can only see its rows.
app.get('/projects', async (req, res) => {
const rows = await tenancy.db().query('SELECT * FROM projects');
res.json(rows); // no WHERE tenant_id — the policy is the WHERE
});
Turn on isolation per table, in your own migrations:
SELECT tenancy.protect('public.projects'); -- column defaults to tenant_id
SELECT tenancy.protect('public.documents', 'org_id'); -- or name it
protect enables forced row-level security and installs one policy:
rows are visible iff their tenant column equals the transaction-local tenant
that tenancy.db() set. A query that escapes scoping sees an empty table —
the forgotten-WHERE bug degrades from a data leak to a bug report.
The two-halves rule
The API's one security idea, worth stating outside a docstring:
extraction: request → TenantClaim (pure; the caller's assertion)
authorization: claim → ResolvedTenant (directory lookup + membership check)
An extractor cannot return a Tenant. Nothing downstream accepts a
TenantClaim. So "trust the subdomain" — the shape of most multi-tenant
breaches — is not expressible through this API by accident. fromSubdomain,
fromHeader, fromPath and fromClaim compose with firstOf; writing your
own extractor is writing one pure function.
Every failure is a typed code on TenancyError — unknown_tenant,
not_a_member, tenant_archived, last_owner, forbidden… — so your HTTP
layer decides once what each reveals. (Deliberately: answer both
unknown_tenant and not_a_member with 404, or an attacker enumerates your
customer list from your status codes.)
Guarantees held as invariants, not conventions
- A tenant always has at least one owner. Demoting or removing the last
owner fails with
last_owner— checked under row locks, so two concurrent removals of the last two owners serialize and one loses. There is a test that races them. - Tenant scope cannot outlive its transaction. Scoping uses
set_config(…, local := true)inside a transaction pinned to one connection; the next borrower of that pooled connection starts unscoped. There is a test for that too. - Archived, never deleted. A tenant's id keeps resolving in your ledger
and your audit log after the tenant leaves;
resolve()refuses it withtenant_archived, andarchived_atrecords the first archival, not the latest retry. - Idempotent writes. Re-creating a tenant with identical input returns the existing tenant; re-adding a member with the same role returns the existing membership. The conflicting retry — same slug, different name; same user, different role — is the one that errors, loudly and by name.
Documentation
| Doc | What it covers |
|---|---|
| docs/ARCHITECTURE.md | The module map, the two-halves rule, context propagation, and the API's design rules. |
| docs/ISOLATION.md | Choosing between shared-schema RLS, schema-per-tenant and database-per-tenant — mechanics, failure modes, and when to move. |
| docs/BILLING_KIT.md | Running tenant-kit and billing-kit together: one pool, one tenant id, RLS over the billing schema. |
| sql/README.md | The shipped schema and how to apply it. |
Requirements
Node ≥ 20.19, Postgres ≥ 14 for the RLS strategy (the directory alone works
anywhere the SqlExecutor interface reaches). Zero runtime dependencies;
pg is the test harness's, not the library's.