Tenancy
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.
Three decisions, kept apart
Section titled “Three decisions, kept apart”| 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.
Three ways to select a tenant
Section titled “Three ways to select a tenant”| 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.
Read the tenant
Section titled “Read the tenant”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 }).
Storage follows the tenant
Section titled “Storage follows the tenant”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.
Best practices
Section titled “Best practices”- 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.
Security considerations
Section titled “Security considerations”- 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.