518c699b90
Refactor SiteCommunicationActor's central→site routing table into one SiteCommandDispatcher — the single routing truth for the 28 migrated commands (IntegrationCallRequest, the dead 29th, stays on the actor and out of the dispatcher). The Akka actor and the new SiteCommandGrpcService both route through one dispatcher instance so the two transports can never drift on where a command goes. Server-side only: nothing central flips to gRPC yet (that is T1B.3); ClusterClient remains the live path. Decisions worth recording: - Targets preserved byte-for-byte. Lifecycle/OPC UA/query/route → the Deployment Manager singleton proxy; DeployArtifacts/EventLog/parked → their null-guarded handlers with the exact same "handler not available" replies; the parked handler stays NODE-LOCAL (per-node replicated-store owner), never the singleton proxy — pinned by a dispatcher test that asserts the target is the parked probe and NOT the dm proxy. - Sender preservation intact. The actor's command handlers became thin DispatchCommand delegations that still Forward (central Ask → reply routes straight back); the existing SiteCommunicationActorTests pass unchanged, which is the regression guard for that plumbing. UnsubscribeDebugView keeps its fire-and-forget shape: the actor Forwards, the gRPC service Tells + returns the synthetic UnsubscribeDebugViewAck so a unary RPC still answers. - Ack-before-Leave on failover. The dispatcher's PrepareFailover resolves the standby with a DRY-RUN (no leave) to build the ack, and hands back a deferred CommitLeave; the gRPC service returns the ack, then schedules the real Cluster.Leave — so a caller reaching the very node about to leave still gets its ack instead of a broken stream. The actor path keeps today's coupled resolve-and-leave (over ClusterClient the ack Tell only enqueues, so order is immaterial). Proven at both levels: a dispatcher test asserts the ack is built before CommitLeave runs, and a TestServer test asserts the recorded seam order is resolve-then-leave. - ControlPlaneAuthInterceptor gates SiteCommandService by EXTENDING DefaultGatedPrefixes (descriptor-derived), not by adding a constructor — the one-public-ctor invariant and its test stay green. Tests: SiteCommandDispatcherTests (28-command routing incl. parked node-locality and both failover paths) and SiteCommandGrpcService TestServer tests (auth, readiness→Unavailable, one command per oneof group, failover ordering). Full solution build 0/0; Communication.Tests 574 and Host.Tests 377 green. No active <Protobuf> item.
235 lines
10 KiB
C#
235 lines
10 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Grpc.Core;
|
|
using Grpc.Core.Interceptors;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
/// <summary>
|
|
/// Gates the site↔central gRPC control plane with a preshared key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The gap this closes.</b> <c>SiteStreamService</c> shipped with no authentication at all —
|
|
/// plaintext h2c, no interceptor. Anything that could reach a site node's gRPC port could open a
|
|
/// live data stream or call <c>PullAuditEvents</c>/<c>PullSiteCalls</c> and read audit rows back.
|
|
/// The only gated surface on that listener was LocalDb sync, and only for its own service. That
|
|
/// gap exists independently of the ClusterClient→gRPC migration; it becomes indefensible once
|
|
/// every command crosses this listener.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Modeled on <see cref="LocalDbSyncAuthInterceptor"/>,</b> deliberately: same four server
|
|
/// handlers funnelling into one <c>Authorize</c>, same <c>authorization: Bearer</c> extraction,
|
|
/// same <see cref="CryptographicOperations.FixedTimeEquals"/> comparison, same fail-closed
|
|
/// posture, same <see cref="StatusCode.PermissionDenied"/> rejection. Two differences:
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>It gates a <b>set</b> of service prefixes rather than one, so later phases can add the
|
|
/// new command/control services without a second interceptor.</item>
|
|
/// <item>Its expected key comes from <see cref="CommunicationOptions.GrpcPsk"/> — the site's own
|
|
/// key, supplied in production as <c>${secret:SB-GRPC-PSK-<siteId>}</c> and expanded before
|
|
/// the host is built.</item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <b>The two keys are separate on purpose.</b> LocalDb sync keeps its own
|
|
/// <c>LocalDb:Replication:ApiKey</c>, which authenticates a different peer (the pair partner, not
|
|
/// central) over a different trust relationship. Sharing one key would mean a site's central-facing
|
|
/// key also admits writes into its database.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Fail-closed, and not optional.</b> With no <c>GrpcPsk</c> configured, every gated call is
|
|
/// rejected — including the ones that work today. That is a deliberate break: LocalDb replication
|
|
/// is an opt-in feature whose "off" state is "no peer", whereas streaming and audit pull are
|
|
/// core paths, so "no key" must not silently mean "no authentication". Every environment must
|
|
/// carry a key before upgrading to this build.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ControlPlaneAuthInterceptor : Interceptor
|
|
{
|
|
/// <summary>
|
|
/// Service prefixes gated by default. Read from the generated service descriptors —
|
|
/// <c>package sitestream; service SiteStreamService</c> (real-time data + audit pull) and
|
|
/// <c>package scadabridge.sitecommand.v1; service SiteCommandService</c> (the T1B command
|
|
/// plane). Later phases append their own services here rather than adding a second
|
|
/// interceptor or constructor (see the public constructor's remarks).
|
|
/// </summary>
|
|
public static readonly IReadOnlyList<string> DefaultGatedPrefixes =
|
|
new[]
|
|
{
|
|
$"/{SiteStreamService.Descriptor.FullName}/",
|
|
$"/{SiteCommandService.Descriptor.FullName}/",
|
|
};
|
|
|
|
private readonly IReadOnlyList<string> _gatedPrefixes;
|
|
private readonly IOptions<CommunicationOptions> _options;
|
|
private readonly ILogger<ControlPlaneAuthInterceptor> _logger;
|
|
|
|
/// <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(
|
|
IOptions<CommunicationOptions> options,
|
|
ILogger<ControlPlaneAuthInterceptor> logger)
|
|
: this(options, logger, DefaultGatedPrefixes)
|
|
{
|
|
}
|
|
|
|
/// <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>
|
|
internal ControlPlaneAuthInterceptor(
|
|
IOptions<CommunicationOptions> options,
|
|
ILogger<ControlPlaneAuthInterceptor> logger,
|
|
IReadOnlyList<string> gatedPrefixes)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(logger);
|
|
ArgumentNullException.ThrowIfNull(gatedPrefixes);
|
|
|
|
_options = options;
|
|
_logger = logger;
|
|
_gatedPrefixes = gatedPrefixes;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
|
TRequest request,
|
|
ServerCallContext context,
|
|
UnaryServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(request, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
|
|
IAsyncStreamReader<TRequest> requestStream,
|
|
IServerStreamWriter<TResponse> responseStream,
|
|
ServerCallContext context,
|
|
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(requestStream, responseStream, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
|
|
IAsyncStreamReader<TRequest> requestStream,
|
|
ServerCallContext context,
|
|
ClientStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(requestStream, context);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
|
|
TRequest request,
|
|
IServerStreamWriter<TResponse> responseStream,
|
|
ServerCallContext context,
|
|
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
Authorize(context);
|
|
return continuation(request, responseStream, context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throws <see cref="RpcException"/> with <see cref="StatusCode.PermissionDenied"/> if this
|
|
/// is a gated call that does not carry the configured bearer token. Calls to services
|
|
/// outside <c>gatedPrefixes</c> — notably LocalDb sync, which has its own interceptor —
|
|
/// return immediately.
|
|
/// </summary>
|
|
private void Authorize(ServerCallContext context)
|
|
{
|
|
if (!IsGated(context.Method))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var expected = _options.Value.GrpcPsk;
|
|
if (string.IsNullOrEmpty(expected))
|
|
{
|
|
_logger.LogWarning(
|
|
"Rejected a control-plane call to {Method}: no ScadaBridge:Communication:GrpcPsk is "
|
|
+ "configured, so the control plane is closed. Set the same key here (in production, "
|
|
+ "as ${{secret:SB-GRPC-PSK-<siteId>}}) and in central's secret store.",
|
|
context.Method);
|
|
throw new RpcException(new Status(
|
|
StatusCode.PermissionDenied,
|
|
"Control plane is not accepting calls: no preshared key is configured on this node."));
|
|
}
|
|
|
|
var presented = ExtractBearerToken(context.RequestHeaders);
|
|
if (presented is null || !FixedTimeEquals(presented, expected))
|
|
{
|
|
_logger.LogWarning(
|
|
"Rejected a control-plane call to {Method}: {Reason}.",
|
|
context.Method,
|
|
presented is null ? "no bearer token presented" : "bearer token did not match");
|
|
throw new RpcException(new Status(
|
|
StatusCode.PermissionDenied,
|
|
"Control plane authentication failed."));
|
|
}
|
|
}
|
|
|
|
private bool IsGated(string method)
|
|
{
|
|
foreach (var prefix in _gatedPrefixes)
|
|
{
|
|
if (method.StartsWith(prefix, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static string? ExtractBearerToken(Metadata headers)
|
|
{
|
|
// gRPC lowercases header keys on the wire; compare case-insensitively anyway so a
|
|
// hand-built Metadata in a test behaves the same as a real request.
|
|
foreach (var entry in headers)
|
|
{
|
|
if (!string.Equals(entry.Key, ControlPlaneCredentials.AuthorizationHeader,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var value = entry.Value;
|
|
if (value is not null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return value["Bearer ".Length..];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool FixedTimeEquals(string presented, string expected)
|
|
=> CryptographicOperations.FixedTimeEquals(
|
|
Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected));
|
|
}
|