fix(security): scope operator-authored credential references per driver (#815)
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>
This commit is contained in:
@@ -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,50 @@ 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>
|
||||
/// <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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
var scope = DriverCredentialPolicy.GalaxyScope;
|
||||
if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
|
||||
{
|
||||
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))
|
||||
if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
|
||||
return secretRef[CredentialReferences.DevPrefix.Length..];
|
||||
}
|
||||
var contents = File.ReadAllText(path).Trim();
|
||||
return !string.IsNullOrEmpty(contents)
|
||||
? contents
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
+24
-47
@@ -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,19 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
|
||||
private readonly GalaxyAlarmAckOptions _options;
|
||||
private readonly ISecretResolver _secrets;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<string, string?>? _environment;
|
||||
|
||||
/// <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>
|
||||
public GalaxyAckCredentialResolver(
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
|
||||
string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
|
||||
Func<string, string?>? environment = null)
|
||||
{
|
||||
_environment = environment;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_options = options ?? new GalaxyAlarmAckOptions();
|
||||
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
|
||||
@@ -142,56 +147,28 @@ 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 (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..]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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.");
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-26
@@ -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,29 @@ 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>
|
||||
/// <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)
|
||||
{
|
||||
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)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
@@ -134,24 +138,22 @@ 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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
// 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;
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' 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).");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user