Skip to main content
Table of Contents

Enforce Tenant Isolation

Derived page. The behaviour described here is specified by the tenant-isolation and authorization capabilities under openspec/specs/. Those specifications are the source; this page explains and illustrates them. Where the two disagree, the specification is right and this page is a bug.

Stratara.Mediator runs request-level tenant enforcement as a mediator pipeline behavior: a request that opts in is checked before the handler, so a command or query naming a tenant other than the caller's data-owner tenant never reaches your domain logic. It is the command-/query-entrance complement to the database-side tenant query filters (ApplyGlobalTenantQueryFilters) — defence in depth, enforced at the front door instead of relying on every handler to filter correctly.

The marker

ITenantScopedRequest lives in Stratara.Abstractions.Multitenancy (so you can mark a request without referencing the behavior). It exposes the tenant the request operates on:

using Stratara.Abstractions.Multitenancy;

public interface ITenantScopedRequest
{
    Guid TenantId { get; }
}

A request implements both its CQRS contract and the marker. The behavior acts only on requests that implement ITenantScopedRequest; everything else passes through untouched (opt-in).

public sealed record GetAccountQuery(Guid AccountId, Guid TenantId)
    : IQuery<AccountDto>, ITenantScopedRequest;

Subject, not Actor

The behavior compares the request's TenantId against the Subject (the data-owner SessionContext.TenantId), not the Actor (SessionContext.ActorTenantId). For the 95% of requests where actor and subject coincide, that's just "you may only touch your own tenant". The distinction matters only for privileged cross-tenant operations — see Strict mode below. (See Actor vs Subject for the session model.)

Default mode — subject match

Situation Result
request.TenantId == session.TenantId Passes to the handler.
request.TenantId != session.TenantId Blocked — the pipeline throws TenantAccessDeniedException.

A privileged cross-tenant operation (actor tenant ≠ data-owner tenant) still passes in default mode, because the calling endpoint is expected to have promoted the session's data-owner tenant to the target before dispatch. Default mode therefore guards against a payload-forged tenant id without getting in the way of a legitimate admin flow.

Strict mode — gate the cross-tenant case

TenantIsolationMode.Strict keeps the subject check and routes every cross-tenant operation (actor tenant ≠ data-owner tenant) through an ICrossTenantAuthorizer. The shipped default denies all, so strict mode forbids cross-tenant access until you register an authorizer that grants it:

using Stratara.Abstractions.Multitenancy;
using Stratara.Contracts.Session;

internal sealed class PlatformAdminCrossTenantAuthorizer(IHttpContextAccessor http)
    : ICrossTenantAuthorizer
{
    public ValueTask<bool> IsCrossTenantAllowedAsync(SessionContext session, CancellationToken ct) =>
        ValueTask.FromResult(http.HttpContext?.User.IsInRole("PlatformAdmin") ?? false);
}

The behavior runs both in-process (queries via IMediator at the endpoint, where HttpContext is available) and worker-side (commands dispatched through the outbox, where there is no HttpContext). An authorizer that reads request-role state belongs on the in-process path; the worker path must decide from the SessionContext alone.

Work the platform starts is not cross-tenant

A durable timer, a saga step or a nightly sweep has no inherited actor. Its session names the platform — build it with SessionContext.ForPlatform(tenantId), see Session Context. Strict mode recognises that shape, permits it without consulting your authorizer, and records it under its own log event (114_104) rather than as a permitted cross-tenant operation. You do not have to teach your authorizer about the framework's own background work.

The subject check applies unchanged: such a session operates on exactly the tenant it names, and a request targeting another one is refused like any other.

A host that would rather decide for itself refers it to the authorizer:

builder.Services.AddStrataraTenantIsolation(o =>
{
    o.Mode = TenantIsolationMode.Strict;
    o.AuthorizePlatformActor = true;
});

With the shipped deny-everything default, that refuses every platform-initiated request until your authorizer permits it — which is the point of the switch, and the reason it is off by default.

Register it

Call AddStrataraTenantIsolation() after AddStrataraValidation() so validation stays the outermost behavior and tenant isolation runs just inside it, still before the handler:

builder.Services
    .AddMediator()
    .AddStrataraValidation()
    .AddStrataraTenantIsolation(o => o.Mode = TenantIsolationMode.Strict)
    .AddCommandHandlersFromAssemblyContaining<Program>()
    .AddQueryHandlersFromAssemblyContaining<Program>();

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICrossTenantAuthorizer, PlatformAdminCrossTenantAuthorizer>();

Map the failure to an HTTP response

TenantAccessDeniedException is declared in Stratara.Abstractions.Multitenancy, so a host can catch it without referencing the behavior. On ASP.NET hosts the framework maps it to HTTP 403 (Forbidden) alongside AuthorizationException, in one RFC 7807 problem shape — or to HTTP 401 when the caller is not authenticated, because what that caller lacks is an identity. The mapping is opt-in — a host that does not register it keeps its own error model, and the exception reaches its own diagnostics unchanged:

builder.Services.AddStrataraProblemDetails();   // Stratara.ServiceDefaults.AspNetCore
app.UseExceptionHandler();

Filter tenant-scoped rows at the database as well

The entrance guard covers requests. It does not cover a query that reaches your database context some other way: a background job, a projection helper, a repository method a handler calls with the wrong id. The second layer, the tenant query filter, is independent on purpose. It constrains every query your code issues through a tenant-scoped context, including the ones the guard never saw.

Two declarations and one call switch it on:

  • Mark the entity with IMultiTenant (Stratara.Abstractions.Entities), which gives it a TenantId. Marking is your decision: an entity that does not implement it gets no filter.
  • Make the context tenant-scoped by implementing ITenantScopedDbContext (Stratara.EventSourcing.EntityFrameworkCore.Abstractions), whose TenantId is the ambient tenant. Usually that is the data-owner tenant of the current session.
  • Call ApplyGlobalTenantQueryFilters(this) on the ModelBuilder in OnModelCreating, after the entities are in the model. It installs a filter on every entity type that implements IMultiTenant at that moment.
using Stratara.Abstractions.Entities;
using Stratara.Abstractions.Session;
using Stratara.EventSourcing.EntityFrameworkCore.Abstractions;
using Stratara.EventSourcing.EntityFrameworkCore.Extensions;

public sealed class InvoiceView : IMultiTenant
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public decimal Total { get; set; }
}

public sealed class InvoiceReadContext(
    DbContextOptions<InvoiceReadContext> options,
    ISessionContextProvider sessions)
    : DbContext(options), ITenantScopedDbContext
{
    public Guid TenantId => sessions.Current?.TenantId ?? Guid.Empty;

    public DbSet<InvoiceView> Invoices => Set<InvoiceView>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<InvoiceView>();
        modelBuilder.ApplyGlobalTenantQueryFilters(this);   // last: filters what is already mapped
    }
}

A query through that context returns only rows whose TenantId matches the context's TenantId. context.Invoices.ToListAsync() cannot return another tenant's invoice, whatever the calling code forgot to check. A context without a session resolves to the empty identifier and sees no tenant's rows. Entity Framework Core's own IgnoreQueryFilters() still switches the filter off for a single query, so treat that call the way you would treat any cross-tenant operation.

The framework's own store entities — event entries, snapshots, hash-chain anchors and the command log — are tenant-scoped too, so calling ApplyGlobalTenantQueryFilters(this) on your write context declares the filter on them as well. It then applies to your own queries of those entities, and to nothing the framework does with its store. The event store is one history: a stream's version and owner are decided across all its entries, a stream may hold events of more than one owner, and the framework reads the store without a session on behalf of every tenant — its readers, a replay, the hash chain, the backfills, a process's timeouts. Every read the framework makes of it therefore goes past every query filter you declare on those entities, not only the tenant filter, and a filtered write context behaves exactly like an unfiltered one.

Neither layer checks the owner of a stream. The guard compares the tenant a request names with the session's. The framework takes a stream's owner from the stream, so that a privileged session can append to another tenant's aggregate without re-homing it. A command that names another tenant's aggregate id therefore loads that aggregate and appends to it, and the event is recorded for the aggregate's owner with the session's actor. A handler that must refuse that checks the owner of the aggregate it loaded, for example with a helper like this one, called with the loaded aggregate's tenant before anything is appended:

using Stratara.Abstractions.Multitenancy;
using Stratara.Abstractions.Session;

public static class AggregateOwnership
{
    public static void EnsureOwnedBySession(Guid aggregateTenantId, ISessionContextProvider sessions)
    {
        var sessionTenantId = sessions.Current?.TenantId ?? Guid.Empty;
        if (aggregateTenantId != sessionTenantId)
        {
            throw new TenantAccessDeniedException(aggregateTenantId, sessionTenantId,
                $"The aggregate belongs to tenant {aggregateTenantId}, not to the session's tenant {sessionTenantId}.");
        }
    }
}

Two framework calls into your read models run without a session: a view truncator before a replay, and a rebuildable projection's truncation before a rebuild. On a read context with the tenant filter, a truncator that does not call IgnoreQueryFilters() deletes nothing, and the replay then applies on top of the old rows.