Session Context
Derived page. The behaviour described here is specified by the
session-contextcapability underopenspec/specs/. That specification is the source; this page explains and illustrates it. Where the two disagree, the specification is right and this page is a bug.
Every call in a Stratara application carries a session context. It holds one answer to two questions: who is asking, and whose data is this. It follows the call from the transport that authenticated it to the store that persists it. Routing, tenant-aware encryption, query filtering, tenant isolation and audit stamping all read that one answer, so they agree with each other and none of them works out identity for itself.
The value is the SessionContext record from Stratara.Contracts. You read and set it through
ISessionContextProvider from Stratara.Abstractions. The provider and the HTTP middleware ship in
Stratara.Sessions.
Actor and data owner
A session context carries two separate identities:
- The actor is the principal who triggered the operation:
ActorTenantIdandActorUserId. The audit trail follows the actor. - The data owner (the subject) is the principal whose data the operation concerns:
TenantIdandUserId. Routing, the encryption scope and the query filter follow the data owner.
The naming rule has no exceptions. A tenant or user identifier with no prefix always means the data owner, and an actor identifier always says Actor.
Most of the time the two are the same principal. A user who works on their own tenant's data is both actor and data owner. They differ in three cases:
| Situation | Data owner | Actor |
|---|---|---|
| A privileged operation acts on a foreign tenant | the target tenant | the tenant of the principal who triggered it |
| An anonymous request | whatever the endpoint sets | the empty identifier (Guid.Empty) |
| Work the platform starts for a tenant — a timer, a saga step, a sweep | the tenant the work is for | SessionContext.SystemActorTenantId and SessionContext.SystemActorUserId (build it with SessionContext.ForPlatform) |
The system values are reserved sentinels. They are not Guid.Empty, so an audit record can tell a
system flow from an anonymous request.
For a privileged cross-tenant operation, the endpoint first authorizes the caller. It then promotes the data-owner tenant to the target and leaves the actor unchanged. After that, everything derived from the data owner follows the target tenant, and the audit record still names the admin who did the work:
using Stratara.Abstractions.Session;
public sealed class ForeignTenantScope(ISessionContextProvider sessions)
{
// Call only after the caller has been authorized to act on targetTenantId.
public void ActOn(Guid targetTenantId)
{
var current = sessions.Current
?? throw new SessionRequiredException("No session context is set.");
sessions.Set(current with { TenantId = targetTenantId });
}
}
In strict mode, Enforce Tenant Isolation sends exactly this case (actor tenant differs from data-owner tenant) through an authorizer.
The connection identity
A session context also carries a third identity that belongs to neither the actor nor the data
owner. ClientId names the connection the operation arrived on: a browser tab, a phone call, or a
server-rendered session. Use it when you need to address that one connection. It is optional,
because not every operation has a connection behind it.
On an HTTP request, the value comes from the X-Client-Id header (StrataraHeaderNames.ClientId):
- A header with a parsable identifier puts that identifier on the context as
ClientId. - If the header is absent, empty or not parsable,
ClientIdisnull. Nothing gets a default value, and the request is not rejected because of the header.
The context is ambient and settable
Any component in the call can read the current context from ISessionContextProvider.Current. You
don't pass it through method signatures. The provider is registered per scope, so within one HTTP
request, one worker message or one saga step, every component that resolves it reads the same
context.
- Before anything sets it,
Currentisnull. The provider never makes up a context. Set(context)replaces the value, and every later read sees the new context. The promotion above is an example: a secondSetreplaces the first.Clear()removes the value at the end of a unit of work, and later reads seenullagain.
A flow that doesn't start from a request sets its own context. SessionContext.ForPlatform builds
the shape for it: the platform as the actor, the tenant the work is for as the data owner, a fresh
correlation id, and a causation id of its own — no command preceded the work, and the store requires
one of every event it appends. Clear the context when the work is done:
using Stratara.Abstractions.Session;
using Stratara.Contracts.Session;
public sealed class NightlyInvoiceRun(ISessionContextProvider sessions)
{
public Task RunForTenantAsync(Guid tenantId, CancellationToken cancellationToken)
{
sessions.Set(SessionContext.ForPlatform(tenantId));
try
{
// Work that reads the ambient context: repositories, encryption, audit.
return Task.CompletedTask;
}
finally
{
sessions.Clear();
}
}
}
The audit trail then says the platform acted for the tenant, rather than saying the tenant acted.
Strict tenant isolation recognises the shape and lets it through without consulting the cross-tenant
authorizer — the platform acting for a tenant is not one tenant acting on another. The
data-owner check still applies: such a session reaches exactly the one tenant it names. A host that
would rather decide for itself sets AuthorizePlatformActor (see
Enforce tenant isolation).
Register the provider with services.AddSessionContext(). The worker composite
AddCommonFrameworkServices() from Stratara.EventSourcing.WorkerDefaults already calls it. The
framework's workers set the context themselves for each message they process. In tests,
TestSessionContextProvider from Stratara.Testing gives you a preset context (see
Testing Patterns).
An authenticated HTTP request populates the context from its claims
SessionContextMiddleware fills in the context from the request's principal, so it is set before
the request reaches your endpoints. Place the middleware after authentication, because it only
works with a principal that has already been authenticated:
builder.Services.AddSessionContext();
var app = builder.Build();
app.UseAuthentication();
app.UseMiddleware<SessionContextMiddleware>();
What happens depends on the request:
- Authenticated principal. The middleware sets a context for the request:
- The user identity comes from the principal's name-identifier claim (
ClaimTypes.NameIdentifier). - The tenant comes from the
stratara:tenant_idclaim (StrataraClaimTypes.TenantId), resolved as described in the next section. - The actor defaults to the data owner:
ActorTenantIdandTenantIdare the same tenant, and the user from the claim isActorUserId. - The data-owner user,
UserId, is left unset. The scope a protected field is encrypted under follows the data owner, so filling in the user would move every protected field written from a request to a per-user scope, and data already written under the tenant scope could no longer be read the same way.
- The user identity comes from the principal's name-identifier claim (
- Principal not authenticated. No context is set, and the request continues.
Currentstaysnull. What the caller receives then is described in An anonymous caller is answered 401. - No name-identifier claim, or one that isn't a parsable identifier. The user identity is the
empty identifier (
Guid.Empty). The request is not rejected.
To act on another tenant's data, promote the data-owner tenant after authorization, as shown in Actor and data owner.
An anonymous caller is answered 401
An operation that must be attributed to somebody fails when no context is set: saving events,
recording a command audit, and dispatching a command, on the bus path and on the Orleans execution
model's path alike. The failure is SessionRequiredException from Stratara.Abstractions.Session.
It derives from InvalidOperationException, so an existing catch (InvalidOperationException)
still catches it, and its message is the one these operations have always used.
On a host that registered the problem-details mapping, a caller that is not authenticated is
answered 401, not 500:
builder.Services.AddStrataraProblemDetails(); // Stratara.ServiceDefaults.AspNetCore
app.UseExceptionHandler();
- An unguarded endpoint. The
SessionRequiredExceptionthe save or the dispatch throws becomes401. - A guarded endpoint. A role, permission or tenant-access guard that refuses a caller without a
session refuses it as a denial. For a caller that is not authenticated the mapping answers that
denial
401as well, because what the caller lacks is an identity, not a permission. An authenticated caller's denial stays403. - With an authentication scheme. If the host registered a default challenge scheme, the mapping
challenges through it, exactly as
[Authorize]on the same endpoint would: a bearer client receives401withWWW-Authenticateand the problem body, and a cookie client is redirected to the login page. Without a scheme the mapping writes a401problem response itself. - An authenticated caller without a context. The failure is not converted. It means the host
did not run
SessionContextMiddlewareafter authentication, and that stays a loud server error.
You don't need RequireAuthorization() on every endpoint just to keep an anonymous caller from
receiving a 500. Keep it where you want the request refused before your handler runs.
Tenant resolution fails closed
An authenticated principal must not be able to pick the tenant its request runs against. When the
tenant claim is missing or can't be parsed, the data-owner tenant becomes a reserved default
identifier, DefaultTenantIdentifier.Value. A tenant sent by the caller is not accepted unless the
host has opted in.
Principal's stratara:tenant_id claim |
Header fallback | Data-owner tenant |
|---|---|---|
| present and parsable | either setting | the claim; the header is not read |
| missing or unparsable | off (the default) | DefaultTenantIdentifier.Value; any X-Tenant-Id header is ignored |
| missing or unparsable | on | the X-Tenant-Id header if it is parsable, otherwise DefaultTenantIdentifier.Value |
The fallback is controlled by SessionContextOptions, which AddSessionContext() — directly or
through AddCommonFrameworkServices() — reads from the SessionContext section of the host's
configuration:
{
"SessionContext": {
"AllowTenantHeader": false
}
}
AllowTenantHeader defaults to false. A value set in code with
services.Configure<SessionContextOptions>(o => o.AllowTenantHeader = false) after
AddSessionContext() takes precedence over the section. A service collection that carries no
configuration — a unit test's bare ServiceCollection — gets the default.
Since 4.2.0 the section is read — check it before you upgrade. Before, AddSessionContext()
did not read the SessionContext section, so "SessionContext": { "AllowTenantHeader": true } in
appsettings.json did nothing unless the host bound the section itself. Now such an entry turns the
tenant-header fallback on. Search every environment's configuration for AllowTenantHeader
before upgrading, and remove the entry or set it to false unless you mean to accept the header.
Turn the fallback on only in two cases: an upstream check (such as a platform-admin role gate) controls who can send the header, or the header is part of a trusted service-to-service contract. Where you can, put the tenant into the principal's claims instead, and the fallback is never needed.
Setting the context stamps the current trace
When a context is set, the provider tags the active trace span with four values. When the context is cleared, it removes those tags. Traces can then be filtered by tenant or by the principal who triggered the work, and no component has to emit those values itself.
| Tag | Value | Constant |
|---|---|---|
correlation.id |
the correlation identity | ApplicationDiagnostics.CorrelationIdTagName |
causation.id |
the causation identity | ApplicationDiagnostics.CausationIdTagName |
tenant.id |
the data-owner tenant | ApplicationDiagnostics.TenantIdTagName |
user.id |
the actor user | ApplicationDiagnostics.UserIdTagName |
The tenant tag is the data owner, and the user tag is the actor. A span for a privileged cross-tenant operation therefore shows the tenant whose data was touched and the admin who touched it. If no span is active, setting and clearing the context still work, and nothing is tagged.
Correlation survives a request that supplies none
Every context the middleware sets has a correlation identity, so every unit of work can be traced:
- If the request has a non-empty trace identifier (
HttpContext.TraceIdentifier), that identifier becomesCorrelationId. - If the trace identifier is empty, the middleware generates a new time-ordered identifier (a version 7 GUID).
The ambient identities are readable without the context
Some components need only one identity. A query filter needs the tenant, and an audit stamp needs
the user. They don't have to depend on the whole context shape. Two small services from
Stratara.Abstractions.Multitenancy return each value on its own:
ITenantService.GetTenantId()returns the data-owner tenant, not the actor's tenant.ICurrentUserService.GetId()returns the actor user, not the data owner's user.
If no context is set, both return the empty identifier (Guid.Empty) instead of throwing. Callers
never have to handle a missing value.
using Stratara.Abstractions.Multitenancy;
public sealed class InvoiceStamp(ITenantService tenants, ICurrentUserService currentUser)
{
public (Guid OwnerTenantId, Guid CreatedByUserId) Stamp() =>
(tenants.GetTenantId(), currentUser.GetId());
}
Register both with services.AddIdentity() from Stratara.Infrastructure, next to
AddSessionContext(). The worker composite registers both.
See also
- Tenant-Aware Encryption: the data-owner tenant as the cryptographic boundary.
- Enforce Tenant Isolation: the entrance guard that compares a request's tenant with the data owner, and strict mode for the cross-tenant case.
- Tenant Membership: how the tenant claim this middleware reads gets into the principal.
- Actor vs Subject: the short glossary entry.