| title | Tenancy |
|---|---|
| description | Serve several customers from one Arc application, choose how each request selects its tenant, prove the caller belongs to it, and keep tenant selection, membership, and storage isolation apart. |
Acme and Globex both use your task application, on the same deployment. A request from an Acme user must only ever read and change Acme's tasks. One missed check, and a Globex user sees a competitor's data.
Arc resolves a tenant for every request, before your code runs, and carries it through commands, queries, services, and storage integrations. You decide how the tenant is selected and how membership is proven. Arc makes sure the answer is the same everywhere in the request.
flowchart LR
Request --> Selection["Selection<br/>which tenant does the request name?"]
Selection --> Membership["Membership<br/>may this caller use it?"]
Membership --> Storage["Storage isolation<br/>where do reads and writes go?"]
| Decision | Who makes it |
|---|---|
| Selection | Arc, from the source you configure: a header, a claim, a subdomain, or your own resolver |
| Membership | Arc with tenancy.membershipClaim, your tenancy.resolve, or your authorization rules |
| Storage isolation | The storage integration: a MongoDB database, a Drizzle connection, or a Chronicle namespace per tenant |
Each decision depends on the one before it, and none replaces another. A separate database per tenant does not stop a Globex user from naming Acme in a header. Storage isolation covers the third decision.
| Configuration | Behavior |
|---|---|
| Nothing | Arc reads the x-cratis-tenant-id header unchanged. Missing header means tenantId is undefined |
tenancy: { resolve(request, principal) } |
Your resolver alone decides, after authentication, and may be async. Returning undefined means no tenant; there is no fallback |
tenancy: { sources: [...] } |
Ordered built-in sources with optional required and membership checks; see Tenant resolvers |
tenancy.resolve overrides built-in sources; tenancy.httpHeader customizes the header when using the built-in header source.
import { ArcApplication, TenantResolverType } from '@cratis/arc.core';
const builder = ArcApplication.createBuilder({
authentication: [/* verified handlers */],
tenancy: { sources: [TenantResolverType.Claim, TenantResolverType.Header], claimType: 'tenant', membershipClaim: 'tenants', required: true }
});This tries an own tenant claim on the verified principal first, then the header. A missing tenant answers 400; a selected tenant that is not listed in the principal's comma-separated tenants claim answers 403. Both checks run before authorization, validation, or your code.
:::danger[A header is a request, not proof]
Without tenancy.membershipClaim or tenancy.resolve, Arc takes the header unchanged and does not check membership. When tenants separate customers' data, derive the tenant from the principal in tenancy.resolve, configure a membership claim, or check it in authorization. Never enforce it in a validator: a trusted direct caller can lower blocking severity.
:::
Every callback receives the execution context with tenantId, principal, correlationId, signal, and allowedSeverity. Code without access to that parameter, such as a repository deep in a call chain, can call currentContext(). It uses Node.js AsyncLocalStorage, so concurrent requests never see each other's context, and it returns undefined outside an Arc execution.
In a spec, set the tenant the same way a trusted caller would: CommandScenario.for(...).withContext({ tenantId: 'acme', principal }).
The storage integrations select per-tenant storage from the resolved tenant:
- MongoDB chooses a database per tenant, and fails without one.
- SQL with Drizzle calls your
databaseFactoryper tenant, and fails without one. - Chronicle, experimental, appends in the tenant's namespace, or in
Defaultwhen there is none.
Storage isolation shows each mapping and how to verify it.
- Derive the tenant from verified identity when tenants are customers. Use the
claimsource, ortenancy.resolvereading the principal. Keep the header for trusted internal callers. - Always prove membership. Configure
membershipClaim, check it intenancy.resolve, or add a policy. Selection alone proves nothing. - Set
required: truewhen every operation is tenant-scoped. A missing tenant then fails with 400 at the edge, instead of deep in a storage integration. - Use stable, lowercase tenant IDs. Built-in sources lowercase IDs and accept only DNS labels of up to 63 characters. Returning the same form from
tenancy.resolvekeeps every integration aligned. - Put the tenant in cache keys, logs, and telemetry. A cache keyed only by entity ID serves one tenant's data to another.
- Keep
fixedanddevelopmentsources for single-tenant or local setups. Neither checks where a request came from.
- Tenant resolution runs after authentication, so
tenancy.resolveand the claim source see a verified principal. Never read identity from the request yourself in a resolver. - A header, query-string, or subdomain value is a request by the caller. The
subdomainsource reads only a host-verified authority, never the rawHostorX-Forwarded-Hostheader; see Tenant resolvers. - Enforce membership in tenancy options or authorization, never in validators.
/.cratis/tenantsis a fixture list for development tools, anonymous by default only in Development. Never return real tenant inventories fromdevelopmentTenants; see Development users and tenants.- Treat tenant IDs as internal metadata. Avoid putting them in public URLs or error messages when a customer name would reveal who else uses the system.
- Arc selects one tenant per request and exposes it as
context.tenantIdand throughcurrentContext(). - Selection, membership, and storage isolation are separate decisions. Configure all three.
- Headers select, principals prove.
Next, follow one request through all three decisions in Tenancy end to end, or pick your sources in Tenant resolvers and check your storage in Storage isolation.