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:
+29
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user