Tenants
tenantGive every request a tenant and keep each customer's records, files, keys and logs apart, in the region they chose.
tenancyFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
tenant tells your app which customer a request belongs to. The adapter sets it first, and every db query, kv key, file path and log record is scoped to it. Core, free on every plan.
Spec tenancy, milestone 0.3. The module is built and runs on in-memory fakes: the tenant directory is held in memory today, and durable directory drivers are planned.
At a glance
tenancy:
isolation: shared # shared | schema | database
resolve: [apikey, session, subdomain]
host: acme.app
personal: true
directory: eu
roles: [owner, admin, member, viewer]
system: { allow: [src/admin/**, scripts/**] }
suspended: { allow: [billing.*, tenant.export] }
erase: { wait: 7d }
global: [Plan, Country] # models shared by every tenant
databases: # isolation: database only
tnt_acme: secret:ACME_DATABASE_URLimport { db, tenant } from "mainsdk";
// the adapter set the tenant from the API key, the session or the subdomain
const acme = tenant.current();
const orders = await db.orders.for(user).list({ limit: 20 }); // only acme's rows
// a job has no request, so it names the tenant once
await tenant.run(job.tenantId, () => sendDigest());test/tenant.test.ts
import { it } from "node:test";
import assert from "node:assert/strict";
import { db, s, tenant } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";
it("tenancy AC8 keeps one tenant's rows from another", async () => {
createTestApp({ yaml: "schema: 1\nproject: prj_test\ntenancy:\n resolve: [session]\n" });
const { orders } = db.define({ orders: s.object({ item: s.string() }) });
const a = await tenant.create({ name: "A", region: "eu" });
const b = await tenant.create({ name: "B", region: "us" });
await tenant.run(a.id, () => orders.for("u1").create({ item: "x" }));
const seen = await tenant.run(b.id, () => orders.for("u1").list({ limit: 20 }));
assert.equal(seen.items.length, 0);
});Calls
tenant.current()tenant.maybe()tenant.run(id, fn)tenant.system(reason, fn)tenant.anonymous(fn)tenant.create({ name, kind?, region, slug?, plan? })tenant.suspend(id, { reason })tenant.resume(id)tenant.export(id)tenant.erase(id, { reason })tenant.cancelErase(id)new MemoryDirectory()globMatch(globs, path)overrideLoosens(direction, base, { value })regionOfMarket(market)uuidv7(now)tenant.active()tenant.actor()tenant.as(who, fn)tenant.audit(action, id, attributes?)tenant.basetenant.carry()tenant.configValue(key)tenant.forgetPerson(user)tenant.handle(req, own, fn)tenant.hold(id)tenant.isGlobal(model)tenant.isolationTarget()tenant.loadOverrides(raw, base, direction)tenant.personHash(user)tenant.personalFor(person)tenant.prefix(key)tenant.principal()tenant.register(driver)tenant.release(id)tenant.resolve(req, own?)tenant.roles()tenant.runJob(id, fn)tenant.scope(call)tenant.setPlan(id, plan)tenant.store(stores)tenant.subject()tenant.sweep()tenant.tag()tenant.tenantOf(id)tenant.verify(key, data, signature)
Guarantees
Whatever the driver.
- Every request has a tenant, and every query, key and file path is scoped to it
- A record, file or team of another tenant reads as not found
- Each tenant is pinned to one region and nothing is written elsewhere
- tenant.system() runs only in listed admin files and is audited with its reason
- Log records carry the tenant, and each tenant has its own exportable audit chain
Drivers
1 drivers. Select one to see its line in main.yaml.
tenant:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-TENANT-0010 | Checkmainsdk check, before deploy | A raw query on a tenant-scoped table has no tenant_id condition |
MAIN-TENANT-0011 | Checkmainsdk check, before deploy | tenant.system() used in a file outside tenancy.system.allow |
MAIN-TENANT-0012 | Checkmainsdk check, before deploy | A per-tenant config or limit override loosens the app value |
MAIN-TENANT-0013 | Checkmainsdk check, before deploy | Shared isolation on a driver with row-level security, but the migrations don't enable it |
MAIN-TENANT-0001 | RuntimeError responses and logs | A tenant-scoped call ran with no tenant in context (500) |
MAIN-TENANT-0002 | RuntimeError responses and logs | The tenant doesn't exist, or the caller isn't a member or key holder (404, same body either way) |
MAIN-TENANT-0003 | RuntimeError responses and logs | The tenant is suspended; tenant-scoped calls fail closed (403) |
MAIN-TENANT-0004 | RuntimeError responses and logs | tenant.system() called from a file outside tenancy.system.allow |
MAIN-TENANT-0005 | RuntimeError responses and logs | A record, file, team or reference of another tenant was used; treated as not found (404) |
MAIN-TENANT-0006 | RuntimeError responses and logs | The tenant's region store or database is not configured or not reachable; nothing is written elsewhere (503) |
MAIN-TENANT-0007 | RuntimeError responses and logs | Erasure refused: the tenant is active, under a legal hold, or still in its waiting period (409) |
MAIN-TENANT-0008 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| tenant@1 contract and the implicit default tenant | Phase 1 | 0.3 | tenancy#contract |
| Tenant in context with tenant.current() and tenant.maybe() | Phase 1 | 0.3 | tenancy#context |
| Tenant resolved from API key | Phase 1 | 0.3 | tenancy#resolvers |
| tenant.run() for jobs and audited tenant.system() | Phase 1 | 0.3 | tenancy#run-system |
| Tenant directory, tenant.create() and personal tenants | Phase 1 | 0.3 | tenancy#directory |
| Rows isolated by tenant_id with row-level security | Phase 1 | 0.3 | tenancy#db-shared |
| Schema-per-tenant and database-per-tenant isolation | Phase 1 | 0.3 | tenancy#db-schema-database |
| kv | Phase 1 | 0.3 | tenancy#kv-files-limits |
| Logs tagged and one audit chain per tenant | Phase 1 | 0.3 | tenancy#log-audit |
| A key per tenant for crypto-erasure | Phase 1 | 0.3 | tenancy#keys-tenant |
| Tenant in every access subject and resource | Phase 1 | 0.3 | tenancy#access-tenant |
| Suspend, export and erase a tenant | Phase 1 | 0.3 | tenancy#lifecycle |
| Flags by tenant and per-tenant config that only tightens | Phase 1 | 0.3 | tenancy#config-flags |
| Check rules for unfiltered queries and tenant.system() outside admin code | Phase 1 | 0.3 | tenancy#checks |
| Two-tenant test helper | Phase 1 | 0.3 | tenancy#two-tenants |
Quick start
tenancy:
isolation: shared
resolve: [apikey, session, subdomain]
personal: trueimport { auth, models, s, tenant } from "mainsdk";
const { orders } = models({
orders: s.object({ item: s.string(), total: s.int() }),
}); // declared once in lib/orders.ts, shown inline so the example stands alone
export async function GET(req: Request) {
const session = await auth.require(req);
const acme = tenant.current(); // set by the adapter
const page = await orders.for(session.user).list({ limit: 20 }); // only this tenant's rows
return Response.json({ tenant: acme.id, items: page.items });
}tenant.current() returns { id, kind, name, slug, region, plan, status, roles }. A call that needs a tenant and finds none fails with MAIN-TENANT-0001. Use tenant.maybe() where a tenant is optional: it returns the tenant or null. Without a tenancy section, every call runs in one implicit tenant, default.
Configuration
Every key lives under tenancy:.
tenancy:
isolation: shared # shared | schema | database
resolve: [apikey, session, subdomain]
host: acme.app # the subdomain resolver reads <slug>.acme.app
personal: true # a personal tenant for each person at first sign-in
roles: [owner, admin, member, viewer]
global: [plans, countries] # db models shared by all tenants; no personal fields
system: { allow: [src/admin/**, scripts/**] }
suspended: { allow: [billing.*, tenant.export] }
erase: { wait: 7d }| Key | What it does |
|---|---|
isolation | shared keeps rows in one database with a tenant_id and row-level security. schema and database give each tenant its own |
resolve | How the tenant is found, in order. The first match wins. header is a selector, never proof |
host | The domain the subdomain resolver reads: acme.app names tenant slug acme from acme.acme.app |
personal | Create a personal tenant for each person at first sign-in |
roles | The roles a member can hold. Default owner, admin, member, viewer |
global | db models shared by all tenants. Only tenant.system() writes them |
system.allow | The only files that may call tenant.system() |
suspended.allow | Calls that still work while a tenant is suspended |
erase.wait | The waiting period before a tenant is erased. Default 7 days |
A tenant is pinned to one region at create (us, eu or in) and nothing it owns is written elsewhere. The key that names where the directory lives is planned with the durable directory drivers.
What you get
| Call | What it does |
|---|---|
tenant.current() and tenant.maybe() | The current tenant, or null from maybe() when there is none |
tenant.run(id, fn) | Run a job or script as one tenant. An unknown tenant fails with MAIN-TENANT-0002 |
tenant.system(reason, fn) | Admin code across tenants, from listed files only, with an audit record |
tenant.create({ name, kind?, region, slug?, plan? }) | Create a tenant pinned to one region |
tenant.suspend(id, { reason }) and tenant.resume(id) | Stop and restart a tenant's calls |
tenant.export(id) | One signed bundle of the tenant's data |
tenant.erase(id, { reason }) and tenant.cancelErase(id) | Erase a tenant after the waiting period, or stop it |
Always:
- Every request has a tenant, and every query, key and file path is scoped to it.
- A record, file or team of another tenant reads as not found.
- Each tenant is pinned to one region and nothing is written elsewhere.
tenant.system()runs only in listed admin files and is audited with its reason.- Log records carry the tenant, and each tenant has its own exportable audit chain.
Examples
Run a job as a tenant
A job has no request, so it names the tenant once with tenant.run(id, fn). It refuses an unknown or suspended tenant.
import { tenant } from "mainsdk";
declare function sendDigest(): Promise<void>; // your own code
export async function sendDigests(tenantIds: string[]) {
for (const id of tenantIds) {
await tenant.run(id, () => sendDigest()); // db, kv, files and logs inside are this tenant's
}
}Admin code across tenants
Cross-tenant work goes through tenant.system(reason, fn). It works only in files listed under tenancy.system.allow, and it writes an audit record with your reason.
Before anyone has signed in there is no tenant yet. tenant.anonymous(fn) runs fn in the implicit default tenant; the sign-in routes use it to send a link to an address that has no session. It grants no access to any customer's records.
import { tenant } from "mainsdk";
export async function countTenants() {
return tenant.system("monthly usage report", async (sys) => {
const page = await sys.list({ limit: 100 });
return page.items.length;
});
}Use it only in admin paths. mainsdk check fails with MAIN-TENANT-0011 when it appears anywhere else.
Create a tenant
auth.orgs.create() is the call for a signed-in person making an organisation. Use tenant.create() in admin code and scripts.
import { tenant } from "mainsdk";
const acme = await tenant.create({ name: "Acme", kind: "org", region: "eu", slug: "acme", plan: "pro" });
console.log(acme.id); // tnt_…Suspend, export and erase
import { tenant } from "mainsdk";
const id = "tnt_acme";
await tenant.suspend(id, { reason: "unpaid for 60 days" }); // calls fail closed with MAIN-TENANT-0003
const bundle = await tenant.export(id); // { files, manifest }: JSON per driver and a signed manifest
const done = tenant.erase(id, { reason: "customer request" }); // a promise that settles after tenancy.erase.wait
await tenant.cancelErase(id); // changes your mind inside the waiting period; `done` rejects
await tenant.resume(id);tenant.erase() schedules the erasure and its promise resolves with the signed record only once the waiting period has passed and every driver has erased, so do not await it in a request. Calling tenant.cancelErase() rejects it with MAIN-TENANT-0007 and returns the tenant to suspended. Erasure needs the tenant suspended first and no legal hold, or it fails with MAIN-TENANT-0007. A tenant id or slug that is invalid or taken, or a missing name or region, is refused when you create it.
Reference: more calls
The calls below are for adapters, admin code and tests. Most apps never call them, because the adapter and db use them for you.
Read the context
| Call | What it does |
|---|---|
tenant.active() | The tenant of this context when the app has a tenancy section, else null. Interfaces use it to scope their records. |
tenant.principal() | The person or agent of this context, as set by tenant.as() or the signed-in actor. Empty outside one. |
tenant.actor() | The member or system actor of this context, or undefined in code with no caller. |
tenant.subject() | The API key subject of this request, or undefined when it was not made with a key. |
tenant.roles() | The caller's roles in the tenant they came in through. They carry into tenant.system(). |
tenant.tag() | The id that tags log and audit records: the tenant, system inside tenant.system(), or undefined. |
tenant.tenantOf(id) | The tenant behind an id as tenant.current() would show it, or null for an unknown or erased tenant. |
tenant.isolationTarget() | Where the current tenant's rows live under schema and database isolation. |
tenant.isGlobal(model) | true when a model is listed under tenancy.global, so it has no tenant column. |
tenant.configValue(key) | A config value for the current tenant: its accepted override, else the app value. |
Scope a call
| Call | What it does |
|---|---|
tenant.scope(call) | For a call named like files.url, the current tenant, or null when the app has no tenancy section. It throws MAIN-TENANT-0001 with no tenant, and MAIN-TENANT-0003 when the tenant is suspended and the call isn't under tenancy.suspended.allow. |
tenant.prefix(key) | Prefix a kv, cache or limits key with the tenant, as t:<id>:<key>. |
tenant.store(stores) | Pick the store for the current tenant's region from { us, eu, in }. It throws MAIN-TENANT-0006 when that region has none, and never returns another region's store. |
Resolve and enter
| Call | What it does |
|---|---|
tenant.resolve(req, own?) | Resolve a request's tenant with the resolvers in tenancy.resolve, first match wins. A subdomain or header only selects: the caller must be a member or hold a key, or the answer is the same 404 as an unknown tenant. Returns null when no resolver names one. |
tenant.handle(req, own, fn) | Resolve the request's tenant and run fn inside it. With no tenant resolved, fn runs with none. Adapters call this. |
tenant.base | A property: identity sources every request gets, such as API key resolution. Per-call sources win. |
tenant.as(who, fn) | Run fn as a person or an agent, and the person it acts for, in the current tenant. For routes and tests. |
tenant.runJob(id, fn) | Run a job for the tenant it was enqueued in. A suspended tenant's job is deferred, not run, and logged. |
tenant.carry() | The id to store with a job when you enqueue it, so the worker can restore the tenant. null with no tenant. |
tenant.personalFor(person) | On first sign-in with tenancy.personal: true, create the person's personal tenant in the region of their market, or return the one they have. Returns null when personal tenants are off. |
Lifecycle and audit
| Call | What it does |
|---|---|
tenant.setPlan(id, plan) | Set the plan name shown in the directory and in tenant.current().plan. entitlements calls it. |
tenant.hold(id) and tenant.release(id) | Put a legal hold on a tenant, so tenant.erase() refuses it, and lift it. |
tenant.register(driver) | Register a driver that holds tenant data, so export and erase reach it. |
tenant.sweep() | Finish every erasure whose waiting period ended, such as after a restart lost the timers. |
tenant.audit(action, id, attributes?) | Write a record on the system audit chain with the acting caller as actor. |
tenant.verify(key, data, signature) | Check a signature made by an export manifest or an erasure record. |
tenant.personHash(user) | A keyed hash of a user id, for the directory's pseudonymous membership index. |
tenant.forgetPerson(user) | Delete a person's index entries when they are erased. |
tenant.loadOverrides(raw, base, direction) | Load per-tenant config overrides from tenancy.overrides or a push. An override that loosens is refused with MAIN-TENANT-0012, logged, and the last good value stays. |
Helpers
These pure functions are exported for adapters and tests.
| Function | What it does |
|---|---|
regionOfMarket(market) | The region of a market code: India to in, European markets to eu, everything else to us. |
overrideLoosens(direction, base, { value }) | true when an override moves a number the way the schema forbids: above the base for lower, below it for raise. |
globMatch(globs, path) | true when a path matches any glob, such as src/admin/**. * stays within a folder, ** crosses folders. |
uuidv7(now) | A UUIDv7 string: 48 bits of the given time in milliseconds, then random bits. |
new MemoryDirectory() | The in-memory tenant directory. Set down = true to make every call reject, to test the 60-second last-known-status fallback. |
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-TENANT-0001 | A tenant-scoped call ran with no tenant in context | Run jobs inside tenant.run(id, fn) |
MAIN-TENANT-0002 | The tenant doesn't exist, or the caller isn't a member or key holder (404) | Switch to a tenant you belong to |
MAIN-TENANT-0003 | The tenant is suspended | Resume it, or list the call under tenancy.suspended.allow |
MAIN-TENANT-0004 | tenant.system() called from an unlisted file | Move the admin code under a listed path |
MAIN-TENANT-0005 | A record, file or team of another tenant was used (404) | Read it inside its own tenant |
MAIN-TENANT-0006 | The tenant's region store isn't configured or reachable | Configure the store for that region |
MAIN-TENANT-0007 | Erasure refused | Suspend first, lift the hold, or wait |
MAIN-TENANT-0008 | tenant.create or auth.orgs.create got input it can't use: no name, a region other than us, eu or in (400), or an id or slug that is invalid or taken (409) | Pass { name, region }, and an id such as tnt_acme or a slug nobody has |
MAIN-TENANT-0010 to 0013 | Check findings: an unfiltered raw query, tenant.system() outside admin code, a loosening override, missing row-level security | See check codes |
Swapping drivers
tenant has no driver. Isolation is an option of the db driver (shared, schema or database), so you change it under tenancy.isolation and db.
Testing
createTestApp() reads your tenancy section and keeps the directory in memory. Create two tenants and run the same code in each with tenant.run().
import { it } from "node:test";
import assert from "node:assert/strict";
import { db, s, tenant } from "mainsdk";
import { createTestApp } from "@mainsdk/testing";
it("tenancy AC8 keeps one tenant's rows from another", async () => {
createTestApp({ yaml: "schema: 1\nproject: prj_test\ntenancy:\n resolve: [session]\n" });
const { orders } = db.define({ orders: s.object({ item: s.string() }) });
const a = await tenant.create({ name: "A", region: "eu" });
const b = await tenant.create({ name: "B", region: "us" });
await tenant.run(a.id, () => orders.for("u1").create({ item: "x" }));
const seen = await tenant.run(b.id, () => orders.for("u1").list({ limit: 20 }));
assert.equal(seen.items.length, 0);
});A suite that runs every test as two tenants and fails on any cross-tenant read is planned (spec tenancy). See the testing kit.
Security and privacy
- A record, file or team of another tenant reads as not found, with the same body as one that does not exist.
- Each tenant has its own key under the region key, so erasing the key erases the tenant's data.
- The directory holds ids, slugs, regions and status only. It holds no personal data.
- Every log record carries the tenant, and each tenant has its own audit chain that can be exported alone.
FAQ
Do I pass the tenant id to db? No. The query is scoped for you. Passing ids by hand is how data leaks.
My app has one customer. Do I need this? No. Without a tenancy section there is one implicit tenant, default.
Can a person belong to several tenants? Yes. auth.orgs.switch() sets the active one for the session.
Next
- Sign-in and sessions: organisations, members, teams and invites.
- API keys for your customers: keys that set the tenant.
- Plans and entitlements: what each tenant's plan allows.
- Build a multi-tenant app
Was this page helpful?