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