Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.Grpc/SecretsGrpcHubServerOptions.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

88 lines
4.1 KiB
C#

namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
/// <summary>
/// Configuration for the central-hosted secrets hub. Bound from a configuration section by
/// <c>AddZbSecretsGrpcHub</c>.
/// </summary>
/// <remarks>
/// <para>
/// The hub serves ciphertext to followers that authenticate with one shared bearer token. That
/// token is the whole of the hub's authorization: there is no per-follower identity, because a
/// follower's authority is uniform (read the manifest, read rows) and the pull-only contract gives
/// it nothing else to be authorized for.
/// </para>
/// <para>
/// It cannot itself be supplied as a <c>${secret:}</c> reference — resolving that reference is what
/// the hub exists to make possible — so it comes from appsettings or the environment, the same way
/// the family's mesh pre-shared keys do.
/// </para>
/// </remarks>
public sealed class SecretsGrpcHubServerOptions
{
/// <summary>The configuration section the hub is bound from unless a host overrides it.</summary>
internal const string DefaultSectionPath = "Secrets:GrpcHub";
/// <summary>
/// The default ceiling on names in one <c>GetSecrets</c> call. Set comfortably above the
/// follower's own fetch chunk so a compliant sweep never approaches it.
/// </summary>
internal const int DefaultMaxNamesPerRequest = 1000;
/// <summary>
/// The shared bearer token a follower must present. Required — an unset token is not "open
/// access", it is a hub that refuses every call (see <see cref="SecretsHubAuthInterceptor"/>).
/// </summary>
public string BearerToken { get; set; } = string.Empty;
/// <summary>
/// The most names a single <c>GetSecrets</c> call may carry. A larger request is refused with
/// <c>ResourceExhausted</c> rather than served.
/// </summary>
/// <remarks>
/// The well-behaved follower already chunks its fetches well below this, so the cap is not a
/// throughput knob — it is the server declining to let one authenticated caller turn a single
/// request into an unbounded sequential store sweep. The cap sits behind authentication, so it
/// is defence in depth against a misbehaving token holder, not an anonymous-DoS control.
/// </remarks>
public int MaxNamesPerRequest { get; set; } = DefaultMaxNamesPerRequest;
/// <summary>
/// The configuration section this instance was bound from, used so failure messages name the
/// key an operator actually has to set.
/// </summary>
/// <remarks>
/// Deliberately <see langword="internal"/>: configuration binding only writes public settable
/// properties, so a stray <c>SectionPath</c> key in an appsettings file cannot make the hub
/// misreport which key it wants. The registration extension sets it.
/// </remarks>
internal string SectionPath { get; set; } = DefaultSectionPath;
/// <summary>
/// Throws if the options are unusable. Called by the registration extension so a hub with no
/// token fails at startup, rather than starting and rejecting every follower at runtime.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The bearer token is missing or blank, or the request cap is not positive.
/// </exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(BearerToken))
{
// Names the key, never the value — there is no value here, and there never will be one
// in an error message from this type.
throw new InvalidOperationException(
$"The gRPC secrets hub requires a shared bearer token ({SectionPath}:BearerToken). " +
"Until it is set the hub refuses every call.");
}
if (MaxNamesPerRequest <= 0)
{
// A zero or negative cap would refuse every GetSecrets call, which reads at runtime like
// a broken follower rather than a typo in configuration — so it is refused at startup.
throw new InvalidOperationException(
$"The gRPC secrets hub request cap ({SectionPath}:MaxNamesPerRequest) must be " +
$"greater than zero; it is {MaxNamesPerRequest}.");
}
}
}