using System.Security.Cryptography; using System.Text; using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// Gates the central-hosted gRPC control plane (CentralControlService) with each site's /// preshared key. The sibling of , but with the /// verification model inverted: a site checks one bearer token against its own single key, whereas /// central must check the presented token against the key belonging to the SITE that sent it. /// /// /// /// Why a separate class rather than a second constructor on /// . Grpc.AspNetCore registers a /// type-registered interceptor through InterceptorRegistration.GetFactory(), which throws /// "Multiple constructors accepting all given argument types have been found" the moment a /// second public constructor is applicable. That throw lands inside the pipeline on every call and /// surfaces as Unknown / "Exception was thrown by handler" — the node boots healthy and /// every gated call dies looking like a handler bug, with correct/wrong/no key all producing the /// identical error. It shipped once with a fully green suite and was caught only on the rig. /// Central's model genuinely differs (per-site key by header, not the site's one own-key), so it /// gets its own class with its own single public constructor rather than a variant ctor on the /// site interceptor. Both classes are pinned by a reflection test asserting exactly one public /// constructor. /// /// /// Fail-closed on every branch. A gated call is refused with /// when: the x-scadabridge-site header is /// missing or blank; no key can be resolved for that site ( throws); /// or the presented bearer token does not match. There is no pass-through — an unresolvable or /// absent identity never degrades to "let it in". Non-gated services (should any share the /// listener) return immediately, matching the site interceptor's shape. /// /// /// The comparison is over UTF-8, the same /// constant-time compare the site interceptor and LocalDb sync use. /// /// public sealed class CentralControlAuthInterceptor : Interceptor { /// /// Service prefixes gated by default — the one central-hosted control-plane service. Taken /// from the generated package scadabridge.centralcontrol.v1; service CentralControlService. /// public static readonly IReadOnlyList DefaultGatedPrefixes = new[] { $"/{CentralControlService.Descriptor.FullName}/" }; private readonly IReadOnlyList _gatedPrefixes; private readonly ISitePskProvider _pskProvider; private readonly ILogger _logger; /// /// Creates the interceptor gating . /// /// /// This must remain the ONLY public constructor — see the class remarks for why a /// second one silently disables the gate. Pinned by /// CentralControlAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor. /// /// Resolves each site's preshared key. /// Logger for denial diagnostics. public CentralControlAuthInterceptor( ISitePskProvider pskProvider, ILogger logger) : this(pskProvider, logger, DefaultGatedPrefixes) { } /// /// Creates the interceptor gating an explicit prefix set. Internal — a public second /// constructor would reintroduce the ambiguous-constructor defect described on the class. /// /// Resolves each site's preshared key. /// Logger for denial diagnostics. /// Method-path prefixes to gate. internal CentralControlAuthInterceptor( ISitePskProvider pskProvider, ILogger logger, IReadOnlyList gatedPrefixes) { ArgumentNullException.ThrowIfNull(pskProvider); ArgumentNullException.ThrowIfNull(logger); ArgumentNullException.ThrowIfNull(gatedPrefixes); _pskProvider = pskProvider; _logger = logger; _gatedPrefixes = gatedPrefixes; } /// public override async Task UnaryServerHandler( TRequest request, ServerCallContext context, UnaryServerMethod continuation) { await AuthorizeAsync(context).ConfigureAwait(false); return await continuation(request, context).ConfigureAwait(false); } /// public override async Task DuplexStreamingServerHandler( IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context, DuplexStreamingServerMethod continuation) { await AuthorizeAsync(context).ConfigureAwait(false); await continuation(requestStream, responseStream, context).ConfigureAwait(false); } /// public override async Task ClientStreamingServerHandler( IAsyncStreamReader requestStream, ServerCallContext context, ClientStreamingServerMethod continuation) { await AuthorizeAsync(context).ConfigureAwait(false); return await continuation(requestStream, context).ConfigureAwait(false); } /// public override async Task ServerStreamingServerHandler( TRequest request, IServerStreamWriter responseStream, ServerCallContext context, ServerStreamingServerMethod continuation) { await AuthorizeAsync(context).ConfigureAwait(false); await continuation(request, responseStream, context).ConfigureAwait(false); } /// /// Throws with unless a /// gated call carries a valid x-scadabridge-site header AND a bearer token matching /// that site's resolved key. Non-gated calls return immediately. /// private async Task AuthorizeAsync(ServerCallContext context) { if (!IsGated(context.Method)) { return; } var siteId = ExtractSiteId(context.RequestHeaders); if (string.IsNullOrWhiteSpace(siteId)) { _logger.LogWarning( "Rejected a central control-plane call to {Method}: the required " + "'{Header}' metadata header is missing or blank, so there is no per-site key to " + "verify against.", context.Method, ControlPlaneCredentials.SiteHeader); throw Denied("missing site identity header"); } string expected; try { expected = await _pskProvider.GetAsync(siteId, context.CancellationToken) .ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { // Fail-closed: an unresolvable key is a denial, never a pass-through. The provider // has already logged the specific cause (missing secret / missing config entry). _logger.LogWarning(ex, "Rejected a central control-plane call to {Method}: no preshared key could be " + "resolved for site {SiteId}.", context.Method, siteId); throw Denied("no key configured for the presented site"); } var presented = ExtractBearerToken(context.RequestHeaders); if (presented is null || !FixedTimeEquals(presented, expected)) { _logger.LogWarning( "Rejected a central control-plane call to {Method} from site {SiteId}: {Reason}.", context.Method, siteId, presented is null ? "no bearer token presented" : "bearer token did not match"); throw Denied("control plane authentication failed"); } } private static RpcException Denied(string reason) => new(new Status(StatusCode.PermissionDenied, $"Control plane authentication failed: {reason}.")); private bool IsGated(string method) { foreach (var prefix in _gatedPrefixes) { if (method.StartsWith(prefix, StringComparison.Ordinal)) { return true; } } return false; } private static string? ExtractSiteId(Metadata headers) { foreach (var entry in headers) { if (string.Equals(entry.Key, ControlPlaneCredentials.SiteHeader, StringComparison.OrdinalIgnoreCase)) { return entry.Value; } } return null; } private static string? ExtractBearerToken(Metadata headers) { // gRPC lowercases header keys on the wire; compare case-insensitively 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)); }