Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/DependencyInjection/SecretsGrpcHubServerExtensions.cs
T
Joseph Doherty 353af72265 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
2026-08-07 06:27:16 -04:00

116 lines
5.9 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.DependencyInjection;
/// <summary>
/// Dependency-injection and endpoint wiring for hosting the central secrets hub.
/// </summary>
/// <remarks>
/// <para>
/// Register this on the <b>central</b> node only. A site node registers the client half instead —
/// the two are separate registrations on purpose, so "central hosts, sites follow" is visible in
/// each role's composition root rather than inferred from configuration.
/// </para>
/// <para>
/// <b>Every node must be configured with the same KEK.</b> The hub serves ciphertext, so a follower
/// whose master key differs cannot decrypt what it pulls; it fails closed on resolve with a
/// <c>kek_id</c> mismatch, which reads like data corruption but is a deployment error.
/// </para>
/// </remarks>
public static class SecretsGrpcHubServerExtensions
{
/// <summary>
/// Binds and validates <see cref="SecretsGrpcHubServerOptions"/>, registers the fail-closed
/// bearer interceptor, and attaches it to <see cref="SecretsHubService"/>.
/// </summary>
/// <remarks>
/// Validation runs here, eagerly, as well as through the options pipeline: a hub with no bearer
/// token should refuse to start rather than start and refuse every follower. The interceptor's
/// deny-all is the second layer, for the case where a host constructs the options some other way.
/// The service requires an <c>ISecretStore</c> in the container — register the store first.
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <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, or the hub has already been registered.
/// </exception>
public static IServiceCollection AddZbSecretsGrpcHub(
this IServiceCollection services,
IConfiguration configuration,
string sectionPath = "Secrets:GrpcHub")
{
ArgumentNullException.ThrowIfNull(services);
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);
// SectionPath is internal, so binding cannot set it; stamping it here is what lets the
// startup failure and the runtime warning both name the key this host actually reads.
services.PostConfigure<SecretsGrpcHubServerOptions>(options => options.SectionPath = sectionPath);
var eager = new SecretsGrpcHubServerOptions { SectionPath = sectionPath };
section.Bind(eager);
eager.SectionPath = sectionPath;
eager.Validate();
// Singleton so the "unconfigured" warning is emitted once per process rather than once per
// interceptor instance; the gRPC interceptor activator prefers a registered instance.
services.TryAddSingleton<SecretsHubAuthInterceptor>();
// AddGrpc is safe to call again if the host already called it — it registers with TryAdd.
services.AddGrpc()
.AddServiceOptions<SecretsHubService>(
options => options.Interceptors.Add<SecretsHubAuthInterceptor>());
return services;
}
/// <summary>
/// Maps the secrets hub gRPC endpoint. The host must have set up routing;
/// <see cref="AddZbSecretsGrpcHub"/> supplies the gRPC services and the auth interceptor.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The same <paramref name="endpoints"/> instance, for chaining.</returns>
public static IEndpointRouteBuilder MapZbSecretsHub(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
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;
}