1424a21419
Add a secret:NAME arm to GalaxySecretRef.ResolveApiKey that resolves the Galaxy gateway API key through the shared ISecretResolver — fail-closed if the secret is absent (never falls through to the cleartext literal arm), retiring the dev:/literal in-DB path for production. Because GetAsync is async the method becomes ResolveApiKeyAsync; the await cascade threads ISecretResolver by ctor injection into GalaxyDriver + GalaxyDriverBrowser and (since GalaxyDriver is built by a static factory closure, not DI) through GalaxyDriverFactoryExtensions + DriverFactoryBootstrap (which pulls the real resolver from the service provider — registered unconditionally in Slice 1). A NullSecretResolver null-object backs the parse-only/test paths only; the runtime path always gets the real resolver (verified end-to-end). TDD: 3 new secret:-arm tests (resolve / fail-closed-on-absent / no-literal-warning) RED without the arm, GREEN with it; 338 Galaxy tests pass; no sync-over-async.
117 lines
6.2 KiB
C#
117 lines
6.2 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
|
|
/// <summary>
|
|
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
|
/// 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><c>secret:NAME</c> — resolves NAME through the shared
|
|
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
|
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
|
/// throws rather than falling through to the literal arm — the production path
|
|
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</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.</item>
|
|
/// </list>
|
|
/// A future PR can swap any of these arms for a different backing store 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. The <c>secret:NAME</c> arm resolves
|
|
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
|
/// is absent). 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="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
|
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
|
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
|
/// <returns>The resolved API-key string.</returns>
|
|
public static async Task<string> ResolveApiKeyAsync(
|
|
string secretRef,
|
|
ISecretResolver resolver,
|
|
ILogger? logger = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
|
ArgumentNullException.ThrowIfNull(resolver);
|
|
|
|
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..];
|
|
}
|
|
|
|
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Production path: resolve the name through the shared encrypted secret store.
|
|
// Fail-closed — an absent/tombstoned secret throws rather than falling through
|
|
// to the literal arm (which would silently treat the ref string as the key).
|
|
var name = secretRef["secret:".Length..];
|
|
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
|
return !string.IsNullOrEmpty(value)
|
|
? value
|
|
: throw new InvalidOperationException(
|
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|