611 lines
19 KiB
Markdown
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
|