Multi-Tenancy
Tayra supports multi-tenant key isolation out of the box. When enabled, each tenant's encryption keys are stored with a tenant-specific prefix, ensuring complete cryptographic separation between tenants. Tenant A can never access or decrypt Tenant B's data, even though they share the same physical key store.
How It Works
Multi-tenancy in Tayra is implemented as a decorator pattern. The TenantAwareKeyStore wraps any existing IKeyStore registration and transparently prefixes all key operations with the current tenant ID:
Logical key ID: patient-abc123
Stored key ID: tenant-a:patient-abc123When no tenant context is set (the tenant provider returns null), key operations pass through unchanged. This makes multi-tenancy opt-in and backward-compatible with single-tenant deployments.
Configure once (recommended)
.WithMultiTenancy(...) on the Tayra builder is the one-call entry point. Chain it after .UseXxxKeyStore() and any .WithXxxMasterKey(...), pick a strategy, and Tayra wires the tenant provider, the keystore decorator, envelope master-key resolution, strict mode, and integration auto-flow coherently, with the correct decorator ordering.
Fail-closed by default
Unlike the lower-level AddTayraMultiTenancy(), .WithMultiTenancy(...) seeds RequireTenant = true. A missing tenant throws TayraTenantRequiredException instead of silently sharing one key namespace. Set t.RequireTenant = false to opt back into passthrough.
Ambient (request/handler-scoped)
The tenant comes from an ITenantProvider. By default the settable AsyncLocalTenantProvider is registered (as both the concrete type and ITenantProvider, the same singleton), and the keystore is decorated with TenantAwareKeyStore. When envelope mode is also configured, strict mode is propagated to the envelope resolver in the same call, so you no longer set RequireTenant in two places.
services.AddTayra(o => o.LicenseKey = key)
.UsePostgreSqlKeyStore(cs)
.WithPostgreSqlMasterKey(cs) // one database for brevity: masters get their own table, but belong in separately secured storage
.WithMultiTenancy(t => t.Strategy = TenantStrategy.Ambient); // Ambient is the defaultTo supply your own provider (for example an HTTP-header provider), use the generic overload:
services.AddTayra(o => o.LicenseKey = key)
.UseInMemoryKeyStore()
.WithMultiTenancy<HttpHeaderTenantProvider>();Because ambient tenancy registers AsyncLocalTenantProvider and TenantAwareKeyStore, the Wolverine auto-flow and the Marten async-projection startup advisory light up automatically.
DataEmbedded (background-safe)
The tenant lives in the data: the master-key id is derived from the leading segment of the DEK key id (for example orgA from orgA:patient1). This needs no ambient tenant, so per-tenant envelope isolation holds in Marten's async projection daemon, projection rebuilds, and Wolverine handlers.
services.AddTayra(o => o.LicenseKey = key)
.UsePostgreSqlKeyStore(cs)
.WithPostgreSqlMasterKey(cs) // one database for brevity: masters get their own table, but belong in separately secured storage
.WithMultiTenancy(t =>
{
t.Strategy = TenantStrategy.DataEmbedded;
t.SubjectIdSeparator = ':'; // default ':'
t.MasterKeyTemplate = "tayra:master:{tenantId}"; // default
});DataEmbedded requires envelope mode: call .WithXxxMasterKey(...) before .WithMultiTenancy(...), otherwise the call throws InvalidOperationException. It registers neither AsyncLocalTenantProvider nor TenantAwareKeyStore (ambient prefixing would double-scope). The resolver throws EnvelopeFormatException when the separator is absent, so DataEmbedded is inherently fail-closed.
Blind indexes across tenants
By default a blind index uses one HMAC key per scope for the whole store, so the same value produces the same index in every tenant. A surname or national id that a patient has at two practices hashes identically at both, and anyone who can read the index columns can join those records across tenants, or run frequency analysis over every tenant at once. Encrypting the value does not prevent this: the index is what links the records.
Under DataEmbedded, set PartitionBlindIndexKeys to give each tenant its own HMAC keys:
var partitionedServices = new ServiceCollection();
partitionedServices.AddTayra(o => o.LicenseKey = licenseKey)
.UsePostgreSqlKeyStore(connectionString)
.WithPostgreSqlMasterKey(connectionString)
.WithMultiTenancy(t =>
{
t.Strategy = TenantStrategy.DataEmbedded;
t.PartitionBlindIndexKeys = true;
});Writing. The tenant is taken from the record's [DataSubjectId] exactly as the master key is: the leading segment of the subject key id up to SubjectIdSeparator, so orgA for orgA:patient1. No ambient tenant is involved, so this works in Marten's async daemon, projection rebuilds, and Wolverine handlers. Each tenant's HMAC key is stored as {tenant}:bi:{scope} (orgA:bi:default), which means:
- it is wrapped by that tenant's master key (
tayra:master:orgA), like the tenant's DEKs; - destroying the tenant's master, or deleting its keys by prefix, destroys its blind index keys too, so the tenant's remaining index values can no longer be linked to anything;
- leaking one tenant's HMAC key exposes that tenant only.
Querying. A query must name the tenant, because the value alone no longer determines the hash:
// One value
var hash = await tenantTayra.ComputeBlindIndexForTenantAsync("Jansen", "SurnameIndex", typeof(TenantPatient), "orgA");
// Several values, for an array blind index
var hashes = await tenantTayra.ComputeBlindIndexesForTenantAsync(terms, "NameSearchTermsIndex", typeof(TenantPatient), "orgA");
// A LINQ predicate: works with any provider (Marten, EF Core, MongoDB)
var match = await blindIndexer.BuildPredicateForTenantAsync((TenantPatient x) => x.Surname, "Jansen", "orgA");
var orgAPatients = patients.Where(match).ToList();EF Core users also have blindIndexer.ComputeBlindIndexForTenantAsync<Patient>(value, indexName, tenant). Querying a partitioned index without a tenant throws InvalidOperationException rather than returning a hash that silently matches nothing. A query for a tenant that has no HMAC key yet matches nothing and does not create one, so a mistyped tenant id cannot create keys (or, in envelope mode, master keys) from a read.
Shared lookups. An index that must match across tenants, such as a login email, can opt out with SharedAcrossTenants. It keeps one bi:{scope} key for the whole store and is queried without a tenant:
public class UserAccount
{
[DataSubjectId]
public string UserId { get; set; } = "";
// A login must find the account whichever tenant it belongs to, so this index keeps one
// HMAC key for the whole store and is queried without a tenant.
[PersonalData, BlindIndex(Scope = "login", SharedAcrossTenants = true)]
public string Email { get; set; } = "";
public string? EmailIndex { get; set; }
}The fluent equivalent is .SharedAcrossTenants() on BlindIndex(...), ArrayBlindIndex(...), and CompoundBlindIndex(...). Passing a tenant when querying a shared index throws ArgumentException, so code cannot believe it is searching one tenant when it is searching all of them.
Edge cases.
- A record whose only subject id is still null (
[DataSubjectId(AllowMissing = true)]) has no tenant, so its partitioned indexes are left null and it is not searchable until the subject is set. Hashing it under a shared key would link it across tenants. - A subject id without the separator, or a record whose subject ids name two different tenants, throws
InvalidOperationException. - A tenant's key can be rotated on its own with
IBlindIndexKeyProvider.RotateKeyForTenantAsync(scope, tenant); only that tenant's indexes then need a recompute.
Turning this on changes every partitioned index value
PartitionBlindIndexKeys changes the key behind every index that is not SharedAcrossTenants. Existing rows stop matching until a blind index recompute has rewritten their companions, which the recompute does per row, taking each row's tenant from its subject id. Enable it before indexing production data where you can.
Under Ambient tenancy blind index keys are always per tenant already, because TenantAwareKeyStore prefixes every key id with the current tenant; PartitionBlindIndexKeys has no effect there and SharedAcrossTenants is not supported (it throws, since no key can be shared).
The sections below document the lower-level building blocks that .WithMultiTenancy(...) composes (ITenantProvider, AsyncLocalTenantProvider, RequireTenant, and DeriveMasterKeyIdFromSubjectPrefix). Use them directly when you need finer control; otherwise prefer the one-call form above, which also fixes decorator ordering and sets strict mode in one place.
Setup
Register multi-tenancy after your key store registration. You must provide an ITenantProvider implementation that resolves the current tenant:
var services = new ServiceCollection();
services.AddTayra(opts => opts.LicenseKey = licenseKey);
// Add multi-tenancy with a custom tenant provider
services.AddTayraMultiTenancy<HttpHeaderTenantProvider>(options =>
{
options.TenantSeparator = ":";
});The AddTayraMultiTenancy<T>() method:
- Registers your
ITenantProviderimplementation as a singleton - Removes the existing
IKeyStoreregistration - Wraps it with a
TenantAwareKeyStoredecorator - Re-registers the decorated store as the
IKeyStoreservice
Register Key Store First
You must register Tayra via AddTayra() (which defaults to the built-in InMemoryKeyStore, or chain a key store like .UseVaultKeyStore()) before calling AddTayraMultiTenancy(). The extension method decorates the existing IKeyStore registration, so it throws InvalidOperationException if no IKeyStore is found.
Configuration Options
The TayraMultiTenancyOptions class controls the key prefixing behavior:
var mtOptions = new TayraMultiTenancyOptions
{
// Separator between tenant ID and key ID (default: ":")
// With tenant "tenant-a" and key "patient-abc123",
// the actual key stored is "tenant-a:patient-abc123"
TenantSeparator = ":",
};| Property | Default | Description |
|---|---|---|
TenantSeparator | ":" | The character(s) inserted between the tenant ID and the original key ID |
RequireTenant | false | When true, key operations throw TayraTenantRequiredException instead of passing through unprefixed when no tenant is set. See Strict mode. |
PartitionBlindIndexKeys | false | DataEmbedded only: gives each tenant its own blind index HMAC keys. See Blind indexes across tenants. |
With the default separator, a key ID patient-abc123 for tenant acme becomes acme:patient-abc123 in the underlying key store.
Implementing ITenantProvider
The ITenantProvider interface has a single method that returns the current tenant ID:
/// <summary>
/// Example tenant provider that resolves the tenant ID from
/// an ambient context. In a real application, this would read
/// from HttpContext headers, JWT claims, or a similar source.
/// </summary>
public class HttpHeaderTenantProvider : ITenantProvider
{
// In production, inject IHttpContextAccessor and read from headers/claims
private static readonly AsyncLocal<string?> CurrentTenant = new();
public string? GetCurrentTenantId()
{
return CurrentTenant.Value;
}
/// <summary>
/// Sets the current tenant for the async flow. Call this in middleware
/// or at the start of a request pipeline.
/// </summary>
public static void SetTenant(string? tenantId)
{
CurrentTenant.Value = tenantId;
}
}In real applications, you would typically resolve the tenant from:
- HTTP request headers (e.g.,
X-Tenant-Id) - JWT claims (e.g., a
tenant_idclaim) - Subdomain (e.g.,
acme.yourapp.com) - Route parameters (e.g.,
/api/{tenantId}/patients)
ASP.NET Core Integration
For ASP.NET Core applications, inject IHttpContextAccessor into your tenant provider to read headers or claims from the current request. Register it with services.AddHttpContextAccessor().
Non-HTTP contexts (Marten async daemon, projection rebuilds, Wolverine handlers)
An HTTP-only ITenantProvider returns null outside a request. In Marten's async projection daemon, projection rebuilds, and Wolverine handlers there is no ambient HTTP context, so a naive provider silently drops the tenant - keys are then stored unprefixed and, in envelope mode, wrapped under a single shared master key. That is a silent loss of tenant isolation exactly where it is hardest to notice.
Tayra supports two models for keeping tenant isolation in these contexts. Pick per workload:
- Ambient tenancy -
TenantAwareKeyStore+ a settableITenantProvider, with the tenant set per unit of work. Works wherever the work runs in a flow you control (request handling, Wolverine handlers). This is the model described immediately below. - Data-embedded tenancy - the tenant/org is carried in the
[DataSubjectId]value itself (orgA:patient1) and the master key is derived from that prefix withDeriveMasterKeyIdFromSubjectPrefix(). It consults no ambient state, so it is correct in every background context including the Marten async daemon, where ambient tenancy cannot reach. See Deriving the master key from the subject id.
Ambient tenancy
Tayra ships AsyncLocalTenantProvider, a settable ambient provider you can drive from background code. Register it with the non-generic overload, which registers both the concrete type and ITenantProvider as the same singleton so you can resolve it to call BeginScope:
services.AddTayra(_ => { })
.UseVaultKeyStore(vaultAddress, vaultToken);
services.AddTayraMultiTenancy(); // uses AsyncLocalTenantProviderWrap each background unit of work in a scope using the tenant known at that point. BeginScope restores the previous value on dispose, so scopes nest safely:
public partial class RebuildStep(AsyncLocalTenantProvider tenants)
{
public async Task RunAsync(IDocumentSession session)
{
using (tenants.BeginScope(session.TenantId))
{
// TenantAwareKeyStore now prefixes with session.TenantId
await DoWorkAsync(session);
}
}
}Envelope mode: prefer a data-derived master key
When you use envelope encryption, you can avoid the ambient tenant entirely by deriving the master key id from the data being encrypted. DeriveMasterKeyIdFromSubjectPrefix() takes the leading segment of the DEK key id (for example orgA from orgA:patient1) as the tenant. This needs no ambient tenant and is inherently correct in every background context. See Deriving the master key from the subject id.
Wolverine handlers (automatic)
When you register the settable provider with the parameterless AddTayraMultiTenancy() and use Tayra's Wolverine integration (UseTayra()), the tenant flows automatically from Envelope.TenantId. No BeginScope call is needed in your handlers:
builder.Services
.AddTayra(o => o.LicenseKey = config["Tayra:License"]!)
.UsePostgreSqlKeyStore(config.GetConnectionString("KeyStore")!);
builder.Services.AddTayraMultiTenancy(); // settable AsyncLocalTenantProvider
builder.Host.UseWolverine(opts =>
{
opts.UseTayra(); // serializer + tenant middleware wired automatically
});Two seams cooperate: TayraMessageSerializer scopes message-body encrypt/decrypt to Envelope.TenantId, and TayraTenantMiddleware sets the same tenant for the whole handler, so Marten session work opened inside the handler (including Wolverine's outbox and tenant sessions) also keys off it. When the envelope carries no tenant the previous unprefixed passthrough is preserved, and the flow is off entirely if you registered a custom provider with AddTayraMultiTenancy<T>(). Manual BeginScope stays available for work that runs outside a handler.
Marten async daemon and projection rebuilds
Ambient tenancy cannot reach the async projection daemon
Marten serializes projected documents on a background channel consumer with a default-tenant root session and no seam to inject a tenant, so an ambient tenant set in a projection or hook does not reach Tayra's key store - this is a Marten architectural boundary, not a Tayra bug. DEKs would then be stored and read under the wrong (or no) tenant prefix, silently masking PII as if it were crypto-shredded.
Tayra logs a startup warning when a TenantAwareKeyStore is combined with a store that has async-lifecycle projections. The supported pattern for daemon workloads is data-embedded tenancy: put the tenant in the [DataSubjectId] value (orgA:patient1) and call DeriveMasterKeyIdFromSubjectPrefix(), which needs no ambient tenant. Enable RequireTenant so any accidental reliance on ambient tenancy fails loudly with TayraTenantRequiredException instead of silently losing isolation.
Strict mode (RequireTenant)
The null-passthrough default is back-compatible but dangerous: a misconfigured background component isolates nothing and does so silently. Set RequireTenant = true to make that failure loud - TenantAwareKeyStore then throws TayraTenantRequiredException instead of passing an operation through unprefixed when no tenant is set:
services.AddTayraMultiTenancy(options => options.RequireTenant = true);The exception message names the fixes: set the tenant with AsyncLocalTenantProvider.BeginScope(...), derive the master key from the data with DeriveMasterKeyIdFromSubjectPrefix(), or turn RequireTenant off if unprefixed passthrough is intentional. In envelope mode, EnvelopeOptions.RequireTenant applies the same strict behavior to the default template master-key resolver (a data-derived resolver never consults the ambient tenant, so it is strict regardless).
Null Tenant Passthrough (hazard unless intentional)
When ITenantProvider.GetCurrentTenantId() returns null and RequireTenant is false (the default), the TenantAwareKeyStore passes all operations through to the inner key store without any prefixing. In a multi-tenant deployment this is almost always a bug - keys stored here have no tenant isolation. Only rely on it when the unprefixed behavior is deliberate:
- Background jobs that legitimately run outside any tenant context - and even then, prefer wrapping them in
AsyncLocalTenantProvider.BeginScope(...)when a tenant is known - System-level keys that are intentionally shared across tenants
- Migration scenarios where you need to access unprefixed keys
If a null tenant should never happen in your deployment, enable strict mode so it fails loudly instead.
Key Isolation Guarantees
The TenantAwareKeyStore provides the following guarantees:
| Operation | Behavior |
|---|---|
StoreAsync | Key is stored with {tenantId}:{keyId} |
GetAsync | Only retrieves keys prefixed with the current tenant |
DeleteAsync | Only deletes keys prefixed with the current tenant |
ExistsAsync | Only checks keys prefixed with the current tenant |
DeleteByPrefixAsync | Prefix is scoped to the current tenant |
ListKeyIdsAsync | Returns only the current tenant's keys, with the tenant prefix stripped |
Tayra's in-process key caches follow the same rule: a cached DEK or blind index key is cached under the id it is stored under, so two tenants asking for the same subject id or the same blind index scope never share a cached key. A warm cache does not bypass RequireTenant either.
Production Deployment
In production multi-tenant deployments, always ensure your ITenantProvider returns a non-null value for tenant-scoped requests. A null tenant in a multi-tenant context could lead to keys being stored without isolation, potentially accessible from other tenant contexts.
See Also
- Dependency Injection - Core service registration
- Key Stores - Key store implementations
- Health Checks - Monitoring key store connectivity
