feat(secrets): Grpc hub server — SecretsHubService + fail-closed bearer interceptor + DI

The server half of the central-hosted pull-only secrets hub. SecretsHubService
adapts the node's own ISecretStore onto secrets_hub.v1 using exactly two read
members (GetManifestAsync, GetAsync) — the proto exposes no write RPC, so a
follower has no path to put a row into central whatever its credentials allow.

GetSecrets omits a name the hub does not hold and skips one that will not parse
as a SecretName; neither is an error, because both describe a follower acting on
a manifest that was true a moment ago. Failing the call would stall the sweep and
let one bad name deny the follower every other row in the batch. Names are
re-validated server-side (the wire type is a plain string) and de-duplicated on
the normalized value.

DomainToWire mirrors the Akka replicator's SecretRowDto discipline exactly:
round-trip "O" timestamps so a DateTimeOffset's offset and ticks survive the
last-writer-wins equality compare, the content type as its enum MEMBER NAME so an
unknown value is rejected rather than decoded to the zero member, and nullable
fields left absent rather than written as "" so null and empty stay distinct.

SecretsHubAuthInterceptor fails closed: with no token configured every call is
denied and one warning names the exact configuration key — never a token value,
presented or configured, at any level. Denial is uniform (one Status, one detail,
no "why") so a caller gets no oracle. Comparison is constant-time over UTF-8
bytes. All four handler shapes are gated, not just unary, so a future RPC cannot
arrive unauthenticated by omission; more than one Authorization header is refused
as a smuggling shape. The type keeps EXACTLY ONE public constructor — Grpc's
activator silently stops invoking an interceptor it cannot construct
unambiguously, and the failure mode is an auth gate that quietly does not run — so
a test pins the count.

Tests: 50, red-first (compile-red, then a fail-closed mutation verified 5 of them
bite). Full Secrets suite 328 passed / 0 failed / 15 live-gated skips, 0 new
warnings.

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-07 06:09:23 -04:00
parent cc95590196
commit 7fc77f2790
8 changed files with 1290 additions and 0 deletions
@@ -0,0 +1,87 @@
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.</exception>
public static IServiceCollection AddZbSecretsGrpcHub(
this IServiceCollection services,
IConfiguration configuration,
string sectionPath = "Secrets:GrpcHub")
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
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;
}
}