Merge fix/815-credential-ref-scoping
# Conflicts: # .claude/rules/drivers.md # docs/drivers/Heidenhain.md
This commit is contained in:
@@ -64,9 +64,11 @@ public sealed record GalaxyDriverOptions(
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="FallbackPasswordSecretRef"/> must be a reference — <c>secret:NAME</c> (production),
|
||||
/// <c>env:NAME</c>, <c>file:PATH</c> or <c>dev:VALUE</c> (rigs only). A bare literal is rejected so a
|
||||
/// Galaxy password can never sit in cleartext in the DriverConfig JSON / config DB.
|
||||
/// <see cref="FallbackPasswordSecretRef"/> must be a Galaxy-namespaced reference (lmxopcua#815) —
|
||||
/// <c>secret:galaxy/NAME</c> / <c>secret:otopcua/galaxy/NAME</c> (production), <c>env:OTOPCUA_GALAXY_NAME</c>
|
||||
/// / <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c> (rigs only). A bare literal, <c>file:</c> and any other
|
||||
/// reference are rejected so a Galaxy password never sits in cleartext in the config DB and the field can
|
||||
/// never name another subsystem's secret.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="FallbackUser">Galaxy user the fallback acknowledges as; null/empty = no fallback.</param>
|
||||
@@ -84,20 +86,16 @@ public sealed record GalaxyAlarmAckOptions(
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||
/// supported, in priority order:
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. References are confined
|
||||
/// to the Galaxy namespace (lmxopcua#815 — the key is sent to the authored <c>Endpoint</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// <item><c>secret:galaxy/NAME</c> (or <c>secret:otopcua/galaxy/NAME</c>) — the shared <c>ZB.MOM.WW.Secrets</c>
|
||||
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||
/// no startup warning.</item>
|
||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||
/// The resolver emits a <c>Warning</c> at startup so an operator who accidentally
|
||||
/// committed a cleartext key sees it; production should migrate to <c>env:</c>
|
||||
/// or <c>file:</c>.</item>
|
||||
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> (or legacy <c>env:GALAXY_MXGW_NAME</c>) — an environment variable.</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests; no startup warning.</item>
|
||||
/// <item>Any other unprefixed string — a literal cleartext API key for back-compat, with a startup
|
||||
/// <c>Warning</c>.</item>
|
||||
/// <item>Any other <c>secret:</c>/<c>env:</c> reference, and every <c>file:</c> reference — refused.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
// PR 6.5 tuning notes:
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
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:
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. The reference is resolved on the node
|
||||
/// and sent to the operator-authored <c>Gateway.Endpoint</c>, so references are confined to the Galaxy
|
||||
/// namespace (lmxopcua#815, <see cref="DriverCredentialPolicy.GalaxyScope"/>) and the check is repeated here, at
|
||||
/// resolve time, before any store or environment read:
|
||||
/// <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>
|
||||
/// <item><c>secret:galaxy/NAME</c> (or <c>${secret:galaxy/NAME}</c>) — resolved through the shared
|
||||
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/>; fail-closed when absent.</item>
|
||||
/// <item><c>env:OTOPCUA_GALAXY_NAME</c> / legacy <c>env:GALAXY_MXGW_NAME</c> — the process environment;
|
||||
/// fail-closed when unset.</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal (dev box, parity rig); no warning.</item>
|
||||
/// <item>Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied.</item>
|
||||
/// <item>A <c>secret:</c>/<c>env:</c> reference OUTSIDE the namespace, and every <c>file:</c> reference (it
|
||||
/// read any file the node could open) — refused with <see cref="CredentialReferenceException"/>.</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
|
||||
@@ -37,80 +28,108 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
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.
|
||||
/// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace
|
||||
/// reference, a <c>file:</c> reference, an absent secret and an unset variable.
|
||||
/// </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>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">
|
||||
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver passes its tracker, so a pre-#815 out-of-namespace
|
||||
/// reference is resolved with the old semantics and warned about instead of failing. The AdminUI browser
|
||||
/// passes null (hard-fail). See <see cref="LegacyCredentialReferenceTracker"/>.
|
||||
/// </param>
|
||||
/// <returns>The resolved API-key string.</returns>
|
||||
public static async Task<string> ResolveApiKeyAsync(
|
||||
string secretRef,
|
||||
ISecretResolver resolver,
|
||||
ILogger? logger = null,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default,
|
||||
Func<string, string?>? environment = null,
|
||||
LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var scope = DriverCredentialPolicy.GalaxyScope;
|
||||
var kind = CredentialReferences.Parse(secretRef, scope, out _);
|
||||
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
|
||||
{
|
||||
legacy.Report("Gateway.ApiKeySecretRef");
|
||||
return await LegacyResolveApiKeyAsync(secretRef, resolver, logger, ct, environment).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (kind == CredentialReferenceKind.Literal)
|
||||
{
|
||||
if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
|
||||
return secretRef[CredentialReferences.DevPrefix.Length..];
|
||||
}
|
||||
|
||||
// Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it.
|
||||
logger?.LogWarning(
|
||||
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||
"Prefer secret:galaxy/<name> or env:OTOPCUA_GALAXY_<NAME>, 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;
|
||||
}
|
||||
|
||||
// secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read.
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
secretRef,
|
||||
scope,
|
||||
"Galaxy.Gateway.ApiKeySecretRef",
|
||||
DriverCredentialPolicy.GalaxyApiKeyField.Literals,
|
||||
async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
|
||||
/// verbatim — case-insensitive <c>env:</c> / <c>file:</c> / <c>dev:</c> / <c>secret:</c>, anything else a
|
||||
/// literal. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
|
||||
/// </summary>
|
||||
private static async Task<string> LegacyResolveApiKeyAsync(
|
||||
string secretRef, ISecretResolver resolver, ILogger? logger, CancellationToken ct, Func<string, string?>? environment)
|
||||
{
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = secretRef[4..];
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
var value = (environment ?? Environment.GetEnvironmentVariable)(name);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||
$"Galaxy.Gateway.ApiKeySecretRef 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();
|
||||
throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef points at '{path}', which doesn't exist.");
|
||||
var contents = (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).Trim();
|
||||
return !string.IsNullOrEmpty(contents)
|
||||
? contents
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||
: throw new InvalidOperationException($"Galaxy.Gateway.ApiKeySecretRef 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("dev:", StringComparison.OrdinalIgnoreCase)) 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).");
|
||||
$"Galaxy.Gateway.ApiKeySecretRef 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.");
|
||||
logger?.LogWarning("Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key.");
|
||||
return secretRef;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IAlarmAcknowledgePreflight, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): this RUNNING driver keeps resolving a pre-#815
|
||||
// out-of-namespace credential reference (API key on InitializeAsync AND every gateway ReopenAsync, plus the
|
||||
// ack fallback password) with the old semantics, warning rate-limited and surfacing it on the diagnostics bag.
|
||||
// The AdminUI browser and the probe never get a tracker, so they stay hard-fail.
|
||||
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
|
||||
|
||||
/// <inheritdoc />
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
|
||||
|
||||
/// <summary>Authored tags this driver could not dial — a TagConfig with no usable
|
||||
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -342,6 +351,8 @@ public sealed class GalaxyDriver
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_legacyCredentials = new LegacyCredentialReferenceTracker(
|
||||
DriverCredentialPolicy.GalaxyScope, _driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
@@ -349,7 +360,7 @@ public sealed class GalaxyDriver
|
||||
_alarmAcknowledger = alarmAcknowledger;
|
||||
_alarmFeed = alarmFeed;
|
||||
_ackCredentialResolver = ackCredentialResolver
|
||||
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger);
|
||||
?? new GalaxyAckCredentialResolver(_driverInstanceId, _options.AlarmAck, _secretResolver, _logger, legacy: _legacyCredentials);
|
||||
|
||||
// Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags
|
||||
// are authored (live-browse path), leaving every translation an identity no-op.
|
||||
@@ -1098,7 +1109,9 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
// Internal: the ONE client-options build InitializeAsync, ReopenAsync (every gateway reconnect) and the
|
||||
// repository/alarm clients share — the #815 warn-only tests drive it directly to prove a reconnect re-resolves.
|
||||
internal async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||
{
|
||||
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||
@@ -1108,7 +1121,7 @@ public sealed class GalaxyDriver
|
||||
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||
var apiKey = await GalaxySecretRef
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken, legacy: _legacyCredentials)
|
||||
.ConfigureAwait(false);
|
||||
return new MxGatewayClientOptions
|
||||
{
|
||||
|
||||
+46
-37
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
@@ -20,15 +21,22 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
private readonly GalaxyAlarmAckOptions _options;
|
||||
private readonly ISecretResolver _secrets;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<string, string?>? _environment;
|
||||
private readonly LegacyCredentialReferenceTracker? _legacy;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyAckCredentialResolver"/> class.</summary>
|
||||
/// <param name="driverInstanceId">The owning driver instance id (for messages).</param>
|
||||
/// <param name="options">The driver's ack-credential options.</param>
|
||||
/// <param name="secrets">The shared secret resolver.</param>
|
||||
/// <param name="logger">Logger; never receives a secret.</param>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
|
||||
public GalaxyAckCredentialResolver(
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
_environment = environment;
|
||||
_legacy = legacy;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_options = options ?? new GalaxyAlarmAckOptions();
|
||||
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
|
||||
@@ -142,56 +150,57 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a password reference. Accepts <c>secret:NAME</c>, <c>${secret:NAME}</c>, <c>env:NAME</c>,
|
||||
/// <c>file:PATH</c> and <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
|
||||
/// <see cref="FormatException"/> for a bare literal (a cleartext password in DriverConfig).
|
||||
/// Resolves the fallback password reference through the shared lmxopcua#815 resolver: <c>secret:galaxy/NAME</c>
|
||||
/// (or <c>secret:otopcua/galaxy/NAME</c>, <c>${secret:…}</c>), <c>env:OTOPCUA_GALAXY_NAME</c> /
|
||||
/// <c>env:GALAXY_MXGW_NAME</c>, or <c>dev:VALUE</c>; returns null when the referenced value is absent; throws
|
||||
/// <see cref="CredentialReferenceException"/> — before any store/environment read — for a bare literal, a
|
||||
/// <c>file:</c> reference, or a reference outside the Galaxy namespace (the password is sent to the
|
||||
/// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret).
|
||||
/// </summary>
|
||||
internal async Task<string?> ResolvePasswordRefAsync(string reference, CancellationToken ct)
|
||||
{
|
||||
if (_legacy is not null
|
||||
&& CredentialReferences.Parse(reference, DriverCredentialPolicy.GalaxyScope, out _) == CredentialReferenceKind.OutOfScope)
|
||||
{
|
||||
_legacy.Report("AlarmAck.FallbackPasswordSecretRef");
|
||||
return await LegacyResolvePasswordRefAsync(reference, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var resolved = await CredentialReferences.ResolveOrNullAsync(
|
||||
reference,
|
||||
DriverCredentialPolicy.GalaxyScope,
|
||||
"AlarmAck.FallbackPasswordSecretRef",
|
||||
DriverCredentialPolicy.GalaxyAckPasswordField.Literals,
|
||||
async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
_environment,
|
||||
ct).ConfigureAwait(false);
|
||||
return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
? resolved[CredentialReferences.DevPrefix.Length..]
|
||||
: resolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
|
||||
/// verbatim. Reached ONLY for an out-of-namespace reference in an already-deployed, running driver.
|
||||
/// </summary>
|
||||
private async Task<string?> LegacyResolvePasswordRefAsync(string reference, CancellationToken ct)
|
||||
{
|
||||
if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
|
||||
{
|
||||
reference = "secret:" + reference["${secret:".Length..^1];
|
||||
}
|
||||
|
||||
if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(reference[4..]);
|
||||
}
|
||||
|
||||
return (_environment ?? Environment.GetEnvironmentVariable)(reference[4..]);
|
||||
if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = reference[5..];
|
||||
return File.Exists(path)
|
||||
? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
|
||||
: null;
|
||||
return File.Exists(path) ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n') : null;
|
||||
}
|
||||
|
||||
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return reference[4..];
|
||||
}
|
||||
|
||||
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
|
||||
if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return reference[4..];
|
||||
throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/dev: reference, not a literal.");
|
||||
}
|
||||
|
||||
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
|
||||
internal static string DescribeRef(string reference)
|
||||
{
|
||||
var r = reference.Trim();
|
||||
if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
|
||||
if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
|
||||
|| r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return r;
|
||||
}
|
||||
|
||||
return "<literal redacted>";
|
||||
}
|
||||
internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
|
||||
}
|
||||
|
||||
+23
-86
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
|
||||
@@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser
|
||||
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in <c>A_LG</c> to a Host
|
||||
/// the author chose, so it must never be able to name another subsystem's secret.
|
||||
/// </summary>
|
||||
public const string SecretNamespace = "heidenhain/";
|
||||
public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0];
|
||||
|
||||
/// <summary>
|
||||
/// The ONLY environment variables a Heidenhain password may name: <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>
|
||||
/// (exact upper-case prefix; <c>NAME</c> is <c>[A-Z0-9_]+</c>) — never <c>ConnectionStrings__*</c>,
|
||||
/// <c>ServerHistorian__ApiKey</c> or any other process variable.
|
||||
/// </summary>
|
||||
public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
|
||||
public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0];
|
||||
|
||||
/// <summary>The password fields a Heidenhain DriverConfig/DeviceConfig may carry.</summary>
|
||||
public static readonly IReadOnlyList<string> PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
|
||||
@@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser
|
||||
/// <returns>True for blank or a namespaced reference.</returns>
|
||||
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
|
||||
{
|
||||
kind = CredentialKind.None;
|
||||
name = "";
|
||||
if (string.IsNullOrEmpty(value)) return true;
|
||||
if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
|
||||
// The shared lmxopcua#815 parser — one rule for every driver that resolves credential references.
|
||||
switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name))
|
||||
{
|
||||
var secret = value[SecretPrefix.Length..];
|
||||
if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
|
||||
var rest = secret[SecretNamespace.Length..];
|
||||
if (rest.Length is 0 or > 200) return false;
|
||||
foreach (var c in rest)
|
||||
if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
|
||||
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
|
||||
kind = CredentialKind.Secret;
|
||||
name = secret;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var variable = value[EnvPrefix.Length..];
|
||||
if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
|
||||
var rest = variable[EnvVariablePrefix.Length..];
|
||||
if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
|
||||
case CredentialReferenceKind.Blank:
|
||||
kind = CredentialKind.None;
|
||||
return true;
|
||||
case CredentialReferenceKind.Secret:
|
||||
kind = CredentialKind.Secret;
|
||||
return true;
|
||||
case CredentialReferenceKind.Environment:
|
||||
kind = CredentialKind.Environment;
|
||||
return true;
|
||||
default:
|
||||
kind = CredentialKind.None;
|
||||
name = "";
|
||||
return false;
|
||||
kind = CredentialKind.Environment;
|
||||
name = variable;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
|
||||
/// namespaced references — at the top level (a device / merged config), under <c>Access</c>, and under every
|
||||
/// <c>Devices[]</c> entry; keys matched case-insensitively, as the parser binds them. The messages name the
|
||||
/// field, never the value. Unreadable JSON is reported, since it cannot be checked.
|
||||
/// namespaced references — at any depth (top level, <c>Access</c>, <c>Devices[]</c>…); keys matched
|
||||
/// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable
|
||||
/// JSON is reported, since it cannot be checked. Delegates to the shared <see cref="DriverCredentialPolicy"/>.
|
||||
/// </summary>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>The problems; empty when every password field is acceptable.</returns>
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(json)) return problems;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
|
||||
return problems;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
|
||||
ScanObject(doc.RootElement, "", problems);
|
||||
foreach (var p in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
|
||||
ScanObject(p.Value, "Access.", problems);
|
||||
if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var d in p.Value.EnumerateArray())
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
public static IReadOnlyList<string> DescribeCredentialProblems(string? json) =>
|
||||
DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json);
|
||||
|
||||
/// <summary>The one wording of the credential rule (never includes the value).</summary>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string CredentialRuleMessage(string field) =>
|
||||
$"{field} must be blank, 'secret:{SecretNamespace}<name>' or 'env:{EnvVariablePrefix}<NAME>' — a literal "
|
||||
+ "password, or a reference outside the Heidenhain namespace, is refused.";
|
||||
|
||||
private static void ScanObject(JsonElement obj, string prefix, List<string> problems)
|
||||
{
|
||||
foreach (var p in obj.EnumerateObject())
|
||||
{
|
||||
if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
var ok = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
|
||||
}
|
||||
}
|
||||
CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused);
|
||||
|
||||
private static void CheckRef(List<string> errors, string field, string? value)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
@@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, Func<stri
|
||||
return await ResolveReferenceAsync(reference, role, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Resolves one reference.</summary>
|
||||
/// <summary>Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read).</summary>
|
||||
public async ValueTask<string> ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
|
||||
{
|
||||
if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
|
||||
throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
|
||||
switch (kind)
|
||||
try
|
||||
{
|
||||
case HeidenhainOptionsParser.CredentialKind.None:
|
||||
return "";
|
||||
case HeidenhainOptionsParser.CredentialKind.Environment:
|
||||
return _environment(name) is { Length: > 0 } value
|
||||
? value
|
||||
: throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
|
||||
default:
|
||||
if (resolver is null)
|
||||
throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
reference,
|
||||
DriverCredentialPolicy.HeidenhainScope,
|
||||
$"{role} password",
|
||||
CredentialLiteralPolicy.Refused,
|
||||
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
_environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (CredentialReferenceException ex)
|
||||
{
|
||||
throw new HeidenhainCredentialException(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-18
@@ -1,5 +1,6 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
@@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </summary>
|
||||
public static class OpcUaUserIdentityFactory
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Build the user-identity token from options whose credential fields are already resolved plaintext
|
||||
@@ -103,25 +103,34 @@ public static class OpcUaUserIdentityFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
|
||||
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
|
||||
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
|
||||
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
|
||||
/// Return a copy of <paramref name="options"/> with a <c>secret:opcuaclient/…</c> or
|
||||
/// <c>env:OTOPCUA_OPCUACLIENT_…</c> <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> resolved (lmxopcua#815 namespace). Literal /
|
||||
/// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed).
|
||||
/// Fail-closed: a reference outside the namespace (or <c>file:</c>) throws before any read, and an absent
|
||||
/// secret, an unset variable, or a <c>secret:</c> ref with no resolver available throws rather than sending
|
||||
/// the reference string as a credential.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <param name="environment">Environment reader (tests); null ⇒ the process environment.</param>
|
||||
/// <param name="legacy">
|
||||
/// ⚠️ Deprecated #815 compatibility: the RUNNING driver's session open passes its tracker, so a pre-#815
|
||||
/// out-of-namespace value keeps the old semantics (a case-insensitive <c>secret:</c> resolves, anything else is
|
||||
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
|
||||
/// </param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
@@ -134,24 +143,48 @@ public static class OpcUaUserIdentityFactory
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment, LegacyCredentialReferenceTracker? legacy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
// A literal (or blank) passes through verbatim, reference-equal — the author already knows it.
|
||||
var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _);
|
||||
if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value;
|
||||
|
||||
if (kind == CredentialReferenceKind.OutOfScope && legacy is not null)
|
||||
{
|
||||
return value;
|
||||
legacy.Report(fieldName);
|
||||
return await LegacyResolveFieldAsync(value!, fieldName, resolver, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
// lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and
|
||||
// env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read.
|
||||
return await CredentialReferences.ResolveAsync(
|
||||
value,
|
||||
DriverCredentialPolicy.OpcUaClientScope,
|
||||
$"OpcUaClientDriverOptions.{fieldName}",
|
||||
CredentialLiteralPolicy.Allowed,
|
||||
resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
|
||||
environment,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ DEPRECATED (lmxopcua#815 warn-only compatibility; delete in the hard-fail release): the pre-#815 resolver,
|
||||
/// verbatim — a case-insensitive <c>secret:</c> resolves (fail-closed), anything else passes through as a
|
||||
/// literal. Reached ONLY for an out-of-namespace value in an already-deployed, running driver.
|
||||
/// </summary>
|
||||
private static async Task<string?> LegacyResolveFieldAsync(
|
||||
string value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||
{
|
||||
if (!value.StartsWith(CredentialReferences.SecretPrefix, StringComparison.OrdinalIgnoreCase)) return value;
|
||||
var name = value[CredentialReferences.SecretPrefix.Length..];
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
|
||||
"available here (fail-closed).");
|
||||
$"OpcUaClientDriverOptions.{fieldName} references secret '{name}', but no secret store is available here (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
$"OpcUaClientDriverOptions.{fieldName} resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// back on its own recovers in place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
|
||||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDriverDiagnostics, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// ⚠️ lmxopcua#815 warn-only compatibility (deprecated): the session open of this RUNNING driver keeps
|
||||
// resolving a pre-#815 out-of-namespace credential with the old semantics, warning rate-limited and surfacing
|
||||
// it on the diagnostics bag. The AdminUI browser never gets a tracker, so it stays hard-fail.
|
||||
private readonly LegacyCredentialReferenceTracker _legacyCredentials;
|
||||
|
||||
/// <inheritdoc />
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics() => _legacyCredentials.Diagnostics;
|
||||
|
||||
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
|
||||
/// to dial. This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
|
||||
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
|
||||
@@ -82,6 +90,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_diag = new OpcUaClientDiagnostics(driverInstanceId);
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
_legacyCredentials = new LegacyCredentialReferenceTracker(
|
||||
DriverCredentialPolicy.OpcUaClientScope, driverInstanceId, message => _logger.LogWarning("{CredentialCompatWarning}", message));
|
||||
_sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
|
||||
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval;
|
||||
}
|
||||
@@ -554,7 +564,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a
|
||||
// single resolve covers both paths.
|
||||
var resolvedOptions = await OpcUaClientSecretResolution
|
||||
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false);
|
||||
.ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken, _legacyCredentials).ConfigureAwait(false);
|
||||
|
||||
var identity = BuildUserIdentity(resolvedOptions);
|
||||
|
||||
|
||||
@@ -31,16 +31,18 @@ internal static class OpcUaClientSecretResolution
|
||||
/// <param name="options">The raw driver options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||
/// <param name="ct">Cancellation token for the async secret resolution.</param>
|
||||
/// <param name="legacy">⚠️ #815 warn-only compatibility tracker of the running driver (deprecated); null ⇒ hard-fail.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct,
|
||||
Core.Abstractions.LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct, legacy: legacy);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user