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

611 lines
19 KiB
Markdown

# 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:**
```csharp
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`
## Recommended Approach: Context Accessor Pattern (Option 4)
**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
```csharp
// 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](#option-4-context-accessor-pattern-ambient-context) below.
---
## Alternative Design Options (For Reference)
### Option 1: Optional ServerCallContext Parameter
**Approach:** Add optional `ServerCallContext` parameter to handler interface methods.
**Handler Interface Changes:**
```csharp
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:**
```csharp
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:**
```csharp
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:**
```csharp
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:**
```csharp
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:**
```csharp
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`
```csharp
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:**
```csharp
public interface ICommandAuthorizationService
{
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
}
```
**Proposed Interface:**
```csharp
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:
```csharp
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:
```csharp
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)
---
## Final Recommended Approach
Based on preference for **Option 4 (Context Accessor Pattern)**:
### Implementation Summary
1. **Create `ICqrsContextAccessor` interface:**
```csharp
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:
```csharp
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