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
88 lines
4.1 KiB
C#
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}.");
|
|
}
|
|
}
|
|
}
|