Enforce Tenant Isolation
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
IMediatorat the endpoint, whereHttpContextis available) and worker-side (commands dispatched through the outbox, where there is noHttpContext). An authorizer that reads request-role state belongs on the in-process path; the worker path must decide from theSessionContextalone.
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's
AuthorizationExceptionMiddleware already maps it to HTTP 403 (Forbidden) alongside
AuthorizationException.
Related
- Write a Command Handler — the handler the behavior guards.
- Authorization Decorators — role-based
[RequireRole]enforcement, the other half of the entrance guard. - DI Extensions Cheat Sheet —
AddStrataraTenantIsolation()at a glance.