Skip to content

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-abc123

When 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.

.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.

cs
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 default
anchor

To supply your own provider (for example an HTTP-header provider), use the generic overload:

cs
services.AddTayra(o => o.LicenseKey = key)
    .UseInMemoryKeyStore()
    .WithMultiTenancy<HttpHeaderTenantProvider>();
anchor

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.

cs
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
    });
anchor

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:

cs
var partitionedServices = new ServiceCollection();
partitionedServices.AddTayra(o => o.LicenseKey = licenseKey)
    .UsePostgreSqlKeyStore(connectionString)
    .WithPostgreSqlMasterKey(connectionString)
    .WithMultiTenancy(t =>
    {
        t.Strategy = TenantStrategy.DataEmbedded;
        t.PartitionBlindIndexKeys = true;
    });
anchor

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:

cs
// 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();
anchor

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:

cs
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; }
}
anchor

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:

cs
var services = new ServiceCollection();
services.AddTayra(opts => opts.LicenseKey = licenseKey);

// Add multi-tenancy with a custom tenant provider
services.AddTayraMultiTenancy<HttpHeaderTenantProvider>(options =>
{
    options.TenantSeparator = ":";
});
anchor

The AddTayraMultiTenancy<T>() method:

  1. Registers your ITenantProvider implementation as a singleton
  2. Removes the existing IKeyStore registration
  3. Wraps it with a TenantAwareKeyStore decorator
  4. Re-registers the decorated store as the IKeyStore service

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:

cs
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 = ":",
};
anchor
PropertyDefaultDescription
TenantSeparator":"The character(s) inserted between the tenant ID and the original key ID
RequireTenantfalseWhen true, key operations throw TayraTenantRequiredException instead of passing through unprefixed when no tenant is set. See Strict mode.
PartitionBlindIndexKeysfalseDataEmbedded 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:

cs
/// <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;
    }
}
anchor

In real applications, you would typically resolve the tenant from:

  • HTTP request headers (e.g., X-Tenant-Id)
  • JWT claims (e.g., a tenant_id claim)
  • 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 settable ITenantProvider, 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 with DeriveMasterKeyIdFromSubjectPrefix(). 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:

cs
services.AddTayra(_ => { })
    .UseVaultKeyStore(vaultAddress, vaultToken);

services.AddTayraMultiTenancy(); // uses AsyncLocalTenantProvider
anchor

Wrap 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:

cs
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);
        }
    }
}
anchor

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:

cs
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
});
anchor

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:

cs
services.AddTayraMultiTenancy(options => options.RequireTenant = true);
anchor

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:

OperationBehavior
StoreAsyncKey is stored with {tenantId}:{keyId}
GetAsyncOnly retrieves keys prefixed with the current tenant
DeleteAsyncOnly deletes keys prefixed with the current tenant
ExistsAsyncOnly checks keys prefixed with the current tenant
DeleteByPrefixAsyncPrefix is scoped to the current tenant
ListKeyIdsAsyncReturns 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 ​