fix(secrets): Grpc hub server review follow-ups — GetSecrets batch cap, DI single-registration guard + tests

Five review items on the Task 2 server half.

1. GetSecrets now enforces a server-side cap on request.Names, refusing an
   over-cap batch with ResourceExhausted BEFORE any store read runs. The
   well-behaved follower chunks at 500, but client discipline is not a server
   defence — the family precedent (HistorianGateway's max_values) is that the
   server declines regardless. The new SecretsGrpcHubServerOptions.
   MaxNamesPerRequest defaults to 1000, comfortably above that chunk so a
   compliant sweep never meets it, and Validate() refuses a non-positive value
   at startup rather than letting it read at runtime as a broken follower. The
   refusal states the cap and the received count — neither is secret, and an
   operator cannot resize a batch without them — and never a name. The cap sits
   behind the interceptor, so it is defence in depth against a misbehaving
   token holder, not an anonymous-DoS control.

2. AddZbSecretsGrpcHub now refuses a second registration. The two calls do not
   compose: options binding MERGES (a key absent from the second section leaves
   the first section's value standing) while the SectionPath stamp is
   last-one-wins, so a hub registered twice could serve a token bound from one
   section while every failure message named the other. Detected via a private
   marker registration. The extension also gains real DI coverage — the intended
   single-call path asserted for token/SectionPath coherence through a built
   provider, a custom section path stamped end to end, a missing token throwing
   from the registration call itself, and the duplicate call throwing.

3. Client-streaming and duplex-streaming denial tests, so all four gated handler
   shapes are pinned rather than only unary and server-streaming, plus a
   valid-token test that reaches all three streaming continuations.

4. A cancellation test: GetSecrets hands the ServerCallContext token through to
   the store, pinned against a fake that records it.

5. The proto's service comment stated the ISecretReplicationHub mapping in prose
   that no longer matched the RPC name; it now says GetManifestAsync ->
   GetManifest and GetManyAsync -> GetSecrets explicitly. Comment-only — no
   message or rpc definition is touched. The GetSecrets rpc comment also notes
   the new RESOURCE_EXHAUSTED cap, which a client author has to know about.

Grpc server suite 50 -> 73 tests; full solution suite green, 0 new warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 06:27:16 -04:00
parent 7fc77f2790
commit 353af72265
7 changed files with 531 additions and 15 deletions
@@ -37,7 +37,9 @@ public static class SecretsGrpcHubServerExtensions
/// <param name="configuration">The application configuration.</param>
/// <param name="sectionPath">Configuration section holding the hub options.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
/// <exception cref="InvalidOperationException">No bearer token is configured.</exception>
/// <exception cref="InvalidOperationException">
/// No bearer token is configured, or the hub has already been registered.
/// </exception>
public static IServiceCollection AddZbSecretsGrpcHub(
this IServiceCollection services,
IConfiguration configuration,
@@ -47,6 +49,24 @@ public static class SecretsGrpcHubServerExtensions
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
// Registering twice is refused rather than tolerated, because the two calls do NOT compose:
// options binding MERGES (a key absent from the second section leaves the first section's
// value standing) while the SectionPath stamp below is last-one-wins. A hub registered from
// two sections could therefore serve a token bound from one of them while every failure
// message names the other — a misdirection that outlives the mistake that caused it.
if (services.Any(descriptor => descriptor.ServiceType == typeof(HubRegistrationMarker)))
{
throw new InvalidOperationException(
"The gRPC secrets hub may only be registered once; AddZbSecretsGrpcHub was called " +
"more than once. Remove the duplicate call — a second registration would merge its " +
"configuration into the first and leave the reported section path pointing at " +
"whichever call ran last.");
}
// Registered as an instance so nothing ever has to activate it: the marker exists to be
// looked for, not resolved.
services.AddSingleton(new HubRegistrationMarker());
IConfigurationSection section = configuration.GetSection(sectionPath);
services.Configure<SecretsGrpcHubServerOptions>(section);
@@ -84,4 +104,12 @@ public static class SecretsGrpcHubServerExtensions
endpoints.MapGrpcService<SecretsHubService>();
return endpoints;
}
/// <summary>
/// Presence of this in the container means the hub is already registered. It carries no state
/// and is never resolved — a dedicated type is used rather than probing for one of the real
/// registrations because every one of those is added with <c>TryAdd</c>-style semantics that a
/// host may legitimately have satisfied for its own reasons.
/// </summary>
private sealed class HubRegistrationMarker;
}