353af72265
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
116 lines
5.9 KiB
C#
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;
|
|
}
|