Security Context Propagation¶
Verified by tests
MessageHopSecurityExtractorTests, MessageSecurityContextProviderTests, MessageSecurityIntegrationTests, MessageSecurityOptionsTests, MessageSecurityServiceCollectionExtensionsTests, ImmutableScopeContextTests, ScopeContextAccessorTests — library CI run #31657041675 (2026-08-13)
Security context propagation ensures that security identity (TenantId, UserId, roles, permissions) flows seamlessly across service boundaries in distributed systems. Whizbang provides automatic propagation from HTTP requests through message processing without manual intervention.
Overview¶
In distributed systems, security context must flow across multiple hops:
- HTTP Request → API receives authenticated user request
- Message Dispatch → API sends command/event to message bus
- Message Transport → Azure Service Bus, RabbitMQ, etc.
- Message Receipt → Consumer service receives message
- Business Logic → Handler executes with original security context
Without propagation, each service must manually extract and forward security information. With Whizbang, security context flows automatically.
Architecture¶
flowchart TD
Request["HTTP Request<br/>(Bearer Token / Cookie)"]
Middleware["WhizbangScopeMiddleware (HTTP)<br/>• Extracts JWT claims<br/>• Populates IScopeContextAccessor"]
Logic["Business Logic / Controller<br/>• Calls dispatcher.SendAsync()"]
Dispatcher["Dispatcher (Outgoing Messages)<br/>• Reads IScopeContextAccessor<br/>• Attaches to MessageHop"]
Transport["Transport (Azure Service Bus, etc.)<br/>• Carries MessageHop.SecurityContext"]
Consumer["ServiceBusConsumerWorker (Incoming)<br/>• Creates DI scope<br/>• Calls provider.EstablishContextAsync()"]
Extractor["MessageHopSecurityExtractor<br/>• Reads MessageHop.SecurityContext<br/>• Populates IScopeContextAccessor"]
Callbacks["ISecurityContextCallback[]<br/>• Initialize custom services<br/>• Populate UserContextManager"]
Receptor["Receptor / Business Logic<br/>• Executes with full security ctx"]
Request --> Middleware
Middleware --> Logic
Logic --> Dispatcher
Dispatcher --> Transport
Transport --> Consumer
Consumer --> Extractor
Extractor --> Callbacks
Callbacks --> Receptor
HTTP to Message Propagation¶
Step 1: HTTP Request Establishes Context¶
The WhizbangScopeMiddleware (shipped in Whizbang.Transports.HotChocolate) extracts security context from HTTP headers and JWT claims. Register with AddWhizbangScope() / UseWhizbangScope() and configure via WhizbangScopeOptions:
Step 1: HTTP Request Establishes Context
// Program.cs
builder.Services.AddWhizbangScope(options => {
options.TenantIdClaimTypes = ["tenant_id"]; // JWT claim(s) for tenant
options.UserIdClaimTypes = ["sub", "oid"]; // JWT claim(s) for user
options.TenantIdHeaderName = "X-Tenant-Id"; // header fallback
});
app.UseWhizbangScope();
This middleware:
- Extracts claims from JWT bearer tokens (and header fallbacks)
- Maps claims to IScopeContext properties
- Populates IScopeContextAccessor.Current
- Makes context available to downstream code
Step 2: Dispatcher Reads Ambient Context¶
When business logic dispatches a message, the dispatcher automatically reads the ambient security context:
Step 2: Dispatcher Reads Ambient Context
// In your controller or service
public class OrderController : ControllerBase {
private readonly IDispatcher _dispatcher;
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request) {
// Dispatcher reads IScopeContextAccessor.Current automatically
await _dispatcher.SendAsync(new CreateOrder {
CustomerId = request.CustomerId,
Items = request.Items
});
return Ok();
}
}
No manual context passing required - the dispatcher finds it via IScopeContextAccessor.
Step 3: Security Context Attached to MessageHop¶
The dispatcher attaches the scope to the message's hop as a ScopeDelta (MessageHop.Scope) — only the changes from the previous hop are serialized (see Scope Propagation):
Step 3: Scope Attached to MessageHop
// Inside the dispatcher (conceptual)
var scopeContext = _scopeContextAccessor.Current;
if (scopeContext is ImmutableScopeContext immutable && immutable.ShouldPropagate) {
var hop = new MessageHop {
Type = HopType.Current,
ServiceInstance = _serviceInstance,
// Full scope on the first hop; only the delta from the previous hop afterwards
Scope = ScopeDelta.CreateDelta(previousScope, scopeContext)
};
envelope.Hops.Add(hop);
}
Key Point: ImmutableScopeContext.ShouldPropagate controls whether security flows to downstream services. MessageHop has no SecurityContext property — the scope travels as ScopeDelta on MessageHop.Scope (wire name "sc").
Step 4: Message Serialized with SecurityContext¶
The message envelope, including the hop chain with its scope delta, is serialized and sent to the transport. Hop properties use abbreviated wire names (ty = Type, si = ServiceInstance, ts = Timestamp, sc = Scope delta):
Step 4: Message Serialized with Scope Delta
{
"messageId": "123e4567-e89b-12d3-a456-426614174000",
"messageType": "MyApp.Orders.CreateOrder",
"payload": { "customerId": "cust-456", "items": [] },
"hops": [
{
"ty": 0,
"si": { "sn": "OrderApi", "ii": "3f6b…", "hn": "orderapi-prod-1", "pi": 1234 },
"ts": "2026-03-03T10:00:00Z",
"sc": {
"v": { "Sc": { "t": "tenant-123", "u": "user-789", "o": "org-456" } }
}
}
]
}
Message to Handler Propagation¶
Step 5: Consumer Receives Message¶
The ServiceBusConsumerWorker receives the message from the transport and deserializes the envelope:
Step 5: Consumer Receives Message
// Inside ServiceBusConsumerWorker (conceptual)
var envelope = await DeserializeEnvelopeAsync(serviceBusMessage);
// Create DI scope for this message
await using var scope = _serviceProvider.CreateAsyncScope();
// Establish security context BEFORE executing handlers
// (IMessageSecurityContextProvider.EstablishContextAsync returns the IScopeContext)
await _securityContextProvider.EstablishContextAsync(
envelope,
scope.ServiceProvider,
cancellationToken);
// Now dispatch the message to its receptors (internal consumer path)
await DispatchToReceptorsAsync(envelope, scope.ServiceProvider, cancellationToken);
Step 6: Security Context Extracted from Hops¶
The MessageHopSecurityExtractor merges the ScopeDelta from every Current hop (via ScopeDelta.ApplyTo) to rebuild the full scope — roles, permissions, principals, claims, and impersonation info included:
Step 6: Security Context Extracted from Hops
public sealed partial class MessageHopSecurityExtractor : ISecurityContextExtractor {
// Extractors run in ascending Priority order (lower runs first).
public int Priority => 100;
public ValueTask<SecurityExtraction?> ExtractAsync(
IMessageEnvelope envelope,
MessageSecurityOptions options,
CancellationToken cancellationToken = default) {
// Merge ScopeDelta from all Current hops to produce the full ScopeContext
var scopeContext = _mergeScopeDeltas(envelope.Hops, _logger, envelope.MessageId);
// No scope in the hop chain, or empty scope (no TenantId/UserId) → nothing to extract
if (scopeContext is null ||
(string.IsNullOrEmpty(scopeContext.Scope.TenantId) &&
string.IsNullOrEmpty(scopeContext.Scope.UserId))) {
return ValueTask.FromResult<SecurityExtraction?>(null);
}
// Map to SecurityExtraction with FULL context from the merged deltas
return ValueTask.FromResult<SecurityExtraction?>(new SecurityExtraction {
Scope = scopeContext.Scope,
Roles = scopeContext.Roles,
Permissions = scopeContext.Permissions,
SecurityPrincipals = scopeContext.SecurityPrincipals,
Claims = scopeContext.Claims,
ActualPrincipal = scopeContext.ActualPrincipal,
EffectivePrincipal = scopeContext.EffectivePrincipal,
ContextType = scopeContext.ContextType,
Source = "MessageHop"
});
}
}
Step 7: Context Populated and Callbacks Invoked¶
The DefaultMessageSecurityContextProvider establishes the context:
Step 7: Context Populated and Callbacks Invoked
// 1. Extract security (via MessageHopSecurityExtractor)
var extraction = await extractor.ExtractAsync(envelope, options, ct);
// 2. Wrap in ImmutableScopeContext
var context = new ImmutableScopeContext(extraction, shouldPropagate: true);
// 3. Invoke the audit callback (if EnableAuditLogging and a callback is wired)
if (options.EnableAuditLogging) {
onAuditEvent?.Invoke(new ScopeContextEstablished {
Scope = context.Scope,
Roles = context.Roles,
Permissions = context.Permissions,
Source = "MessageHop",
Timestamp = DateTimeOffset.UtcNow
});
}
// 4. Invoke all callbacks
foreach (var callback in callbacks) {
await callback.OnContextEstablishedAsync(context, envelope, scopedProvider, ct);
}
// 5. Return the context — the caller (SecurityContextHelper) sets
// IScopeContextAccessor.Current with it for the message's DI scope
return context;
Step 8: Handler Executes with Context¶
The receptor now has full access to the original security context:
Step 8: Handler Executes with Context
public class CreateOrderReceptor : IReceptor<CreateOrder> {
private readonly IScopeContextAccessor _scopeAccessor;
private readonly IScopedLensFactory _lensFactory;
public async ValueTask HandleAsync(CreateOrder message, CancellationToken ct) {
var context = _scopeAccessor.Current!;
// Same TenantId and UserId from original HTTP request
Console.WriteLine($"Tenant: {context.Scope.TenantId}");
Console.WriteLine($"User: {context.Scope.UserId}");
// Scoped READS use the propagated context (lenses are read models —
// writes happen through events applied by perspectives)
var orderLens = _lensFactory.GetUserLens<IOrderLens>();
var myOrders = await orderLens.Query.ToListAsync(ct);
}
}
Controlling Propagation¶
Enable/Disable Globally¶
Enable/Disable Globally
services.AddWhizbangMessageSecurity(options => {
// Enable/disable propagation globally
options.PropagateToOutgoingMessages = true; // default
});
Per-Context Control¶
Per-Context Control
// Create context with propagation enabled
var extraction = new SecurityExtraction { /* ... */ };
var propagate = new ImmutableScopeContext(extraction, shouldPropagate: true);
// Create context that stays local (no propagation)
var local = new ImmutableScopeContext(extraction, shouldPropagate: false);
Explicit Context Override¶
For system operations or impersonation, use explicit context:
Explicit Context Override
// System context (no user) — a tenant strategy is REQUIRED before SendAsync
await dispatcher.AsSystem().KeepTenant().SendAsync(new MaintenanceCommand());
// Scope delta on hop: { ContextType = System, EffectivePrincipal = "SYSTEM" }
// Impersonation context — same tenant-strategy requirement
await dispatcher.RunAs("target-user@example.com").ForTenant("user-tenant").SendAsync(command);
// Scope delta on hop: { ContextType = Impersonated, ActualPrincipal = "admin@...", EffectivePrincipal = "target-user@..." }
Multi-Hop Propagation¶
Security context flows across multiple service hops:
HTTP → Service A → Service B → Service C
User makes request
↓ (JWT)
Service A (API)
↓ MessageHop.Scope (ScopeDelta) = { TenantId, UserId }
Service B (Worker)
↓ hop without "sc" → scope inherited unchanged
Service C (Processor)
↓ All services see same TenantId, UserId
Each service adds a new hop to the chain. A hop without an "sc" (ScopeDelta) property inherits the scope unchanged from the previous hop — nothing is re-serialized when nothing changed:
Multi-Hop Propagation
{
"hops": [
{
"ty": 0,
"si": { "sn": "ServiceA" },
"sc": { "v": { "Sc": { "t": "t1", "u": "u1" } } }
},
{
"ty": 0,
"si": { "sn": "ServiceB" }
}
]
}
(ty: 0 = HopType.Current; ty: 1 = HopType.Causation, a hop carried forward from the parent message for distributed tracing.)
Audit Trail¶
Every security context establishment is audited (when EnableAuditLogging = true):
Audit Trail
public sealed record ScopeContextEstablished : ISystemEvent {
public Guid Id { get; init; } = TrackedGuid.NewMedo();
public required PerspectiveScope Scope { get; init; }
public required IReadOnlySet<string> Roles { get; init; }
public required IReadOnlySet<Permission> Permissions { get; init; }
public required string Source { get; init; } // "MessageHop", "JwtPayload", etc.
public required DateTimeOffset Timestamp { get; init; }
}
This enables: - Security audits: Who accessed what, when - Compliance: GDPR, HIPAA, SOC 2 audit trails - Debugging: Trace security context flow across services - Monitoring: Detect unauthorized access attempts
Security Considerations¶
1. Trust Boundaries¶
Problem: Services within the trust boundary should accept the scope carried on MessageHop.Scope from other services, but messages from external sources should not.
Solution: Use different extractors for internal vs external messages:
Trust Boundaries
// Internal service-to-service: Trust MessageHop (built-in, Priority 100)
services.AddSecurityExtractor<MessageHopSecurityExtractor>();
// External API: your own extractor that validates a JWT in the payload.
// Extractors run in ascending Priority order — Priority 50 runs BEFORE 100.
services.AddSecurityExtractor<JwtPayloadExtractor>(); // custom ISecurityContextExtractor, Priority 50
2. Token Expiration¶
Problem: Long-running message processing may outlive the original JWT token.
Solution: Extract security at message ingress, not at processing time. The scope on MessageHop.Scope is a snapshot, not a live token.
3. Privilege Escalation¶
Problem: Malicious service could forge the MessageHop.Scope delta to impersonate users.
Solution:
- Use message signing/encryption for cross-service communication
- Validate message signatures before trusting security context
- Use AsSystem() or RunAs() with explicit audit trails for elevated operations
4. Cross-Tenant Isolation¶
Problem: Bug in one service could leak data across tenants.
Solution:
- Always use IScopedLensFactory for queries (automatic tenant filtering)
- Enable audit logging to detect cross-tenant access attempts
- Use database-level row-level security (RLS) as defense-in-depth
Integration with UserContextManager¶
For legacy systems with existing UserContextManager, use a callback to bridge:
Integration with UserContextManager
public class UserContextManagerCallback : ISecurityContextCallback {
private readonly UserContextManager _userContextManager;
public UserContextManagerCallback(UserContextManager userContextManager) {
_userContextManager = userContextManager;
}
public ValueTask OnContextEstablishedAsync(
IScopeContext context,
IMessageEnvelope envelope,
IServiceProvider scopedProvider,
CancellationToken cancellationToken = default) {
// Populate UserContextManager from Whizbang security context
if (context?.Scope != null) {
_userContextManager.SetFromScopeContext(
tenantId: context.Scope.TenantId,
userId: context.Scope.UserId
);
}
return ValueTask.CompletedTask;
}
}
// Register callback
services.AddSecurityContextCallback<UserContextManagerCallback>();
This ensures UserContextManager is populated before any receptor code runs.
Best Practices¶
DO¶
- Enable audit logging for compliance and debugging
- Use IScopedLensFactory for all queries to ensure tenant isolation
- Trust MessageHop security context within your service boundary
- Use callbacks to initialize custom services with security context
- Test cross-service flows to verify security propagation
DON'T¶
- Don't bypass scoped lenses with raw SQL or global queries
- Don't trust security context from external/untrusted sources without validation
- Don't cache security context across requests (it's request-scoped)
- Don't disable propagation unless you have a strong reason
- Don't forget to test security isolation in multi-tenant scenarios
Related Documentation¶
- Message Security - Security context establishment for messages
- Security - Permissions, roles, and access control
- Scoping - Multi-tenancy and data isolation
- Scoped Lenses - Automatic scope-based filtering
- System Events - Audit events and monitoring
Version 1.0.0 - Foundation Release