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.

csharp
services.AddTayra(o => o.LicenseKey = key)
    .UsePostgreSqlKeyStore(cs)
    .WithPostgreSqlMasterKey(cs)
    .WithMultiTenancy(t => t.Strategy = TenantStrategy.Ambient); // Ambient is the default

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

csharp
services.AddTayra(...)
    .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.

csharp
services.AddTayra(o => o.LicenseKey = key)
    .UsePostgreSqlKeyStore(cs)
    .WithPostgreSqlMasterKey(cs)
    .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.

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.

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:

csharp
services.AddTayra(_ => { })
    .UseVaultKeyStore(/* ... */);

services.AddTayraMultiTenancy(); // uses AsyncLocalTenantProvider

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:

csharp
public 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:

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

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

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

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