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)); }