From script to stage · Part 10: Tenancy and identity end to end
Cratis: from script to stage, and the long run · Part 10 of 26
AuthProxy forwards the tenant it resolved in a header named Tenant-ID. Arc, left on its defaults, reads the tenant from a header named x-cratis-tenant-id. Put the two together without configuring either, and Arc ignores the tenant AuthProxy resolved. A request with no x-cratis-tenant-id header gets no tenant, and Arc’s Chronicle integration sends it to Chronicle’s Default namespace. Nothing fails, and every organization ends up in one event log. A request that carries x-cratis-tenant-id gets whatever tenant the caller put there, because AuthProxy passes that header through.
The workaround is one line, options.UseHeaderTenancy("Tenant-ID"). The fix on the proxy side is tracked in the public issue Cratis/AuthProxy#157.
Keeping author names unique within an organization rests on a tenant ID that travels from the edge to the database. Take a made-up week with the mismatch in place. Contoso tries to register Jane Austen and is told the name is taken, because Acme registered her on Monday and both organizations are writing to Default. On Friday, Acme’s admin asks why their author list shows names they’ve never seen. Every step behind that week is a default, and each one is sensible on its own. Follow one tenant, acme, from sign-in to storage, and past the edge a missing tenant turns into Default at each hop, without an error.

At every hop, find out what “no tenant” turns into.
At the edge: sign-in, tenant and onboarding
Section titled “At the edge: sign-in, tenant and onboarding”AuthProxy sits in front of the backend and the frontend. It handles OIDC and OAuth2 sign-in, plus JWT bearer authentication, resolves a tenant for each request and forwards it downstream in a header named Tenant-ID. Tenant resolution is a list of strategies under TenantResolutions, and resolving from a token claim is one entry in it:
{ "Cratis": { "AuthProxy": { "TenantResolutions": [ { "Strategy": "Claim", "Options": { "ClaimType": "tid" } } ] } }}AuthProxy matches the claim’s value against the source identifiers you configure for each tenant. It can also require configured claims, such as a role or a group, before it forwards a request. Let the backend accept traffic from the proxy and nothing else, because a service reachable directly believes whatever headers a caller sends it.
A user who has signed in but belongs to no tenant yet needs somewhere to go. Ante is a separate invitation and onboarding lobby for that moment, where they can join an existing tenant, create one by invitation, or self-register. It records the acceptance and publishes it for your application to act on. Membership, provisioning and email stay yours.
How the edge picks a tenant
Section titled “How the edge picks a tenant”The strategies run in order until one succeeds, so a list with Host first and Claim second tries the request host before the token. For Host, Claim and Route, a strategy finds a source identifier and looks it up in the Tenants registry, and what travels downstream is the registry key, acme, instead of the raw value of the tid claim. SubHost takes the subdomain as it is, with no lookup. For that strategy, AuthProxy’s tenant verification can ask your backend whether the tenant exists, and a tenant that exists still isn’t proof that this caller belongs to it.
Before any of that runs, AuthProxy’s tenancy middleware removes inbound identity headers and any inbound Tenant-ID. A caller can’t hand the backend a tenant of its own choosing under that name. The resolved value is then added to the proxied request.
A request can also leave the edge with no tenant at all. If strategies are configured and none resolves, a signed-in user gets a 403 page explaining that they belong to no organization, an anonymous caller gets a 401, and with a lobby configured for it the user is redirected there instead. If TenantResolutions is empty, the request goes through with no tenant and no Tenant-ID header. Sign-in, invitation, registration and paths declared anonymous go through without a tenant as well.
Who the user is
Section titled “Who the user is”AuthProxy authenticates before a request reaches your code, and it forwards the signed-in identity downstream in the x-ms-client-principal, x-ms-client-principal-id and x-ms-client-principal-name headers, with the principal itself as base64-encoded JSON. It removes inbound copies of those headers first, so a caller can’t pass a principal of its own through the proxy.
Arc doesn’t sign anyone in. The host establishes the principal, and Arc’s authorization attributes check it on each command and query: [Authorize] for any authenticated user, [Roles] for any of the listed roles, a named policy, or [AllowAnonymous]. With no requirement declared, the built-in evaluator allows the call. A role isn’t ownership either. “Only the librarian who registered an author may rename it” is a rule the application writes, by deriving the owner from the principal and constraining the query, and Arc has no universal mapping from a claim to an owner.
For the frontend, an identity details provider composes application-specific details from the authenticated principal, and Arc serves them at /.cratis/me. The endpoint exists only when a provider is registered. The .cratis-identity cookie that carries those details to the browser is unsigned presentation data the client controls, so nothing on the backend should ever authorize on it. /.cratis/me doesn’t authorize commands or queries.
Identity and tenant arrive by the same route, as headers the proxy sets. Arc trusts both, and the difference is in what Arc does with them. The principal goes through authorization. The tenant only selects where data goes.
The header hop into Arc
Section titled “The header hop into Arc”Arc reads the tenant from a header too, but its default header name is x-cratis-tenant-id, so point it at the proxy’s header. The tenant resolver docs show the setting with a custom name, and in .NET the extension method lives in Cratis.Arc.Tenancy:
using Cratis.Arc;using Cratis.Arc.Tenancy;
builder.AddCratisArc(options =>{ options.UseHeaderTenancy("Tenant-ID");});In Arc for Kotlin and Java this is a Spring property. A request without the header has no tenant (null) unless cratis.arc.tenancy.required is set, which rejects it with a 400, and the Chronicle integration uses the default event store for a null tenant.
cratis.arc.tenancy.header-name=Tenant-IDIn Arc for Kotlin and Java this is a Spring property. A request without the header has no tenant (null) unless cratis.arc.tenancy.required is set, which rejects it with a 400, and the Chronicle integration uses the default event store for a null tenant.
cratis.arc.tenancy.header-name=Tenant-IDIn Arc for TypeScript this is the tenancy.httpHeader option. A request without the header has no tenantId (undefined) unless tenancy.required is set, which answers 400, and the Chronicle integration then appends in the Default namespace.
import { ArcApplication } from '@cratis/arc.core';
const builder = ArcApplication.createBuilder({ tenancy: { httpHeader: 'Tenant-ID' }});Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
In .NET, with the default resolvers, a missing header doesn’t reject the request. It selects the unset tenant and then Chronicle’s default namespace, so decide deliberately whether that’s allowed.
Nothing sets the name for you, and putting AuthProxy in front of Arc doesn’t change which header Arc reads. That’s the mismatch in Cratis/AuthProxy#157, and the one line above is the workaround.
Setting the name closes a second gap as well. AuthProxy removes the inbound headers it owns, and x-cratis-tenant-id isn’t one of them. An Arc left on its default reads a tenant from a header the proxy doesn’t remove. Configure Tenant-ID, and strip x-cratis-tenant-id at any ingress that could pass it through, the same treatment Arc recommends for the client-supplied fallback header of its subdomain resolver.
In .NET, when the header Arc reads is missing, the resolver returns an empty string and the tenant context becomes TenantId.NotSet. The tenant is resolved once per async execution flow and cached, so every service in a request sees the same value. An explicit tenant scope wins over the resolver, which matters for background work further down.
Non-ASCII tenant IDs have a seam of their own. AuthProxy sends a tenant ID that isn’t plain ASCII in RFC 8187 encoded form, and Arc’s header resolver passes the value through as it arrives. Tenant IDs that are plain lowercase labels, such as acme, never meet it, and the next hop gives a second reason to keep them that way.
The tenant becomes a namespace
Section titled “The tenant becomes a namespace”A Chronicle namespace is a logical partition within one event store. Events, projections, reducers and read models are processed per namespace, and when no namespace is given, Chronicle uses Default.
Arc’s Chronicle integration is optional, and Arc runs without it. Once you add it, with WithChronicle or the all-in-one AddCratis, it registers TenantNamespaceResolver as Chronicle’s namespace resolver, and your own options callback runs afterwards, so you can replace it. The resolver is short:
public EventStoreNamespaceName Resolve(){ var tenantId = tenantIdAccessor.Current; return tenantId.IsDefault ? EventStoreNamespaceName.Default : new EventStoreNamespaceName(tenantId.Value);}IsDefault is true for two values, TenantId.NotSet and a tenant actually named Default. Both land in Chronicle’s Default namespace, so a tenant called “Default” and a request with no tenant read and write the same data. Any other tenant ID becomes the namespace name exactly as it is.
That cuts both ways. A wrong but accepted tenant ID sends the write to that tenant’s namespace, faithfully. Isolation follows the tenant resolver, and the mapping selects a namespace without authorizing anything. Caller-controlled headers, query strings and hosts don’t prove membership. Check membership before any tenant-scoped work, and test the no-tenant case.
On MongoDB the namespace also becomes part of a database name, so it has to be a legal one. MongoDB rejects / \ . " $ * < > : | ?, the space and the null character, and caps a database name at 63 bytes. A display-style tenant ID such as Acme Books Ltd. won’t fit. Chronicle checks the composed name and throws InvalidDatabaseName, naming the event store, the namespace and the offending character, before the driver fails somewhere later. Arc’s subdomain resolver already insists on a letter-digit-hyphen label of up to 63 characters. AuthProxy registry keys in the same shape keep all three products in agreement about what a tenant ID can look like.
This hop is where the author feature’s rule becomes per organization. A constraint is scoped to its event sequence and namespace, so with a namespace per tenant, Acme and Contoso can each register Jane Austen. In the made-up week, they were sharing one namespace.

Selecting a tenant isn’t authorizing it.
Where the read models land
Section titled “Where the read models land”Events and read models aren’t stored under the same names, and the default namespace has one asymmetry. From Chronicle’s namespace naming, for an event store named Ada on MongoDB:
| Holds | Namespace Default |
Namespace acme |
|---|---|---|
| Event store wide state | Ada+es |
Ada+es |
| Event sequences | Ada+es+Default |
Ada+es+acme |
| Read models | Ada |
Ada+acme |
Read models of the default namespace go into the bare event store name, while event sequences carry a suffix for every namespace, Default included. Query read models from outside Chronicle with a database name you composed by hand, and Ada+Default is a database nothing writes to. Reads from it come back empty instead of failing.

Read models of the default namespace use the bare store name.
Arc’s MongoDB integration uses the same shape, the configured base database for a default tenant and base+tenant for any other, and both NotSet and Default resolve to the base name. For Arc’s direct MongoDB queries to find what Chronicle materialized, the base database and Chronicle’s read-model sink have to line up, and checking that alignment is your job. With EF Core, Arc uses the connection string you configured, pooled contexts included, and appends no tenant. Separate databases, schemas or enforced filters are yours to build and test.
Work that has no request
Section titled “Work that has no request”A scheduled job, or a reactor that starts work later, has no header to read, so it names its tenant. Arc’s command pipeline shows the pattern with a cart:
public class TenantCartJob(ITenantScope tenants, ICommandPipeline pipeline){ public async Task<CommandResult<CartLineId>> Add(TenantId tenant, Sku sku, Quantity quantity, CancellationToken stoppingToken) { using (tenants.Begin(tenant)) { return await pipeline.Execute<CartLineId>(new AddItemToCart(sku, quantity), stoppingToken); } }}In Arc for Kotlin and Java the tenant travels in the CommandExecutionOptions passed to the pipeline.
class TenantCartJob( private val pipeline: CommandPipeline, private val services: ServiceResolver) { suspend fun add(tenant: TenantId, sku: Sku, quantity: Quantity, principal: ArcPrincipal): CommandResult<*> = pipeline.execute( AddItemToCart(sku, quantity), CommandExecutionOptions(UUID.randomUUID(), principal, services, tenant.value()) )}In Arc for Kotlin and Java the tenant travels in the CommandExecutionOptions passed to the pipeline.
public class TenantCartJob { private final BlockingCommandPipeline pipeline; private final ServiceResolver services;
public TenantCartJob(BlockingCommandPipeline pipeline, ServiceResolver services) { this.pipeline = pipeline; this.services = services; }
public CommandResult<?> add(TenantId tenant, Sku sku, Quantity quantity, ArcPrincipal principal) { return pipeline.execute( new AddItemToCart(sku, quantity), new CommandExecutionOptions(UUID.randomUUID(), principal, services, tenant.value())); }}In Arc for TypeScript the tenant is the tenantId of the execution context passed to executeCommand, and no tenant is resolved for a direct call.
export class TenantCartJob { constructor(private readonly app: ArcApplication) {}
add(tenant: string, sku: Sku, quantity: Quantity, principal: Principal, signal: AbortSignal): Promise<CommandResult> { return this.app.server.executeCommand('AddItemToCart', { sku: sku.value, quantity: quantity.value }, { correlationId: randomUUID(), principal, tenantId: tenant, signal, allowedSeverity: Severity.Warning }); }}Arc for Elixir doesn’t exist; use the Chronicle Elixir client directly.
In .NET, Begin refuses an empty tenant, and the explicit scope wins over the configured resolver, even during an HTTP request. Dependency injection is where it goes wrong. A tenant-scoped service, such as Chronicle’s event store or a DbContext, that was already resolved in an existing DI scope stays bound to that scope’s original tenant after the current tenant changes. Create the DI scope inside the tenant scope. The Execute call above takes no scope of its own and creates one for you. Arc for Kotlin and Java take the tenant in CommandExecutionOptions, and Arc for TypeScript in the execution context passed to executeCommand.
The tools you look through
Section titled “The tools you look through”Every cratis chronicle command that works on a namespace takes -n or --namespace. Leave it out, and the CLI uses the active context’s namespace, or Default when the context doesn’t set one.
So when Acme reports that a view stopped updating, cratis chronicle failed-partitions list lists the failed partitions in Default, and an empty list tells you nothing about Acme. Ask for the namespace you mean:
cratis chronicle failed-partitions list -n acmeAdd --debug to any command and the CLI prints the event store and namespace it resolved, each with where the value came from: the option, the context or the built-in default. A context with a namespace saves typing, and it also means the same command answers for a different tenant depending on which context is active. State-changing commands such as observers replay and observers retry-partition take the same -n with the same fallback. They ask for confirmation first, and the target namespace is worth reading before you answer.
Workbench asks you to select the event store and a namespace you’re authorized to inspect, and to confirm both before reading anything into what it shows. Being able to open a namespace in a tool isn’t permission to change anything in it.
Why not a deployment per tenant?
Section titled “Why not a deployment per tenant?”The strongest case against all of this is to share nothing. Give each tenant its own deployment and its own database, and no header mismatch can mix their data. For a handful of large tenants with contractual separation, that’s a reasonable choice, and Arc supports it: UseFixedTenancy resolves every request to one tenant ID, decided once for the whole deployment.
The cost is that every tenant becomes another deployment to upgrade, migrate, back up and watch. Namespaces put many tenants on one Chronicle kernel and one event store, each with its own data. In exchange, the selection path has to be right at every hop, which is why it deserves tests.
A second objection is that the defaults should reject a missing tenant. Arc does fail early in one place. Configure the subdomain resolver without a usable base domain and the application refuses to start, because otherwise every request would take its tenant from a header any client can set. For a missing tenant, though, the resolvers select Default on purpose, so single-tenant applications work with no tenant configuration at all. Arc for .NET has no switch that rejects a request without a tenant, although Arc for Kotlin and Java (cratis.arc.tenancy.required) and Arc for TypeScript (tenancy.required) do. In .NET that check is yours, as middleware or an authorization rule, next to the membership check.
Seven tests before the second tenant signs up
Section titled “Seven tests before the second tenant signs up”- Send a request with no tenant header. Decide whether default-namespace access is allowed, then assert either that the request is rejected or that it lands in
Defaultby design. - Make sure nothing in your tenant registry can resolve to a tenant named
Default, or that you’re content with it sharing data with requests that carry no tenant. - Through the proxy, as an Acme user, send
Tenant-ID: contosoandx-cratis-tenant-id: contoso, and assert that the write lands inacme. - Register the same author name in
acmeand incontoso. Both should succeed, and if the second is rejected, your namespaces aren’t per tenant. - Run a background job under
ITenantScope.Begin, with the DI scope created inside the tenant scope, and check which namespace its events land in. - Query read models directly for the default tenant and for
acme, and assert that both return data. That one catchesAda+Default. - Run your usual diagnostic command with
--debug, and check that the namespace it reports is the one you meant.
None of these proves that a caller belongs to a tenant. Membership is a separate rule, checked against your own data, and its spec belongs in the same suite as these seven.