dotnet-cqrs/roadmap-2026/metadata-and-authorization-draft.md

19 KiB

gRPC Metadata & Authorization Support - Implementation Plan (DRAFT)

Status: Draft (Option 4 Preferred) Target: Q1 2026 Priority: High Complexity: Medium

Overview

Expose gRPC metadata and ServerCallContext to handlers, and integrate the existing ICommandAuthorizationService and IQueryAuthorizationService with gRPC endpoints. This will enable authentication, authorization, distributed tracing, multi-tenancy, and other context-aware features in gRPC services.

Problem Statement

Current Limitations

  1. No Authorization in gRPC:

    • ICommandAuthorizationService and IQueryAuthorizationService exist but are only used by Minimal API
    • gRPC services skip authorization checks entirely
    • Security gap for gRPC endpoints
  2. No Context Access:

    • Handlers cannot access ServerCallContext
    • Cannot read request metadata (headers, auth tokens, correlation IDs)
    • Cannot set response metadata
    • Cannot access user identity/claims
    • Cannot determine caller information
  3. Authorization Interface Limitation:

    Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct);
    
    • No access to user context
    • No access to request metadata
    • Cannot implement context-aware authorization

Goals

  1. Expose Context to Handlers: Allow handlers to access ServerCallContext via accessor service
  2. Integrate Authorization: Call authorization services before executing handlers in gRPC
  3. Maintain Backward Compatibility: Existing handlers continue to work without changes
  4. Enable Advanced Scenarios: Multi-tenancy, distributed tracing, custom authentication
  5. Familiar Pattern: Use accessor pattern similar to ASP.NET Core's IHttpContextAccessor

PREFERRED APPROACH - Use ambient context accessor similar to IHttpContextAccessor in ASP.NET Core.

Why Option 4?

  • Zero breaking changes - No handler interface modifications
  • Opt-in - Only inject accessor when context is needed
  • Familiar pattern - Mirrors IHttpContextAccessor developers already know
  • Full protocol access - Can use raw ServerCallContext or HttpContext features
  • Flexible - Handlers that don't need context remain simple

Implementation Overview

// New accessor interface
public interface ICqrsContextAccessor
{
    ServerCallContext? GrpcContext { get; }
    HttpContext? HttpContext { get; }
}

// Handlers opt-in by injecting the accessor
public class AddUserCommandHandler : ICommandHandler<AddUserCommand, int>
{
    private readonly ICqrsContextAccessor _context;

    public AddUserCommandHandler(ICqrsContextAccessor context)
    {
        _context = context;
    }

    public async Task<int> HandleAsync(AddUserCommand command, CancellationToken ct)
    {
        // Access gRPC metadata when available
        if (_context.GrpcContext != null)
        {
            var token = _context.GrpcContext.RequestHeaders.GetValue("authorization");
            var userId = ExtractUserIdFromToken(token);
        }

        // Or access HTTP context when available
        if (_context.HttpContext != null)
        {
            var userId = _context.HttpContext.User?.Identity?.Name;
        }

        // Handler logic...
    }
}

See full implementation details in Option 4 section below.


Alternative Design Options (For Reference)

Option 1: Optional ServerCallContext Parameter

Approach: Add optional ServerCallContext parameter to handler interface methods.

Handler Interface Changes:

namespace Svrnty.CQRS.Abstractions;

public interface ICommandHandler<TCommand>
{
    Task HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}

public interface ICommandHandler<TCommand, TResult>
{
    Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}

public interface IQueryHandler<TQuery, TResult>
{
    Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default, ServerCallContext? context = null);
}

Pros:

  • Simple and straightforward
  • Backward compatible (optional parameter with default null)
  • Handlers can explicitly access context when needed
  • No dependency on HTTP types in core abstractions

Cons:

  • Couples abstractions to gRPC (requires Grpc.Core reference)
  • Minimal API handlers won't have access to HttpContext through same parameter
  • Different context types for HTTP vs gRPC

Authorization Service Changes:

namespace Svrnty.CQRS.Abstractions.Security;

public interface ICommandAuthorizationService
{
    Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? context, CancellationToken ct = default);
}

Where context is:

  • ServerCallContext for gRPC
  • HttpContext for Minimal API
  • null if no context available

Option 2: Unified Context Abstraction

Approach: Create abstraction over both ServerCallContext and HttpContext.

New Abstraction:

namespace Svrnty.CQRS.Abstractions;

public interface IRequestContext
{
    string? GetHeader(string name);
    void SetHeader(string name, string value);
    IDictionary<string, object> Items { get; }
    CancellationToken CancellationToken { get; }
    IUserContext? User { get; }
}

public interface IUserContext
{
    bool IsAuthenticated { get; }
    string? UserId { get; }
    IEnumerable<string> Roles { get; }
    IEnumerable<Claim> Claims { get; }
}

public interface ICommandHandler<TCommand>
{
    Task HandleAsync(TCommand command, IRequestContext? context = null, CancellationToken ct = default);
}

Implementation Wrappers:

internal class GrpcRequestContext : IRequestContext
{
    private readonly ServerCallContext _grpcContext;

    public GrpcRequestContext(ServerCallContext context)
    {
        _grpcContext = context;
    }

    public string? GetHeader(string name) => _grpcContext.RequestHeaders.GetValue(name);
    public CancellationToken CancellationToken => _grpcContext.CancellationToken;
    // ... map other properties
}

internal class HttpRequestContext : IRequestContext
{
    private readonly HttpContext _httpContext;
    // ... similar mapping
}

Pros:

  • Protocol-agnostic handlers
  • Unified authorization interface
  • No coupling to gRPC or HTTP in abstractions
  • Can support future protocols (WebSockets, etc.)

Cons:

  • Additional abstraction layer (more complexity)
  • Cannot access protocol-specific features (e.g., gRPC deadlines, HTTP response codes)
  • Mapping overhead between contexts
  • Leaky abstraction if protocol-specific needs arise

Option 3: Separate gRPC-Aware Handler Interfaces

Approach: Keep existing handlers unchanged, add new gRPC-specific interfaces.

New Interfaces:

namespace Svrnty.CQRS.Grpc.Abstractions;

public interface IGrpcCommandHandler<TCommand>
{
    Task HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
}

public interface IGrpcCommandHandler<TCommand, TResult>
{
    Task<TResult> HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
}

public interface IGrpcQueryHandler<TQuery, TResult>
{
    Task<TResult> HandleAsync(TQuery query, ServerCallContext context, CancellationToken ct = default);
}

Pros:

  • No changes to core abstractions
  • Explicit opt-in for context access
  • Can access full ServerCallContext API
  • Clear separation between gRPC and HTTP handlers

Cons:

  • Handler duplication (need to implement both interfaces if supporting HTTP + gRPC)
  • Discovery system needs to handle multiple handler types
  • More complex registration (which handler for which protocol?)

Option 4: Context Accessor Pattern (Ambient Context)

Approach: Use service locator/accessor pattern for context access.

Implementation:

namespace Svrnty.CQRS.Abstractions;

public interface ICommandContextAccessor
{
    ServerCallContext? GrpcContext { get; }
    HttpContext? HttpContext { get; }
}

public class MyCommandHandler : ICommandHandler<MyCommand>
{
    private readonly ICommandContextAccessor _contextAccessor;

    public MyCommandHandler(ICommandContextAccessor contextAccessor)
    {
        _contextAccessor = contextAccessor;
    }

    public async Task HandleAsync(MyCommand command, CancellationToken ct)
    {
        var grpcContext = _contextAccessor.GrpcContext;
        if (grpcContext != null)
        {
            // Access gRPC-specific features
        }
    }
}

Pros:

  • No changes to handler interfaces
  • Fully backward compatible
  • Handlers can access context only when needed

Cons:

  • Ambient context is an anti-pattern (hidden dependency)
  • Harder to test (need to mock accessor)
  • AsyncLocal overhead for context flow
  • Not explicit in handler signature

Authorization Integration

Regardless of which option is chosen, authorization needs to be integrated into the gRPC source generator.

Source Generator Changes

File: Svrnty.CQRS.Grpc.Generators/ServiceImplementationGenerator.cs

public override async Task<AddUserResponse> AddUser(
    AddUserRequest request,
    ServerCallContext context)
{
    var command = new AddUserCommand
    {
        Name = request.Name,
        Email = request.Email
    };

    // AUTHORIZATION CHECK
    var authService = _serviceProvider.GetService<ICommandAuthorizationService>();
    if (authService != null)
    {
        var authResult = await authService.IsAllowedAsync(typeof(AddUserCommand), context, context.CancellationToken);

        if (authResult == AuthorizationResult.Unauthorized)
        {
            throw new RpcException(new Status(StatusCode.Unauthenticated, "Authentication required"));
        }

        if (authResult == AuthorizationResult.Forbidden)
        {
            throw new RpcException(new Status(StatusCode.PermissionDenied, "Insufficient permissions"));
        }
    }

    // Validation (existing)
    var validator = _serviceProvider.GetService<IValidator<AddUserCommand>>();
    if (validator != null)
    {
        var validationResult = await validator.ValidateAsync(command, context.CancellationToken);
        if (!validationResult.IsValid)
        {
            // ... existing validation error handling
        }
    }

    // Execute handler
    var handler = _serviceProvider.GetRequiredService<ICommandHandler<AddUserCommand, int>>();
    var result = await handler.HandleAsync(command, context.CancellationToken, context); // Option 1

    return new AddUserResponse { Result = result };
}

Authorization Service Update

Current Interface:

public interface ICommandAuthorizationService
{
    Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
}

Proposed Interface:

public interface ICommandAuthorizationService
{
    Task<AuthorizationResult> IsAllowedAsync(
        Type commandType,
        object? requestContext = null,
        CancellationToken ct = default);
}

Where requestContext can be:

  • ServerCallContext for gRPC
  • HttpContext for HTTP
  • Custom context for other scenarios
  • null if no context

Breaking Change Mitigation:

Add new method with default parameter to maintain backward compatibility:

public interface ICommandAuthorizationService
{
    // Old method (deprecated but still supported)
    Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);

    // New method with context
    Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct = default);
}

Default implementation:

public abstract class CommandAuthorizationServiceBase : ICommandAuthorizationService
{
    public virtual Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default)
    {
        return IsAllowedAsync(commandType, null, ct);
    }

    public abstract Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct);
}

Implementation Phases

Phase 1: Context Access (Choose Design Option)

  • Decide on design option (1, 2, 3, or 4)
  • Update handler interfaces (if needed)
  • Update source generator to pass context to handlers
  • Create context wrappers/abstractions (if needed)
  • Update Minimal API to work with new approach

Phase 2: Authorization Service Update

  • Update ICommandAuthorizationService and IQueryAuthorizationService interfaces
  • Provide backward-compatible base class
  • Update Minimal API to pass context to authorization services

Phase 3: gRPC Authorization Integration

  • Update source generator to call authorization services
  • Map authorization results to gRPC status codes
  • Handle Unauthenticated (401) → StatusCode.Unauthenticated
  • Handle Forbidden (403) → StatusCode.PermissionDenied

Phase 4: Sample Implementation

  • Create sample authorization service implementation
  • Demonstrate JWT token validation in gRPC
  • Demonstrate role-based authorization
  • Update sample project with authorization examples

Phase 5: Documentation

  • Update CLAUDE.md with authorization details
  • Document how to implement custom authorization services
  • Document how to access context in handlers
  • Create migration guide

Open Questions

1. Which design option should we use?

Question: Option 1 (optional parameter), Option 2 (unified abstraction), Option 3 (separate interfaces), or Option 4 (accessor pattern)?

Decision: Option 4 (Context Accessor Pattern) - Chosen for backward compatibility and familiar ASP.NET Core pattern.

2. Should we support accessing HttpContext in handlers?

Question: If a handler wants to access HTTP-specific features, should we support that through the same mechanism?

Decision: Yes - ICqrsContextAccessor will expose both GrpcContext and HttpContext properties. Handlers can check which is non-null to determine the protocol.

3. Breaking changes to authorization interface?

Question: Is it acceptable to add a parameter to the authorization interface?

Options:

  • Add new method, keep old one (backward compatible but verbose)
  • Add optional parameter with default null (semi-breaking for implementations)
  • Create v2 interfaces (ICommandAuthorizationServiceV2)

4. How should we handle multi-protocol handlers?

Question: If a handler is used by both HTTP and gRPC, how does it differentiate?

Decision: Check which context is non-null - Simple null check: if (_context.GrpcContext != null) vs if (_context.HttpContext != null)

5. Should metadata access be strongly typed?

Question: Should we provide typed accessors for common metadata (JWT, correlation ID, etc.)?

Options:

  • Raw string access only: context.GetHeader("authorization")
  • Typed extensions: context.GetAuthorizationToken(), context.GetCorrelationId()
  • Dedicated metadata services

Success Criteria

Functional Requirements

  • Handlers can access ServerCallContext when needed
  • Authorization services are called before gRPC handler execution
  • Authorization failures return appropriate gRPC status codes
  • Backward compatibility maintained for existing handlers
  • Works with both HTTP and gRPC (if unified approach)

Security Requirements

  • Unauthorized requests return StatusCode.Unauthenticated
  • Forbidden requests return StatusCode.PermissionDenied
  • Authorization decisions can access user context
  • JWT tokens can be validated from gRPC metadata

Performance Requirements

  • Minimal overhead for context passing
  • No performance regression for handlers that don't use context

Migration & Backward Compatibility

Breaking Changes

With Option 4: NO BREAKING CHANGES

  • Handler interfaces remain unchanged
  • Existing handlers work without modification
  • Authorization interface adds optional parameter (backward compatible)

Migration Path

For Option 4 (Chosen):

  1. Update to new version
  2. All existing handlers continue to work unchanged
  3. Handlers that need context inject ICqrsContextAccessor via constructor
  4. Authorization service implementations can optionally use the new context parameter
  5. No code changes required for basic upgrade

Dependencies

NuGet Packages

  • Grpc.AspNetCore >= 2.68.0 (already referenced)
  • Microsoft.AspNetCore.Http.Abstractions (for HttpContext, if using unified abstraction)

Internal Dependencies

  • Svrnty.CQRS.Abstractions (handler interfaces, authorization interfaces)
  • Svrnty.CQRS.Grpc (context wrappers, if needed)
  • Svrnty.CQRS.Grpc.Generators (source generator updates)
  • Svrnty.CQRS.MinimalApi (authorization integration updates)

Based on preference for Option 4 (Context Accessor Pattern):

Implementation Summary

  1. Create ICqrsContextAccessor interface:

    public interface ICqrsContextAccessor
    {
        ServerCallContext? GrpcContext { get; }
        HttpContext? HttpContext { get; }
    }
    
  2. Implement accessor with AsyncLocal:

    • Use AsyncLocal<T> to flow context through async calls
    • Similar to ASP.NET Core's IHttpContextAccessor implementation
    • Set context in gRPC interceptor and HTTP middleware
  3. Update authorization interface with context parameter:

    Task<AuthorizationResult> IsAllowedAsync(
        Type commandType,
        object? requestContext = null,
        CancellationToken ct = default);
    
  4. Update source generator to:

    • Set context in accessor before calling handler
    • Call authorization service with ServerCallContext
    • Map authorization results to gRPC status codes
  5. Provide sample implementations for:

    • JWT token validation from gRPC metadata
    • Role-based authorization
    • Multi-tenancy with tenant ID from headers
    • Correlation ID extraction and propagation

Advantages

  • Zero breaking changes - No handler signature modifications required
  • Familiar to ASP.NET Core developers - Same pattern as IHttpContextAccessor
  • Opt-in complexity - Simple handlers remain simple
  • Full protocol support - Access raw gRPC and HTTP contexts
  • Testable - Can mock ICqrsContextAccessor in tests

Next Steps

  1. Finalize accessor interface design
  2. Implement AsyncLocal-based accessor
  3. Update source generator for context setting
  4. Integrate authorization checks
  5. Create comprehensive samples

Document Version: 0.2 (DRAFT - Option 4 Selected) Last Updated: 2025-01-08 Author: Svrnty Team Status: Ready for Implementation Planning