fix(grpc): one public constructor on ControlPlaneAuthInterceptor — two made the gate inert

Caught by the Phase 0 live gate, not by the suite.

Grpc.AspNetCore registers the interceptor BY TYPE, and
InterceptorRegistration.GetFactory() throws "Multiple constructors accepting all
given argument types have been found" when more than one public constructor is
applicable. The interceptor had two: the DI one and a prefix-set overload added
for later phases.

The failure mode is nasty. The throw happens inside the interceptor pipeline on
every call, so nothing fails at startup — the site node boots, joins, reports
healthy. Every gated call then dies with Unknown / "Exception was thrown by
handler", which reads as a handler bug rather than an auth bug. And it fails
OPEN in the sense that matters least and closed in the sense that matters most:
no call is ever authorized, but no call is ever correctly REFUSED either, so the
rig showed identical errors for a correct key, a wrong key and no key at all.
Live evidence, site-a: three PullAuditEvents calls, three identical
InvalidOperationExceptions in the node log.

Fix: the prefix-set constructor is internal (Host.Tests already has
InternalsVisibleTo). Later phases extend DefaultGatedPrefixes rather than adding
a second public registration shape.

Why the tests missed it, and what changed: ControlPlaneAuthEndToEndTests
registered the interceptor with AddSingleton alongside AddGrpc, so DI handed
back the instance and Grpc.AspNetCore's activation path — the thing that throws
— never ran. The harness now registers exactly as Program.cs does, by type and
not in DI. Plus a direct reflection assertion that the type has exactly one
public constructor, since that is the real invariant and it is cheap to pin.
This commit is contained in:
Joseph Doherty
2026-07-22 17:56:51 -04:00
parent 2ee84af1c0
commit 228ff8b428
3 changed files with 43 additions and 4 deletions
@@ -62,7 +62,19 @@ public sealed class ControlPlaneAuthInterceptor : Interceptor
private readonly IOptions<CommunicationOptions> _options;
private readonly ILogger<ControlPlaneAuthInterceptor> _logger;
/// <summary>Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.</summary>
/// <summary>
/// Creates the interceptor gating <see cref="DefaultGatedPrefixes"/>.
/// </summary>
/// <remarks>
/// <b>This must remain the ONLY public constructor.</b> <c>AddGrpc</c> registers the
/// interceptor by type, and <c>Grpc.AspNetCore.Server.InterceptorRegistration.GetFactory()</c>
/// throws <c>"Multiple constructors accepting all given argument types have been found"</c>
/// when a second one is applicable. That throw happens per call, inside the pipeline, and
/// surfaces to the caller as <c>Unknown / "Exception was thrown by handler"</c> — so the gate
/// silently stops authorizing anything while still failing every call. A second public
/// constructor added here in a later phase reintroduces exactly that. Pinned by
/// <c>ControlPlaneAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor</c>.
/// </remarks>
/// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
/// <param name="logger">Logger for denial diagnostics.</param>
public ControlPlaneAuthInterceptor(
@@ -72,11 +84,16 @@ public sealed class ControlPlaneAuthInterceptor : Interceptor
{
}
/// <summary>Creates the interceptor gating an explicit set of service prefixes.</summary>
/// <summary>
/// Creates the interceptor gating an explicit set of service prefixes. <b>Internal</b> —
/// see the public constructor's remarks for why this cannot be public. Phases that add a
/// service to the gate should extend <see cref="DefaultGatedPrefixes"/> rather than reach
/// for a second registration shape.
/// </summary>
/// <param name="options">Communication options; <c>GrpcPsk</c> is the expected bearer token.</param>
/// <param name="logger">Logger for denial diagnostics.</param>
/// <param name="gatedPrefixes">Method-path prefixes to gate, e.g. <c>/sitestream.SiteStreamService/</c>.</param>
public ControlPlaneAuthInterceptor(
internal ControlPlaneAuthInterceptor(
IOptions<CommunicationOptions> options,
ILogger<ControlPlaneAuthInterceptor> logger,
IReadOnlyList<string> gatedPrefixes)
@@ -47,10 +47,16 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
.UseTestServer()
.ConfigureServices(services =>
{
// Registered exactly as Program.cs does: by TYPE on AddGrpc, with the
// interceptor itself NOT in DI. That is load-bearing. An earlier version of
// this test added it as a singleton, which let DI hand back the instance and
// bypassed Grpc.AspNetCore's own activation — hiding a defect where the
// interceptor had two public constructors and
// InterceptorRegistration.GetFactory() threw on every single call. The rig
// caught it; this test did not. Do not pre-register it.
services.AddGrpc(o => o.Interceptors.Add<ControlPlaneAuthInterceptor>());
services.AddSingleton(Options.Create(
new CommunicationOptions { GrpcPsk = SiteKey }));
services.AddSingleton<ControlPlaneAuthInterceptor>();
services.AddSingleton<EchoSiteStreamService>();
})
.Configure(app =>
@@ -205,6 +205,22 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, notGated));
}
[Fact]
public void TheInterceptorHasExactlyOnePublicConstructor()
{
// Grpc.AspNetCore registers this interceptor BY TYPE, and
// InterceptorRegistration.GetFactory() throws "Multiple constructors accepting all given
// argument types have been found" the moment a second public constructor is applicable.
// The throw lands inside the pipeline on every call, so the symptom is not a startup
// failure but a gate that authorizes nothing and fails everything with
// Unknown / "Exception was thrown by handler" — a shape that looks like a handler bug,
// not an auth bug. This shipped once and was caught only on the docker rig; the
// prefix-set constructor is internal now to keep it from recurring.
var publicCtors = typeof(ControlPlaneAuthInterceptor).GetConstructors();
Assert.Single(publicCtors);
}
[Fact]
public void DefaultGatedPrefixes_MatchTheRealSiteStreamServicePath()
{