780bb9c369
Central now ALSO listens for the seven site→central control messages over gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet — sites keep CentralTransport=Akka (T1A.3's job); central simply starts also accepting. - CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the SAME in-process message the ClusterClient path carries, Asks the existing CentralCommunicationActor (zero handler-logic changes), encodes the reply via the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady — Unavailable until AkkaHostedService hands the actor over. Heartbeat stays fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status mapping is retry-aware: Unavailable (never dispatched, safe to cross-node retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere). - CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site (verify the Bearer token against the key for the site in the required x-scadabridge-site header, via ISitePskProvider) where a site verifies its one own-key — a genuinely different model. Fail-closed on every branch: missing or blank header, unresolvable key, and mismatched token all → PermissionDenied, never pass-through. One public constructor only (the explicit-prefix ctor is internal), pinned by a reflection test — a second public ctor makes Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate. - Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort (default 8083, symmetric with sites), mirroring the Site branch. Additive to central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with MapGrpcService. Port range-validated by NodeOptionsValidator. - Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes so a later task can exercise it. Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor + real service over a stub actor) proves auth positives/negatives are distinguishable and covers unary + the ingest bridge shapes; the interceptor is registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests (Communication.Tests, TestKit) covers the readiness gate, fire-and-forget heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active <Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
198 lines
8.7 KiB
C#
198 lines
8.7 KiB
C#
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of
|
|
/// <see cref="ControlPlaneAuthInterceptorTests"/>, but central's verification model is inverted:
|
|
/// it looks up the key for the site named in the <c>x-scadabridge-site</c> header and checks the
|
|
/// bearer token against THAT, rather than against one own-key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Central genuinely needs a distinct verification model, so it is a separate class with its own
|
|
/// single public constructor — not a variant ctor on <see cref="ControlPlaneAuthInterceptor"/>,
|
|
/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor
|
|
/// invariant is pinned below exactly as the sibling pins it.
|
|
/// </remarks>
|
|
public class CentralControlAuthInterceptorTests
|
|
{
|
|
private const string ControlMethod =
|
|
"/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification";
|
|
private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance";
|
|
|
|
private const string SiteA = "site-a";
|
|
private const string SiteAKey = "site-a-preshared-key";
|
|
private const string SiteB = "site-b";
|
|
private const string SiteBKey = "site-b-preshared-key";
|
|
|
|
/// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary>
|
|
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
|
|
{
|
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
|
|
=> keys.TryGetValue(siteId, out var key)
|
|
? new ValueTask<string>(key)
|
|
: throw new InvalidOperationException($"no key for '{siteId}'");
|
|
|
|
public void Invalidate(string siteId) { }
|
|
}
|
|
|
|
private static CentralControlAuthInterceptor CreateInterceptor()
|
|
=> new(
|
|
new MapPskProvider(new Dictionary<string, string> { [SiteA] = SiteAKey, [SiteB] = SiteBKey }),
|
|
NullLogger<CentralControlAuthInterceptor>.Instance);
|
|
|
|
private static ServerCallContext CreateContext(
|
|
string method, string? siteHeader, string? authorizationHeader)
|
|
{
|
|
var headers = new Metadata();
|
|
if (siteHeader is not null)
|
|
headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader);
|
|
if (authorizationHeader is not null)
|
|
headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader);
|
|
|
|
return new FakeServerCallContext(method, headers);
|
|
}
|
|
|
|
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
|
|
: ServerCallContext
|
|
{
|
|
protected override string MethodCore => method;
|
|
protected override string HostCore => "localhost";
|
|
protected override string PeerCore => "ipv4:127.0.0.1:12345";
|
|
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
|
protected override Metadata RequestHeadersCore => requestHeaders;
|
|
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
|
protected override Metadata ResponseTrailersCore { get; } = [];
|
|
protected override Status StatusCore { get; set; }
|
|
protected override WriteOptions? WriteOptionsCore { get; set; }
|
|
protected override AuthContext AuthContextCore { get; } =
|
|
new(null, new Dictionary<string, List<AuthProperty>>());
|
|
|
|
protected override ContextPropagationToken CreatePropagationTokenCore(
|
|
ContextPropagationOptions? options)
|
|
=> throw new NotSupportedException();
|
|
|
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
|
=> Task.CompletedTask;
|
|
}
|
|
|
|
private static Task<string> Invoke(
|
|
CentralControlAuthInterceptor interceptor, ServerCallContext context)
|
|
=> interceptor.UnaryServerHandler<string, string>(
|
|
"request", context, (_, _) => Task.FromResult("ok"));
|
|
|
|
[Fact]
|
|
public async Task NonGatedMethod_PassesThrough()
|
|
{
|
|
// Central's interceptor gates only CentralControlService; anything else on the listener
|
|
// (there is nothing today) is not its concern.
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(SiteStreamMethod, siteHeader: null, authorizationHeader: null);
|
|
|
|
Assert.Equal("ok", await Invoke(interceptor, context));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CorrectSiteAndKey_IsAccepted()
|
|
{
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}");
|
|
|
|
Assert.Equal("ok", await Invoke(interceptor, context));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MissingSiteHeader_IsDenied()
|
|
{
|
|
// Fail-closed: no header means no per-site key to verify against, so there is nothing to
|
|
// pass through TO. A present bearer token does not rescue it.
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, siteHeader: null, $"Bearer {SiteAKey}");
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BlankSiteHeader_IsDenied()
|
|
{
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, siteHeader: " ", $"Bearer {SiteAKey}");
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnknownSite_IsDenied_NotPassedThrough()
|
|
{
|
|
// The provider throws for an unknown site; the interceptor must turn that into a denial,
|
|
// never swallow it and let the call proceed unauthenticated.
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, "site-nonexistent", "Bearer whatever");
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoBearerToken_IsDenied()
|
|
{
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, SiteA, authorizationHeader: null);
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongKeyForTheSite_IsDenied()
|
|
{
|
|
// site-a presents site-b's key. Both keys are valid keys; the point is the token must
|
|
// match the key for THIS site, not just be a key central knows.
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteBKey}");
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
|
|
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EachSiteIsVerifiedAgainstItsOwnKey()
|
|
{
|
|
// site-b with site-b's key is accepted by the same interceptor instance that rejected
|
|
// site-a-with-site-b's-key above — proving the per-site lookup, not a single shared key.
|
|
var interceptor = CreateInterceptor();
|
|
var context = CreateContext(ControlMethod, SiteB, $"Bearer {SiteBKey}");
|
|
|
|
Assert.Equal("ok", await Invoke(interceptor, context));
|
|
}
|
|
|
|
[Fact]
|
|
public void TheInterceptorHasExactlyOnePublicConstructor()
|
|
{
|
|
// Grpc.AspNetCore registers this interceptor BY TYPE; InterceptorRegistration.GetFactory()
|
|
// throws "Multiple constructors accepting all given argument types have been found" the
|
|
// moment a second public constructor is applicable, and the throw lands inside the
|
|
// pipeline on every call — a gate that authorizes nothing while looking like a handler
|
|
// bug. The explicit-prefix constructor is internal to keep it from recurring; this pins
|
|
// that. (Same invariant as ControlPlaneAuthInterceptorTests.)
|
|
var publicCtors = typeof(CentralControlAuthInterceptor).GetConstructors();
|
|
|
|
Assert.Single(publicCtors);
|
|
}
|
|
|
|
[Fact]
|
|
public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath()
|
|
{
|
|
// A typo here disables the whole gate silently: every call would pass through. Pin it
|
|
// against the generated service descriptor, not the proto text.
|
|
var method = CentralControlService.Descriptor.FullName;
|
|
Assert.Contains(
|
|
CentralControlAuthInterceptor.DefaultGatedPrefixes,
|
|
p => p == $"/{method}/");
|
|
}
|
|
}
|