Skip to content

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.Current for 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:

  1. Creates DI scope
  2. Calls IMessageSecurityContextProvider.EstablishContextAsync()
  3. Provider iterates through extractors in priority order (lower = earlier)
  4. First successful extraction populates IScopeContextAccessor.Current
  5. All callbacks are invoked with the established context
  6. 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:

  1. Examines the message envelope's hop chain
  2. Merges the ScopeDelta from every Current hop (via ScopeDelta.ApplyTo) to rebuild the full scope
  3. Returns null when no scope is found, or when both TenantId and UserId are empty
  4. Otherwise creates a SecurityExtraction carrying 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:

  1. Resolves extractors from DI (all ISecurityContextExtractor registrations)
  2. Sorts by priority (lower numbers first)
  3. Iterates extractors until one returns a non-null SecurityExtraction
  4. Wraps result in ImmutableScopeContext
  5. Sets accessor (IScopeContextAccessor.Current = context)
  6. Invokes callbacks (all ISecurityContextCallback registrations)

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:

  1. Dispatcher checks IScopeContextAccessor.Current for an established security context
  2. If ImmutableScopeContext.ShouldPropagate is true, computes the scope changes as a ScopeDelta
  3. Populates MessageHop.Scope on all outgoing envelopes
  4. 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 strategyForTenant(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

  1. No implicit fallback to elevated - Code must explicitly request system or elevated context
  2. Full audit trail - Both actual and effective identities are always captured
  3. Context restoration - Previous context is restored after dispatch completes (try/finally)
  4. 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