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;
///
/// Gates the site↔central gRPC control plane with a preshared key.
///
///
///
/// The gap this closes. SiteStreamService 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 PullAuditEvents/PullSiteCalls 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.
///
///
/// Modeled on , deliberately: same four server
/// handlers funnelling into one Authorize, same authorization: Bearer extraction,
/// same comparison, same fail-closed
/// posture, same rejection. Two differences:
///
///
/// - It gates a set of service prefixes rather than one, so later phases can add the
/// new command/control services without a second interceptor.
/// - Its expected key comes from — the site's own
/// key, supplied in production as ${secret:SB-GRPC-PSK-<siteId>} and expanded before
/// the host is built.
///
///
/// The two keys are separate on purpose. LocalDb sync keeps its own
/// LocalDb:Replication:ApiKey, 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.
///
///
/// Fail-closed, and not optional. With no GrpcPsk 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.
///
///
public sealed class ControlPlaneAuthInterceptor : Interceptor
{
///
/// Service prefixes gated by default. Read from the generated service descriptors —
/// package sitestream; service SiteStreamService (real-time data + audit pull) and
/// package scadabridge.sitecommand.v1; service SiteCommandService (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).
///
public static readonly IReadOnlyList DefaultGatedPrefixes =
new[]
{
$"/{SiteStreamService.Descriptor.FullName}/",
$"/{SiteCommandService.Descriptor.FullName}/",
};
private readonly IReadOnlyList _gatedPrefixes;
private readonly IOptions _options;
private readonly ILogger _logger;
///
/// Creates the interceptor gating .
///
///
/// This must remain the ONLY public constructor. AddGrpc registers the
/// interceptor by type, and Grpc.AspNetCore.Server.InterceptorRegistration.GetFactory()
/// throws "Multiple constructors accepting all given argument types have been found"
/// when a second one is applicable. That throw happens per call, inside the pipeline, and
/// surfaces to the caller as Unknown / "Exception was thrown by handler" — 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
/// ControlPlaneAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor.
///
/// Communication options; GrpcPsk is the expected bearer token.
/// Logger for denial diagnostics.
public ControlPlaneAuthInterceptor(
IOptions options,
ILogger logger)
: this(options, logger, DefaultGatedPrefixes)
{
}
///
/// Creates the interceptor gating an explicit set of service prefixes. Internal —
/// see the public constructor's remarks for why this cannot be public. Phases that add a
/// service to the gate should extend rather than reach
/// for a second registration shape.
///
/// Communication options; GrpcPsk is the expected bearer token.
/// Logger for denial diagnostics.
/// Method-path prefixes to gate, e.g. /sitestream.SiteStreamService/.
internal ControlPlaneAuthInterceptor(
IOptions options,
ILogger logger,
IReadOnlyList gatedPrefixes)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(gatedPrefixes);
_options = options;
_logger = logger;
_gatedPrefixes = gatedPrefixes;
}
///
public override Task UnaryServerHandler(
TRequest request,
ServerCallContext context,
UnaryServerMethod continuation)
{
Authorize(context);
return continuation(request, context);
}
///
public override Task DuplexStreamingServerHandler(
IAsyncStreamReader requestStream,
IServerStreamWriter responseStream,
ServerCallContext context,
DuplexStreamingServerMethod continuation)
{
Authorize(context);
return continuation(requestStream, responseStream, context);
}
///
public override Task ClientStreamingServerHandler(
IAsyncStreamReader requestStream,
ServerCallContext context,
ClientStreamingServerMethod continuation)
{
Authorize(context);
return continuation(requestStream, context);
}
///
public override Task ServerStreamingServerHandler(
TRequest request,
IServerStreamWriter responseStream,
ServerCallContext context,
ServerStreamingServerMethod continuation)
{
Authorize(context);
return continuation(request, responseStream, context);
}
///
/// Throws with if this
/// is a gated call that does not carry the configured bearer token. Calls to services
/// outside gatedPrefixes — notably LocalDb sync, which has its own interceptor —
/// return immediately.
///
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-}}) 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));
}