Message Security Context Propagation¶
Verified by tests
MessageSecurityContextProviderTests, MessageSecurityIntegrationTests, MessageSecurityOptionsTests, MessageSecurityServiceCollectionExtensionsTests, MessageHopSecurityExtractorTests, ImmutableScopeContextTests, MessageContextAccessorTests, SecurityContextHelperTests, DispatcherSecurityBuilderTests, SystemDispatcherBuilderTests, ImpersonationDispatcherBuilderTests — library CI run #31657041675 (2026-08-13)
Whizbang provides automatic security context establishment for incoming messages, ensuring that security identity flows across service boundaries in distributed systems.
Overview¶
When messages arrive from external transports (Azure Service Bus, RabbitMQ, etc.), security context must be established before any business logic executes. The message security system:
- Extracts security information from message hops, payloads, or transport metadata
- Populates
IScopeContextAccessor.Currentfor scoped services - Invokes callbacks for custom service initialization
- Emits audit events for security compliance
Architecture¶
flowchart TD
Arrives["Message Arrives"]
Provider["IMessageSecurityContextProvider<br/>(DefaultMessageSecurityContextProvider)"]
Extractors["ISecurityContextExtractor[]<br/>• MessageHopSecurityExtractor (100, built-in)<br/>• JwtPayloadExtractor (200, custom)<br/>• TransportMetadataExtractor (300, custom)"]
Context["ImmutableScopeContext<br/>(wraps SecurityExtraction)"]
Accessor["Populates IScopeContextAccessor.Current"]
Callbacks["ISecurityContextCallback[]<br/>• UserContextManagerCallback<br/>• AuditLogCallback"]
Arrives --> Provider
Provider -->|"Calls extractors in priority order"| Extractors
Extractors -->|"First successful extraction wins"| Context
Context --> Accessor
Context -->|"Invokes callbacks"| Callbacks
Quick Start¶
Extraction¶
The message security system uses a provider/extractor pattern to establish security context from incoming messages. The IMessageSecurityContextProvider orchestrates multiple ISecurityContextExtractor implementations in priority order until one successfully extracts security information.
Key Concepts: - Provider: Coordinates extractors and establishes the security context - Extractors: Each attempts to extract security from a specific source (hops, JWT, transport metadata) - Priority: Lower numbers run first (100, 200, 300, etc.) - First Wins: The first successful extraction establishes the context
Registration¶
Registration
services.AddWhizbangMessageSecurity(options => {
// AllowAnonymous defaults to FALSE (least privilege)
// Must explicitly opt-in to allow anonymous messages
options.AllowAnonymous = false;
// Exempt specific message types
options.ExemptMessageTypes.Add(typeof(HealthCheckMessage));
options.ExemptMessageTypes.Add(typeof(SystemDiagnosticMessage));
// Adjust timeout for slow token validation
options.Timeout = TimeSpan.FromSeconds(10);
});
// Register custom extractors
services.AddSecurityExtractor<AppMessageTokenExtractor>();
// Register callbacks
services.AddSecurityContextCallback<UserContextManagerCallback>();
How It Works¶
Explicit Security Context API¶
For system-triggered operations or impersonation scenarios, use the explicit security context API. This is documented in detail in the Explicit Security Context API section below.
Security Context Helper¶
The IMessageSecurityContextProvider provides helper methods for establishing security context from different sources. The provider coordinates extractors and manages the context lifecycle.
Scoped Message Context¶
When ServiceBusConsumerWorker receives a message:
- Creates DI scope
- Calls
IMessageSecurityContextProvider.EstablishContextAsync() - Provider iterates through extractors in priority order (lower = earlier)
- First successful extraction populates
IScopeContextAccessor.Current - All callbacks are invoked with the established context
- Business logic runs with security context available
Configuration Options¶
Configuration Options
public sealed class MessageSecurityOptions {
// When true, allows messages without security context.
// DEFAULT: FALSE (least privilege - must explicitly enable)
public bool AllowAnonymous { get; set; }
// When true, logs security context establishment for audit.
// DEFAULT: TRUE
public bool EnableAuditLogging { get; set; } = true;
// When true, extractors should validate tokens/credentials.
// DEFAULT: TRUE
public bool ValidateCredentials { get; set; } = true;
// Maximum time to wait for security context establishment.
// DEFAULT: 5 seconds
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(5);
// Message types exempt from security requirements.
public HashSet<Type> ExemptMessageTypes { get; } = new();
// When true, propagates security context to cascaded events.
// DEFAULT: TRUE
public bool PropagateToOutgoingMessages { get; set; } = true;
}
Immutable Context¶
The established security context is wrapped in ImmutableScopeContext, which provides:
- Immutability: Cannot be modified after establishment
- Source tracking: Which extractor created it
- Timestamp: When it was established
- Propagation flag: Whether to include in outgoing messages
Immutable Context
var context = await provider.EstablishContextAsync(envelope, scopedProvider, ct);
if (context is ImmutableScopeContext immutable) {
Console.WriteLine($"Source: {immutable.Source}");
Console.WriteLine($"Established: {immutable.EstablishedAt}");
Console.WriteLine($"Propagate: {immutable.ShouldPropagate}");
}
See the ImmutableScopeContext section below for full details.
Built-in Extractors¶
MessageHopSecurityExtractor (Priority: 100)¶
Extracts security context from the message envelope's hop chain. This is the default extractor for distributed message security propagation.
MessageHopSecurityExtractor (Priority: 100)
// Message hops carry scope as a ScopeDelta (MessageHop.Scope)
var hop = new MessageHop {
ServiceInstance = serviceInstance,
Scope = ScopeDelta.FromPerspectiveScope(new PerspectiveScope {
TenantId = "tenant-123",
UserId = "user-456"
})
};
// MessageHopSecurityExtractor merges hop scope deltas automatically
When to use: Messages flowing between Whizbang services that already have security context attached to their hop chain.
Message Hop Extractor¶
The MessageHopSecurityExtractor is the default built-in extractor that reads security context from message hop chains. It runs with priority 100 (early in the extraction pipeline).
How it works:
- Examines the message envelope's hop chain
- Merges the
ScopeDeltafrom everyCurrenthop (viaScopeDelta.ApplyTo) to rebuild the full scope - Returns
nullwhen no scope is found, or when bothTenantIdandUserIdare empty - Otherwise creates a
SecurityExtractioncarrying the full merged context (scope, roles, permissions, principals, claims, impersonation info)
Example:
Message Hop Extractor
// Message hops carry scope as a ScopeDelta (MessageHop.Scope)
var hop = new MessageHop {
ServiceInstance = serviceInstance,
Scope = ScopeDelta.FromPerspectiveScope(new PerspectiveScope {
TenantId = "tenant-123",
UserId = "user-456"
})
};
// MessageHopSecurityExtractor merges hop scope deltas automatically
Custom Extractors¶
Create custom extractors for different security sources:
Custom Extractors
public class JwtPayloadExtractor : ISecurityContextExtractor {
public int Priority => 50; // Runs before MessageHopSecurityExtractor
public ValueTask<SecurityExtraction?> ExtractAsync(
IMessageEnvelope envelope,
MessageSecurityOptions options,
CancellationToken cancellationToken = default) {
// Check if payload contains JWT token
if (envelope.Payload is not IAppMessage appMessage ||
string.IsNullOrEmpty(appMessage.Token)) {
return ValueTask.FromResult<SecurityExtraction?>(null);
}
// Decode and validate JWT
var claims = DecodeJwt(appMessage.Token, options.ValidateCredentials);
return ValueTask.FromResult<SecurityExtraction?>(new SecurityExtraction {
Scope = new PerspectiveScope {
TenantId = claims["tenant_id"],
UserId = claims["sub"]
},
Roles = claims["roles"]?.Split(',').ToHashSet() ?? new HashSet<string>(),
Permissions = new HashSet<Permission>(),
SecurityPrincipals = new HashSet<SecurityPrincipalId>(),
Claims = claims,
Source = "JwtPayload"
});
}
}
Security Context Callbacks¶
Callbacks run after security context is established but before business logic (receptors) execute. This enables custom service initialization at exactly the right time.
ISecurityContextCallback Interface¶
ISecurityContextCallback Interface
public interface ISecurityContextCallback {
ValueTask OnContextEstablishedAsync(
IScopeContext context,
IMessageEnvelope envelope,
IServiceProvider scopedProvider,
CancellationToken cancellationToken = default);
}
Callback Execution Points¶
New
Callbacks are now invoked at ALL security establishment points (v1.0.0)
Callbacks are invoked at three key points in the message processing pipeline:
| Execution Point | Component | When |
|---|---|---|
| Message Arrival | ServiceBusConsumerWorker |
When message arrives from transport |
| Lifecycle Processing | PerspectiveWorker |
Before each lifecycle stage receptor |
| Receptor Execution | ReceptorInvoker |
Before each receptor invocation |
This ensures your custom services have security context available regardless of where the receptor executes.
Execution Sequence Diagram¶
flowchart TD
Arrival["HTTP Request or Message Arrival"]
Establishment["Security Context Establishment<br/>(Extractors run in priority order)"]
Callback["ISecurityContextCallback.OnContextEstablishedAsync()<br/>← YOUR CALLBACK RUNS HERE<br/><br/>• UserContextManager initialized<br/>• Tenant config loaded<br/>• Custom services populated"]
Receptor["Receptor / Handler Executes<br/>← BUSINESS LOGIC RUNS HERE<br/><br/>• IMessageContext.TenantId available<br/>• IScopeContextAccessor.Current ready<br/>• UserContextManager ready (if used)"]
Arrival --> Establishment
Establishment --> Callback
Callback --> Receptor
Key insight: Callbacks complete before any receptor code runs, so your services are fully initialized when business logic needs them.
Example: UserContextManager Integration¶
Example: UserContextManager Integration
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 in DI
services.AddScoped<ISecurityContextCallback, UserContextManagerCallback>();
When to Use Callbacks vs Direct Injection¶
| Scenario | Approach | Why |
|---|---|---|
| Simple TenantId/UserId access | IMessageContext | Direct, no setup needed |
| Check roles or permissions | IScopeContextAccessor | Full scope access |
| Initialize custom service state | ISecurityContextCallback | Runs before receptors |
| Load tenant configuration | ISecurityContextCallback | Centralized initialization |
| Legacy service integration | ISecurityContextCallback | Bridge to existing patterns |
| Stateless receptor | IMessageContext | Simplest approach |
Multiple Callbacks¶
You can register multiple callbacks. They execute in registration order:
Multiple Callbacks
// Multiple callbacks for different concerns
services.AddScoped<ISecurityContextCallback, UserContextManagerCallback>();
services.AddScoped<ISecurityContextCallback, TenantConfigurationCallback>();
services.AddScoped<ISecurityContextCallback, AuditLogCallback>();
Callback Registration¶
Callback Registration
// Option 1: Extension method (recommended)
services.AddSecurityContextCallback<UserContextManagerCallback>();
// Option 2: Direct registration
services.AddScoped<ISecurityContextCallback, UserContextManagerCallback>();
Security Context in Event Cascades¶
When events are cascaded from receptor return values (auto-cascade), security context automatically propagates from the source envelope to the new DI scope created for receptor execution. This ensures downstream receptors have access to the original user and tenant context.
Cascade Flow Diagram¶
flowchart TD
Request["HTTP Request<br/>(UserId: user@test.com, TenantId: tenant-123)"]
Handler["Command Handler<br/>(security context established by message security system)<br/>(IMessageContext available: UserId, TenantId)"]
Returns["Returns Event (OrderCreated)"]
Cascade["Auto-Cascade via GetUntypedReceptorPublisher"]
Scope["Creates new DI scope"]
Establish["SecurityContextHelper.EstablishFullContextAsync(sourceEnvelope, scope.ServiceProvider)<br/>• Extracts security from envelope hops<br/>• Sets IScopeContextAccessor.Current<br/>• Invokes ISecurityContextCallback[] (UserContextManager, etc.)"]
Resolve["Resolves receptors from new scope"]
Execute["Event Receptors execute<br/>(IMessageContext available: UserId = user@test.com, TenantId = tenant-123)"]
Populated["UserContextManager.TenantContext is populated"]
Request --> Handler
Handler --> Returns
Returns --> Cascade
Cascade --> Scope
Scope --> Establish
Establish --> Resolve
Resolve --> Execute
Execute --> Populated
How It Works¶
The generated GetUntypedReceptorPublisher method (created by source generators) ensures security context flows through cascades:
How It Works
// Generated by Whizbang.Generators
protected override Func<object, IMessageEnvelope?, CancellationToken, Task>?
GetUntypedReceptorPublisher(Type eventType) {
if (eventType == typeof(OrderCreated)) {
async Task PublishToReceptorsUntyped(
object evt,
IMessageEnvelope? sourceEnvelope,
CancellationToken cancellationToken) {
// Step 1: Create isolated DI scope for cascade execution
var scope = _scopeFactory.CreateScope();
try {
// Step 2: Establish security context from source envelope
if (sourceEnvelope is not null) {
await SecurityContextHelper.EstablishFullContextAsync(
sourceEnvelope,
scope.ServiceProvider,
cancellationToken);
}
// Step 3: Resolve receptors with populated security context
var receptors = scope.ServiceProvider
.GetServices<IReceptor<OrderCreated>>();
// Step 4: Invoke receptors (IMessageContext.UserId/TenantId available)
foreach (var receptor in receptors) {
await receptor.HandleAsync((OrderCreated)evt, cancellationToken);
}
} finally {
await scope.DisposeAsync();
}
}
return PublishToReceptorsUntyped;
}
// ... other event types
}
Example: Context Flow Through Cascade¶
Example: Context Flow Through Cascade
// 1. Command receptor returns event
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
private readonly IMessageContext _context;
public CreateOrderReceptor(IMessageContext context) {
_context = context;
}
public ValueTask<OrderCreated> HandleAsync(CreateOrder cmd, CancellationToken ct = default) {
// ✅ Context available: _context.UserId, _context.TenantId
var userId = _context.UserId;
var tenantId = _context.TenantId;
return ValueTask.FromResult(new OrderCreated(cmd.OrderId));
}
}
// 2. Event cascades to OrderCreatedReceptor
public class OrderCreatedReceptor : IReceptor<OrderCreated> {
private readonly IMessageContext _context;
private readonly UserContextManager _userContext;
public OrderCreatedReceptor(
IMessageContext context,
UserContextManager userContext) {
_context = context;
_userContext = userContext;
}
public ValueTask HandleAsync(OrderCreated evt, CancellationToken ct = default) {
// ✅ Security context AUTOMATICALLY propagated!
// _context.UserId = same as command handler
// _context.TenantId = same as command handler
// _userContext.TenantContext is populated
var userId = _context.UserId; // ✅ Available
var tenantId = _context.TenantId; // ✅ Available
return ValueTask.CompletedTask;
}
}
Nested Dispatch Context Inheritance¶
Security context flows through nested dispatches from cascaded receptors:
Nested Dispatch Context Inheritance
public class OrderCreatedReceptor : IReceptor<OrderCreated> {
private readonly IDispatcher _dispatcher;
private readonly IMessageContext _context;
public OrderCreatedReceptor(
IDispatcher dispatcher,
IMessageContext context) {
_dispatcher = dispatcher;
_context = context;
}
public async ValueTask HandleAsync(OrderCreated evt, CancellationToken ct = default) {
// ✅ This receptor has security context from cascade
var userId = _context.UserId;
var tenantId = _context.TenantId;
// Dispatch nested command - inherits security context
await _dispatcher.SendAsync(new SendOrderConfirmation(evt.OrderId));
// ✅ SendOrderConfirmation receptor will ALSO have security context!
}
}
Null Envelope Scenarios¶
Some cascade paths don't have a source envelope:
| Scenario | Source Envelope | Context Available? |
|---|---|---|
| HTTP → Command → Event Cascade | ✅ Yes | ✅ Yes |
| Timer/Scheduler → Command | ❌ No | ❌ No (system context) |
RPC LocalInvokeAsync cascade |
❌ No | ❌ No |
Manual CascadeMessageAsync(msg, sourceEnvelope: null) |
❌ No | ❌ No |
For system-initiated operations, use explicit security context API:
Null Envelope Scenarios
// Timer/scheduler scenario - establish system context explicitly
// (a tenant strategy is required before dispatching)
await _dispatcher.AsSystem().KeepTenant().SendAsync(new ScheduledCleanupCommand());
Key Points¶
- Automatic propagation: Security context flows through cascades without manual intervention
- New scope per cascade: Each cascade creates an isolated DI scope with fresh context establishment
- Callback invocation:
ISecurityContextCallback[](UserContextManager, audit, etc.) execute in new scope - AOT compatible: Zero reflection, compile-time type-switch dispatch via source generators
- Transitive flow: Nested dispatches from cascaded receptors inherit security context
New
New: Security context now automatically propagates through all cascade paths, enabling cascaded receptors to access user and tenant context from the original request.
Message Context Accessor¶
The IMessageContext interface provides direct access to security information from the current message being processed. This is a simpler alternative to IScopeContextAccessor when you only need basic TenantId/UserId access.
Message Context Accessor
public interface IMessageContext {
MessageId MessageId { get; }
CorrelationId CorrelationId { get; }
MessageId CausationId { get; }
DateTimeOffset Timestamp { get; }
string? UserId { get; }
string? TenantId { get; }
IReadOnlyDictionary<string, object> Metadata { get; }
// Plus: IScopeContext? ScopeContext, ICallerInfo? CallerInfo
}
// Usage in a receptor
public class OrderReceptor : IReceptor<CreateOrder> {
private readonly IMessageContext _messageContext;
public OrderReceptor(IMessageContext messageContext) {
_messageContext = messageContext;
}
public async ValueTask HandleAsync(CreateOrder message, CancellationToken ct = default) {
var tenantId = _messageContext.TenantId;
var userId = _messageContext.UserId;
// Process message with security context
}
}
Default Provider¶
The DefaultMessageSecurityContextProvider is the built-in implementation of IMessageSecurityContextProvider. It orchestrates the extraction process:
- Resolves extractors from DI (all
ISecurityContextExtractorregistrations) - Sorts by priority (lower numbers first)
- Iterates extractors until one returns a non-null
SecurityExtraction - Wraps result in
ImmutableScopeContext - Sets accessor (
IScopeContextAccessor.Current = context) - Invokes callbacks (all
ISecurityContextCallbackregistrations)
Registration:
Default Provider
// Automatically registered by AddWhizbangMessageSecurity
services.AddWhizbangMessageSecurity();
// Or manually
services.AddSingleton<IMessageSecurityContextProvider, DefaultMessageSecurityContextProvider>();
Transport Metadata¶
For extracting security from transport-level headers (e.g., Azure Service Bus application properties), Whizbang ships the metadata types — ITransportMetadata and ServiceBusTransportMetadata (with GetProperty<T> / TryGetProperty<T>) in Whizbang.Core.Transports.
Updated
Shipped behavior: IMessageEnvelope does not expose transport metadata (there is no ITransportMetadataAware interface or TransportMetadata envelope property at this release). A transport-metadata extractor is therefore a custom pattern: your transport adapter must hand the received metadata to the extractor itself — for example via a scoped accessor it populates when the message is received.
Transport Metadata
// Your transport adapter populates this scoped accessor when receiving the message.
public class TransportMetadataAccessor {
public ITransportMetadata? Current { get; set; }
}
public class ServiceBusMetadataExtractor(TransportMetadataAccessor metadataAccessor)
: ISecurityContextExtractor {
public int Priority => 300;
public ValueTask<SecurityExtraction?> ExtractAsync(
IMessageEnvelope envelope,
MessageSecurityOptions options,
CancellationToken cancellationToken = default) {
if (metadataAccessor.Current is not ServiceBusTransportMetadata metadata) {
return ValueTask.FromResult<SecurityExtraction?>(null);
}
// Extract from Service Bus application properties
var tenantId = metadata.GetProperty<string>("X-Tenant-Id");
var userId = metadata.GetProperty<string>("X-User-Id");
if (string.IsNullOrEmpty(tenantId) && string.IsNullOrEmpty(userId)) {
return ValueTask.FromResult<SecurityExtraction?>(null);
}
return ValueTask.FromResult<SecurityExtraction?>(new SecurityExtraction {
Scope = new PerspectiveScope {
TenantId = tenantId,
UserId = userId
},
Roles = new HashSet<string>(),
Permissions = new HashSet<Permission>(),
SecurityPrincipals = new HashSet<SecurityPrincipalId>(),
Claims = new Dictionary<string, string>(),
Source = "ServiceBusMetadata"
});
}
}
Exceptions¶
The message security system defines specific exceptions for security failures:
SecurityContextRequiredException¶
Thrown when a message requires security context but none could be established:
SecurityContextRequiredException
public sealed class SecurityContextRequiredException : Exception {
public Type? MessageType { get; }
// (Constructors set MessageType and build a descriptive message.)
}
Handling Security Exceptions¶
Handling Security Exceptions
try {
await provider.EstablishContextAsync(envelope, scopedProvider, ct);
} catch (SecurityContextRequiredException ex) {
logger.LogWarning(
"Security context required for {MessageType} but none established",
ex.MessageType?.Name);
// Message will be dead-lettered or rejected
throw;
}
Envelope Reconstruction¶
When messages are reconstructed from transport (deserialization), the security context must be re-established. The IMessageEnvelope provides access to:
- Hops: Message hop chain carrying scope deltas (
MessageHop.Scope) - Payload: The actual message (may contain security tokens)
Example:
Envelope Reconstruction
// Envelope reconstruction preserves security information
var envelope = new MessageEnvelope<MyMessage> {
MessageId = messageId,
Payload = deserializedMessage,
Hops = deserializedHops // Contains ScopeDelta on each hop
};
// Extractors can read from these sources
await provider.EstablishContextAsync(envelope, scopedProvider, ct);
Cross-Tenant Operations¶
By default, security context is tenant-scoped. For cross-tenant operations (admin, reporting), use explicit security context:
Cross-Tenant Operations
// Admin cross-tenant query — target the tenant explicitly
await dispatcher.AsSystem().ForTenant("other-tenant").SendAsync(new GenerateTenantReport());
// Or system-wide: dispatcher.AsSystem().ForAllTenants()...
// Audit: ContextType=System, EffectivePrincipal="SYSTEM"
// Or with specific tenant context
var extraction = new SecurityExtraction {
Scope = new PerspectiveScope { TenantId = "other-tenant" },
// ... permissions for cross-tenant access
};
var context = new ImmutableScopeContext(extraction, shouldPropagate: true);
scopeAccessor.Current = context;
Security Failure Handling¶
When AllowAnonymous is false (default) and no extractor can establish context:
Security Failure Handling
// SecurityContextRequiredException is thrown
try {
await provider.EstablishContextAsync(envelope, scopedProvider, ct);
} catch (SecurityContextRequiredException ex) {
// ex.MessageType contains the message type that required security
logger.LogWarning(
"Security context required for {MessageType} but none established",
ex.MessageType?.Name);
// Message will be dead-lettered or rejected
throw;
}
Audit Events¶
When EnableAuditLogging is true, a ScopeContextEstablished system event is emitted:
Audit Events
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; }
}
ImmutableScopeContext¶
The established security context is wrapped in ImmutableScopeContext, which provides:
- Immutability: Cannot be modified after establishment
- Source tracking: Which extractor created it
- Timestamp: When it was established
- Propagation flag: Whether to include in outgoing messages
ImmutableScopeContext
var context = await provider.EstablishContextAsync(envelope, scopedProvider, ct);
if (context is ImmutableScopeContext immutable) {
Console.WriteLine($"Source: {immutable.Source}");
Console.WriteLine($"Established: {immutable.EstablishedAt}");
Console.WriteLine($"Propagate: {immutable.ShouldPropagate}");
}
Automatic Security Propagation¶
When MessageSecurityOptions.PropagateToOutgoingMessages is true (the default), the Dispatcher automatically attaches security context from the ambient scope to all outgoing message hops:
- Dispatcher checks
IScopeContextAccessor.Currentfor an established security context - If
ImmutableScopeContext.ShouldPropagateistrue, computes the scope changes as aScopeDelta - Populates
MessageHop.Scopeon all outgoing envelopes - Downstream services extract via
MessageHopSecurityExtractor(which merges the hop deltas)
This enables seamless security context flow across service boundaries without manual propagation.
Default Registration¶
AddWhizbangDispatcher() automatically registers IScopeContextAccessor by default, enabling security propagation without additional configuration:
Default Registration
// IScopeContextAccessor is registered automatically
services.AddWhizbangDispatcher();
// You can override with your own implementation if needed
services.AddSingleton<IScopeContextAccessor, CustomScopeContextAccessor>();
services.AddWhizbangDispatcher(); // Uses your implementation (TryAddSingleton)
To disable security propagation, set ShouldPropagate = false when creating ImmutableScopeContext.
How It Works¶
How It Works (2)
// When a message is sent, the Dispatcher:
// 1. Reads IScopeContextAccessor.Current
// 2. If ImmutableScopeContext with ShouldPropagate=true, computes the scope delta
// 3. Attaches it to the outgoing MessageHop.Scope
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)
};
Controlling Propagation¶
Propagation can be controlled at multiple levels:
Controlling Propagation
// 1. Globally via MessageSecurityOptions (default: true)
services.AddWhizbangMessageSecurity(options => {
options.PropagateToOutgoingMessages = true; // default
});
// 2. Per-context via ImmutableScopeContext
var extraction = new SecurityExtraction { /* ... */ };
// Propagation enabled - security flows to downstream services
var propagate = new ImmutableScopeContext(extraction, shouldPropagate: true);
// Propagation disabled - security stays local
var local = new ImmutableScopeContext(extraction, shouldPropagate: false);
End-to-End Flow¶
flowchart LR
subgraph ServiceA["Service A (HTTP Request)"]
direction TB
Middleware["WhizbangScopeMiddleware<br/>establishes IScopeContext"]
Logic["Business logic calls<br/>dispatcher.SendAsync()"]
Hop["MessageHop.Scope (ScopeDelta)<br/>carries { UserId, TenantId }"]
Middleware --> Logic
Logic -->|"Dispatcher attaches security"| Hop
end
subgraph ServiceB["Service B (Message Consumer)"]
direction TB
Consumer["ServiceBusConsumerWorker"]
Extractor["MessageHopSecurityExtractor<br/>merges hop scope deltas"]
Accessor["IScopeContextAccessor<br/>.Current = context"]
Consumer --> Extractor
Extractor --> Accessor
end
Hop -->|"Message"| Consumer
Explicit Security Context API¶
For system-triggered operations (timers, schedulers) or impersonation scenarios, use the explicit security context API. Both AsSystem() and RunAs() require an explicit tenant strategy — ForTenant(id), ForAllTenants(), or KeepTenant() — before any dispatch method is available (see Scope Propagation):
AsSystem() - System Operations¶
Use AsSystem() when dispatching messages from system contexts where no user identity exists, or when a user-initiated action should run with system privileges:
AsSystem() - System Operations
// Timer/scheduler with no user context
await dispatcher.AsSystem().KeepTenant().SendAsync(new ReseedSystemEvent());
// Audit: ContextType=System, ActualPrincipal=null, EffectivePrincipal="SYSTEM"
// Admin triggering system operation (preserves who triggered it)
await dispatcher.AsSystem().KeepTenant().SendAsync(new ReseedSystemEvent());
// Audit: ContextType=System, ActualPrincipal="admin@example.com", EffectivePrincipal="SYSTEM"
Key behaviors:
- EffectivePrincipal is always set to "SYSTEM"
- ActualPrincipal captures the current user if one exists (for audit trail)
- ContextType is set to SecurityContextType.System
- Previous security context is restored after dispatch completes
RunAs() - Impersonation¶
Use RunAs() when a user needs to perform actions as another identity, with full audit trail:
RunAs() - Impersonation
// Support staff impersonating a user (full audit trail)
await dispatcher.RunAs("target-user@example.com").KeepTenant().SendAsync(command);
// Audit: ContextType=Impersonated, ActualPrincipal="support@example.com", EffectivePrincipal="target-user@example.com"
Key behaviors:
- EffectivePrincipal is set to the specified identity
- ActualPrincipal captures who initiated the impersonation
- ContextType is set to SecurityContextType.Impersonated
- Both identities are captured for security auditing
Supported Methods¶
The security builder supports all dispatch methods:
Supported Methods
// Send commands
await dispatcher.AsSystem().KeepTenant().SendAsync(command);
await dispatcher.AsSystem().KeepTenant().SendAsync(command, options);
await dispatcher.AsSystem().KeepTenant().SendAsync(command, messageContext);
// Local invoke (in-process)
await dispatcher.AsSystem().KeepTenant().LocalInvokeAsync<TMessage, TResult>(message);
await dispatcher.AsSystem().KeepTenant().LocalInvokeAsync(message);
// Publish events
await dispatcher.AsSystem().KeepTenant().PublishAsync(eventData);
Audit Trail¶
The explicit security API provides complete audit trail information:
| Scenario | ContextType | ActualPrincipal | EffectivePrincipal |
|---|---|---|---|
| Timer job (no user) | System | null | SYSTEM |
| Admin runs as system | System | admin@example.com | SYSTEM |
| Support impersonates | Impersonated | support@example.com | target-user |
| Normal user | User | user@example.com | user@example.com |
SecurityContextType Enum¶
SecurityContextType Enum
public enum SecurityContextType {
User, // Normal user context from HTTP/message
System, // System-initiated (no user involved)
Impersonated, // User running as different identity
ServiceAccount // Service-to-service with service identity
}
Context Propagation¶
The explicit security context is propagated to outgoing message hops when ImmutableScopeContext.ShouldPropagate is true (the default for explicit contexts). This ensures downstream services receive the security context:
Context Propagation
// This message will carry SYSTEM context to downstream services
await dispatcher.AsSystem().KeepTenant().SendAsync(new MaintenanceCommand());
Design Principles¶
- No implicit fallback to elevated - Code must explicitly request system or elevated context
- Full audit trail - Both actual and effective identities are always captured
- Context restoration - Previous context is restored after dispatch completes (try/finally)
- Authorization not bypassed - This only sets context, not permissions
Integration with Existing Security¶
This message security system complements existing security tools:
| Existing Tool | Relationship |
|---|---|
| IScopeContext/Accessor | Provider populates this - single source of truth |
| WhizbangScopeMiddleware | HTTP equivalent; this is the message equivalent |
| MessageHop.Scope (ScopeDelta) | Default extractor merges these |
| PerspectiveScope | Included in IScopeContext.Scope |
| Scoped Lens Factory | Reads from IScopeContextAccessor (works automatically) |
| System Events | Provider emits ScopeContextEstablished for audit |
AOT Compatibility¶
The message security system is fully AOT-compatible:
- No reflection for extractor/callback discovery
- Explicit generic registration:
AddSecurityExtractor<T>() [DynamicallyAccessedMembers]attributes on generic constraints- All type resolution at compile time
Related Documentation¶
- Security System - Permissions, roles, and scope context
- Scoping - PerspectiveScope and multi-tenancy
- System Events - Audit events
- Transport Consumer - Message processing workers