Skip to content

Server-side tenant id detection for Wolverine.Grpc (GH-3368) - #3382

Merged
jeremydmiller merged 1 commit into
mainfrom
grpc-tenant-detection-3368
Jul 12, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
grpc-tenant-detection-3368

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes GH-3368

Builds on #3369 (the runtime WolverineGrpcServicePropagationInterceptor): this adds the codegen-level server-side tenant id detection mirroring Wolverine.Http's ITenantDetectionPolicies, so Marten/Polecat session-opening frames and the forwarded envelope get the tenant structurally — without relying on the ambient IMessageContext — plus claims and custom detection that the interceptor cannot do.

What's here

API (WolverineGrpcOptions.TenantId, IGrpcTenantDetectionPolicies)

builder.Services.AddWolverineGrpc(grpc =>
{
    grpc.TenantId.IsRequestHeaderValue("tenant-id"); // ServerCallContext.RequestHeaders, case-insensitive
    grpc.TenantId.IsClaimTypeNamed("tenant-id");     // context.GetHttpContext().User
    grpc.TenantId.DetectWith<MyDetection>();         // custom IGrpcTenantDetection, built from the container
    grpc.TenantId.DefaultIs("default_tenant");
    grpc.TenantId.AssertExists();                    // missing tenant → RpcException(InvalidArgument)
});

AssertExists() maps to gRPC status InvalidArgument — the canonical (AIP-193) equivalent of the 400 Bad Request Wolverine.Http returns for the same condition; documented in the new docs page, including when Unauthenticated/PermissionDenied via regular authorization is the better fit.

Detection frame across all three chain flavors

GrpcTenantIdDetection : IGrpcChainPolicy is registered by the WolverineGrpcOptions constructor (exactly like WolverineHttpOptions registers its TenantIdDetection) and marks every discovered chain. Each chain's AssembleTypes weaves a per-method DetectGrpcTenantIdFrame that:

  1. declares the tenantId variable (PersistenceConstants.TenantIdVariableName) consumed by OpenMartenSessionFrame/OpenPolecatSessionFrame,
  2. optionally enforces AssertExists(),
  3. assigns the detected tenant to the wrapper's scoped IMessageBus before InvokeAsync/StreamAsync (proto-first + code-first), or to the request-scoped IMessageContext via the wrapper's IServiceProvider (hand-written delegation wrappers, which carry no bus field).

Coverage by flavor: proto-first = all RPC shapes (they all end in ServerCallContext); code-first & hand-written = unary methods declaring a CallContext parameter (the only route to ServerCallContext there; IAsyncEnumerable-returning methods can't host the awaited detection call). Fallback for the uncovered shapes is the #3369 interceptor, and the limits are documented.

Zero-config default

With no explicit TenantId configuration and PropagateEnvelopeHeaders == true (default), the policy auto-registers IsRequestHeaderValue("tenant-id") — the exact header the Wolverine gRPC client interceptor stamps — so a Wolverine→Wolverine hop round-trips the tenant with zero server config. Explicit configuration replaces the default; PropagateEnvelopeHeaders = false suppresses it. Surfaced explicitly via ZeroConfigDefaultApplied and a comment line in the generated source.

Docs

  • New docs/guide/grpc/multi-tenancy.md (modeled on the HTTP page): three detection modes, zero-config default, interplay with the propagation interceptor, InvalidArgument mapping rationale, scope/limitations.
  • Nav entry in .vitepress/config.mts; section index updated.
  • client.md propagation section now describes the round-trip story with both server-side mechanisms (interceptor + codegen detection).

Tests (src/Wolverine.Grpc.Tests/MultiTenancy)

  • End-to-end (per-configuration disposable host): header detection (incl. case-insensitivity), claim detection via a stamped ClaimsPrincipal, DetectWith<T> custom strategy, strategy ordering, DefaultIs fallback, AssertExists rejection (InvalidArgument + detail message) and pass-through, missing-tenant flow, zero-config round-trip, and explicit detection with the interceptor disabled (proving the codegen path is independent of gRPC: server-side envelope propagation + correct false docs/sample claims (GH-3368) #3369). Non-tenant-id headers are used wherever the interceptor could otherwise mask the result.
  • Structural: generated source of all three chain flavors contains the detection call; the tenantId codegen variable name is pinned; PropagateEnvelopeHeaders = false suppresses the default; hand-written wrapper e2e through the ambient context.
  • 3 existing AddPolicy unit tests updated: Policies now always contains the built-in detection policy (same situation as HTTP's always-registered policies).

Deviations from a literal HTTP mirror (and why)

  • Frames are woven in AssembleTypes, not inserted into chain.Middleware: gRPC chains clone middleware frames per generated RPC method (CloneFrames), which only knows ConstructorFrame/MethodCall and would share a single detection frame instance (mutable Next) across N methods. The policy marks the chain; each flavor creates one frame per method with the right context expression (context vs context.ServerCallContext).
  • DetectWith<T>() is deferred: HTTP configures TenantId inside MapWolverineEndpoints() when the container exists; gRPC options are configured during AddWolverineGrpc() service registration, so the type is recorded and QuickBuild-resolved at policy-apply time.
  • No route/query-string/sub-domain strategies: those concepts don't exist on a gRPC call; metadata headers, claims, and custom strategies cover the surface.
  • AssertExists() throws RpcException(InvalidArgument) instead of writing a 400 ProblemDetails.

Verification

  • Wolverine.Grpc.Tests (net9.0): 271/271 passing (255 existing + 16 new)
  • dotnet build wolverine.slnx -c Release: 0 errors, 0 warnings

🤖 Generated with Claude Code

Mirrors Wolverine.Http's ITenantDetectionPolicies for gRPC services:

- New WolverineGrpcOptions.TenantId (IGrpcTenantDetectionPolicies) with
  IsRequestHeaderValue (case-insensitive metadata), IsClaimTypeNamed
  (HttpContext.User), DetectWith/DetectWith<T>, DefaultIs, and
  AssertExists (RpcException InvalidArgument, the gRPC mapping of
  HTTP's 400)
- DetectGrpcTenantIdFrame declares the 'tenantId' codegen variable
  (PersistenceConstants.TenantIdVariableName) that Marten/Polecat
  session-opening frames consume, and applies the detected tenant to
  the scoped IMessageBus before InvokeAsync/StreamAsync — structurally,
  without relying on the ambient IMessageContext
- Woven as the built-in GrpcTenantIdDetection IGrpcChainPolicy across
  all three chain flavors: proto-first (all RPC shapes), code-first and
  hand-written (unary methods with a CallContext parameter; hand-written
  delegation wrappers apply the tenant via the request-scoped
  IMessageContext since they carry no bus field)
- Zero-config default: with no explicit TenantId configuration and
  PropagateEnvelopeHeaders=true, detect the 'tenant-id' metadata header
  the Wolverine gRPC client interceptor stamps — Wolverine-to-Wolverine
  hops round-trip the tenant with no server config; suppressed by
  PropagateEnvelopeHeaders=false, replaced by any explicit strategy
- Docs: new guide/grpc/multi-tenancy page, nav entry, client.md now
  describes propagation as round-trip with both server-side mechanisms
- Tests: e2e header/claim/custom/fallback/assert-exists detection,
  zero-config default, interceptor-disabled isolation, structural
  assertions on generated source for all three chain flavors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 853de2c into main Jul 12, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wolverine.Grpc: server-side tenant-id detection (mirror of Wolverine.Http's ITenantDetectionPolicies)

1 participant