fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)

- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding
- Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations
- Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15)
- Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read
- Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs
- Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup)
- Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests)
- Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
Joseph Doherty
2026-06-20 22:43:36 -04:00
parent 3cc6a5f30d
commit ab57e53b92
26 changed files with 577 additions and 220 deletions
@@ -30,7 +30,7 @@ public sealed record GalaxyDriverOptions(
/// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <c>GalaxyDriver.ResolveApiKey</c> at InitializeAsync time. Four forms
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
/// supported, in priority order:
/// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <remarks>
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
/// AdminUI <c>GalaxyDriverBrowser</c> (which intentionally don't reference each
/// other) share a single resolver rather than each maintaining a copy.
/// </remarks>
public static class GalaxySecretRef
{
/// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the
/// back-compat literal arm (an unprefixed cleartext API key in
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
// this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>