using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.ScadaBridge.Host; /// /// Central's : resolves each site's gRPC preshared key at /// channel-build time — from ScadaBridge:Communication:SitePsks if the site is listed /// there, otherwise from the secrets store under the name SB-GRPC-PSK-{siteId}. /// /// /// /// Why resolved at runtime rather than expanded into config at boot. Site nodes get their /// single key through the pre-host SecretReferenceExpander — a ${secret:…} in /// appsettings, resolved once before the host is built. Central cannot do that: its set of sites /// comes from the configuration database and changes while the process runs, so there is no /// fixed list of references to expand at boot. This mirrors the pattern the MxGateway data /// connection already uses for its per-connection API keys. /// /// /// Caching. Successful resolves are cached indefinitely; a site's key changes only on /// rotation, and rotation restarts the pair. Failures are deliberately NOT cached, so a key /// seeded after the first (failed) dial is picked up on the next attempt instead of requiring a /// restart. drops an entry when a site is removed or a key is rotated /// in place. /// /// /// Fail-closed. A key absent from BOTH sources throws, and the dial that needed it fails. /// It never degrades to an unauthenticated call. The message names the exact config key and the /// exact secret name, so the fix is one setting or one secret CLI seed. /// /// public sealed class SitePskProvider : ISitePskProvider { /// Prefix of the per-site secret name; the site identifier is appended verbatim. public const string SecretNamePrefix = "SB-GRPC-PSK-"; private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); private readonly ISecretResolver _resolver; private readonly IOptionsMonitor _options; private readonly ILogger _logger; /// Creates the provider over the host's secret resolver. /// Runtime secret resolver from the host container. /// /// Communication options supplying the optional SitePsks map. Read through an /// so a reloaded configuration is honoured on the /// next uncached resolve rather than pinned at construction. /// /// Logger for resolution diagnostics. public SitePskProvider( ISecretResolver resolver, IOptionsMonitor options, ILogger logger) { ArgumentNullException.ThrowIfNull(resolver); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _resolver = resolver; _options = options; _logger = logger; } /// Builds the secret name for a site. /// Site identifier. /// The secret name, e.g. SB-GRPC-PSK-site-a. public static string SecretNameFor(string siteId) => SecretNamePrefix + siteId; /// public async ValueTask GetAsync(string siteId, CancellationToken ct) { ArgumentException.ThrowIfNullOrWhiteSpace(siteId); if (_cache.TryGetValue(siteId, out var cached)) { return cached; } // Configured map first: it is the explicit, operator-stated answer for this site, and // it is the only source available to a host running without a secrets master key. var configured = _options.CurrentValue.SitePsks; if (configured.TryGetValue(siteId, out var fromConfig) && !string.IsNullOrEmpty(fromConfig)) { _cache[siteId] = fromConfig; return fromConfig; } // Otherwise the store, which is the only source that can serve a site added at runtime. var secretName = SecretNameFor(siteId); var value = await ResolveFromStoreAsync(secretName, siteId, ct).ConfigureAwait(false); if (string.IsNullOrEmpty(value)) { _logger.LogError( "gRPC control-plane key for site {SiteId} could not be resolved: it is absent from " + "ScadaBridge:Communication:SitePsks and secret '{SecretName}' is missing, empty or " + "tombstoned. Calls to this site are refused until one of the two is set (the site " + "node must carry the same value in ScadaBridge:Communication:GrpcPsk).", siteId, secretName); throw new InvalidOperationException( $"gRPC preshared key for site '{siteId}' could not be resolved from " + $"ScadaBridge:Communication:SitePsks or secret '{secretName}'."); } // Two concurrent first dials may both resolve; the read is idempotent and the values // identical, so the loser simply overwrites with the same string. _cache[siteId] = value; return value; } /// /// Reads the key from the secrets store, treating a store fault as "not found" rather than /// letting it propagate. A host with no master key configured (the development rig) throws /// from the resolver; that must produce the same clear "key not configured" error as a /// missing secret, not an opaque cryptographic one. /// private async Task ResolveFromStoreAsync(string secretName, string siteId, CancellationToken ct) { try { return await _resolver.GetAsync(new SecretName(secretName), ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.LogDebug(ex, "Secret store lookup for '{SecretName}' (site {SiteId}) faulted; treating as not found.", secretName, siteId); return null; } } /// public void Invalidate(string siteId) { if (!string.IsNullOrWhiteSpace(siteId)) { _cache.TryRemove(siteId, out _); } } }