a224fd6391
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control, set a credential field to any reference the node can resolve (env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>, Galaxy file:<any path>) and click Test Connect / live browse; the node resolved the value and sent it to that host. One shared rule in Core.Abstractions (CredentialReferences + DriverCredentialPolicy) generalises the #803 Heidenhain namespace: - Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged. - OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged. - Heidenhain adopts the shared parser/resolver (namespace unchanged). Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate (CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test Connect (AdminOperationsActor, before any probe), live browse (BrowserSessionService, before any browser) and again at resolve time in every driver/browser (fail closed, before any store/env read). Co-Authored-By: Claude <noreply@anthropic.com>
261 lines
12 KiB
C#
261 lines
12 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
|
|
|
/// <summary>
|
|
/// Follow-up #2 + G-2a + lmxopcua#815 — pins the resolution forms supported by
|
|
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:OTOPCUA_GALAXY_NAME</c> (<c>file:</c> is refused),
|
|
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
|
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
|
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
|
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
|
|
/// runtime driver and the AdminUI browser share one copy.)
|
|
/// </summary>
|
|
public sealed class GalaxyDriverApiKeyResolverTests
|
|
{
|
|
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
|
|
private const string KnownSecretName = "galaxy/inst1/apikey";
|
|
|
|
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
|
|
private const string KnownSecretValue = "key-from-secret-store";
|
|
|
|
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
|
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
|
|
|
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
|
[Fact]
|
|
public async Task Literal_string_is_returned_unchanged()
|
|
{
|
|
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), ct: TestContext.Current.CancellationToken))
|
|
.ShouldBe("plain-text-key");
|
|
}
|
|
|
|
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
|
[Fact]
|
|
public async Task Env_prefix_resolves_to_environment_variable()
|
|
{
|
|
const string name = "OTOPCUA_GALAXY_TEST_API_KEY";
|
|
Environment.SetEnvironmentVariable(name, "key-from-env");
|
|
try
|
|
{
|
|
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), ct: TestContext.Current.CancellationToken))
|
|
.ShouldBe("key-from-env");
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(name, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
|
[Fact]
|
|
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
|
{
|
|
const string name = "OTOPCUA_GALAXY_TEST_API_KEY_UNSET";
|
|
Environment.SetEnvironmentVariable(name, null);
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
|
ex.Message.ShouldContain(name);
|
|
ex.Message.ShouldContain("not set");
|
|
}
|
|
|
|
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
|
|
|
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
|
|
[Fact]
|
|
public async Task Secret_prefix_resolves_through_secret_resolver()
|
|
{
|
|
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), ct: TestContext.Current.CancellationToken))
|
|
.ShouldBe(KnownSecretValue);
|
|
}
|
|
|
|
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
|
|
[Fact]
|
|
public async Task Secret_prefix_absent_secret_throws_fail_closed()
|
|
{
|
|
const string missingRef = "secret:galaxy/missing/apikey";
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
|
|
|
|
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
|
|
// must NOT return the ref string as the key.
|
|
ex.Message.ShouldContain("galaxy/missing/apikey");
|
|
ex.Message.ShouldContain("absent");
|
|
}
|
|
|
|
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
|
|
[Fact]
|
|
public async Task Secret_prefix_does_not_emit_literal_warning()
|
|
{
|
|
var logger = new CaptureLogger();
|
|
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger, TestContext.Current.CancellationToken);
|
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
|
}
|
|
|
|
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
|
|
|
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
|
[Fact]
|
|
public async Task Literal_string_emits_warning_when_logger_supplied()
|
|
{
|
|
// A literal API key on a production deployment means the cleartext key sits
|
|
// in the DriverConfig JSON. The resolver must surface a warning so an
|
|
// operator who committed one by accident sees it at startup.
|
|
var logger = new CaptureLogger();
|
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger, TestContext.Current.CancellationToken);
|
|
|
|
key.ShouldBe("plain-text-key");
|
|
logger.Entries.ShouldContain(e =>
|
|
e.Level == LogLevel.Warning && e.Message.Contains("literal", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
|
[Fact]
|
|
public async Task Dev_prefix_returns_literal_without_warning()
|
|
{
|
|
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
|
// key (dev / parity rig). The resolver must accept it AND suppress the
|
|
// warning so production logs aren't polluted on a deliberate dev choice.
|
|
var logger = new CaptureLogger();
|
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger, TestContext.Current.CancellationToken);
|
|
|
|
key.ShouldBe("plain-text-key");
|
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
|
}
|
|
|
|
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
|
[Fact]
|
|
public async Task Env_prefix_does_not_emit_literal_warning()
|
|
{
|
|
const string name = "OTOPCUA_GALAXY_TEST_API_KEY_NOWARN";
|
|
Environment.SetEnvironmentVariable(name, "v");
|
|
try
|
|
{
|
|
var logger = new CaptureLogger();
|
|
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger, TestContext.Current.CancellationToken);
|
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(name, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
|
|
[Fact]
|
|
public async Task Null_resolver_throws()
|
|
{
|
|
await Should.ThrowAsync<ArgumentNullException>(() =>
|
|
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
|
|
}
|
|
|
|
/// <summary>A test logger that captures log entries for verification.</summary>
|
|
private sealed class CaptureLogger : ILogger
|
|
{
|
|
/// <summary>Gets the list of captured log entries with their levels and messages.</summary>
|
|
public List<(LogLevel Level, string Message)> Entries { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
|
|
/// <inheritdoc />
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
/// <inheritdoc />
|
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
|
|
private sealed class StubSecretResolver : ISecretResolver
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
Task.FromResult<string?>(
|
|
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
|
|
? KnownSecretValue
|
|
: null);
|
|
}
|
|
|
|
// ===== lmxopcua#815: references are confined to the Galaxy namespace, checked before any read =====
|
|
|
|
/// <summary>
|
|
/// An out-of-namespace reference — another subsystem's secret or process variable — throws, and neither the
|
|
/// store nor the environment is consulted (the value is never read, so it can never be sent).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("env:ConnectionStrings__ConfigDb")]
|
|
[InlineData("env:ServerHistorian__ApiKey")]
|
|
[InlineData("env:OTOPCUA_GALAXY_lower")]
|
|
[InlineData("secret:otopcua/historian/api-key")]
|
|
[InlineData("secret:OT-MESH-PSK-MAIN")]
|
|
[InlineData("secret:galaxy/../otopcua/historian/api-key")]
|
|
[InlineData("${secret:otopcua/apikeys/abc/galaxy-ack-password}")]
|
|
[InlineData("SECRET:galaxy/inst1/apikey")]
|
|
[InlineData("file:/tmp/any-file")]
|
|
[InlineData("file:appsettings.json")]
|
|
public async Task Out_of_namespace_reference_is_refused_without_reading_store_or_environment(string reference)
|
|
{
|
|
var store = new CountingResolver();
|
|
var envReads = 0;
|
|
var ex = await Should.ThrowAsync<CredentialReferenceException>(() => GalaxySecretRef.ResolveApiKeyAsync(
|
|
reference, store, ct: TestContext.Current.CancellationToken, environment: _ => { envReads++; return "LEAKED"; }));
|
|
ex.Message.ShouldContain("secret:galaxy/<name>");
|
|
ex.Message.ShouldContain("env:OTOPCUA_GALAXY_<NAME>");
|
|
ex.Message.ShouldNotContain("LEAKED");
|
|
store.Calls.ShouldBe(0);
|
|
envReads.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>The namespaced environment forms (incl. the legacy GALAXY_MXGW_ variable) resolve.</summary>
|
|
[Theory]
|
|
[InlineData("env:OTOPCUA_GALAXY_API_KEY", "OTOPCUA_GALAXY_API_KEY")]
|
|
[InlineData("env:GALAXY_MXGW_API_KEY", "GALAXY_MXGW_API_KEY")]
|
|
public async Task Namespaced_environment_reference_resolves(string reference, string variable)
|
|
{
|
|
var key = await GalaxySecretRef.ResolveApiKeyAsync(
|
|
reference, FakeResolver(), ct: TestContext.Current.CancellationToken,
|
|
environment: n => n == variable ? "the-key" : null);
|
|
key.ShouldBe("the-key");
|
|
}
|
|
|
|
/// <summary>A file reference is refused even when the file exists — its contents are never returned.</summary>
|
|
[Fact]
|
|
public async Task File_reference_is_refused_even_when_the_file_exists()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
|
File.WriteAllText(path, "key-from-file");
|
|
try
|
|
{
|
|
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
|
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
|
ex.Message.ShouldNotContain("key-from-file");
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>Counts store lookups.</summary>
|
|
private sealed class CountingResolver : ISecretResolver
|
|
{
|
|
/// <summary>Gets the number of lookups.</summary>
|
|
public int Calls { get; private set; }
|
|
|
|
/// <inheritdoc />
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
Calls++;
|
|
return Task.FromResult<string?>("LEAKED");
|
|
}
|
|
}
|
|
}
|