fix(secrets): rate-limited denial warning on the Grpc hub (live-gate finding) — 0.4.1
The ScadaBridge live gate (check 4) found SecretsHubAuthInterceptor denying correctly but logging nothing: the only trace of a refusal was a framework Grpc.AspNetCore.Server Information line. A follower whose bearer token was mis-rotated therefore stopped converging silently, with the hub showing it only at INF. Every denial is now counted and summarised in a Warning emitted on the first denial after startup or after a 60-second quiet window, carrying the denials since the previous summary plus a breakdown by cause. A single probe is visible immediately; a sustained flood is bounded to one line per window, which is what makes warning safe at all — the endpoint is unauthenticated by construction, so a per-denial warning would be an unbounded log write driven by an unauthenticated caller. Counters are Interlocked and the emit is decided by a compare-exchange on the last-emit timestamp, so exactly one thread emits per window and a denial counted after the drain carries into the next summary rather than being lost. The cause breakdown is server-side diagnostics only. Denied() is untouched: every rejection still returns the identical uniform Unauthenticated status, the comparison is still constant-time, and no token material appears in any log line. The existing warn-once-on-unconfigured-token signal is unchanged and still emitted exactly once; denials in that state count toward the new summary's "unconfigured" category. TimeProvider is an optional default-valued parameter rather than a second overload — the type must keep exactly one public constructor or the gRPC interceptor activator silently stops invoking the auth gate. Two existing tests scoped their assertions to the configuration warning, since both kinds now share a category: the new line is deliberate behavior, not drift. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
+11
-1
@@ -317,8 +317,18 @@ public sealed class SecretsHubIntegrationTests : IAsyncLifetime
|
||||
() => client.Hub.GetManifestAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
CapturedLog warning = Assert.Single(server.Logs.WarningsFrom<SecretsHubAuthInterceptor>());
|
||||
// Scoped to the CONFIGURATION warning, because the interceptor deliberately emits a second
|
||||
// kind on this path: a rate-limited summary of the denials themselves. Both are warnings
|
||||
// from the same category, so an unfiltered Single would now be counting two signals as one.
|
||||
IReadOnlyList<CapturedLog> warnings = server.Logs.WarningsFrom<SecretsHubAuthInterceptor>();
|
||||
CapturedLog warning = Assert.Single(
|
||||
warnings, w => w.Message.Contains("no bearer token configured", StringComparison.Ordinal));
|
||||
Assert.Contains("Secrets:GrpcHub:BearerToken", warning.Message, StringComparison.Ordinal);
|
||||
|
||||
// And the denial summary is likewise once, not once per refused caller — the point of the
|
||||
// rate limit, checked here through the real registration rather than a hand-built instance.
|
||||
Assert.Single(
|
||||
warnings, w => w.Message.Contains("The gRPC secrets hub denied", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user