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:
Joseph Doherty
2026-11-11 01:28:58 -05:00
parent 5bbe1e3c7a
commit a224fd6391
23 changed files with 1252 additions and 314 deletions
@@ -0,0 +1,46 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type
/// listed in <see cref="DriverCredentialPolicy"/> must carry its credential fields as blank, an acceptable
/// literal, or a reference inside that driver's namespace (e.g. Galaxy <c>secret:galaxy/…</c> /
/// <c>env:OTOPCUA_GALAXY_…</c>). The same table the AdminUI save path, Test Connect, live browse and every
/// driver's resolve-time check read — so the gate and the runtime cannot disagree.
/// </summary>
/// <remarks>
/// Heidenhain is excluded here only because <see cref="HeidenhainDeployChecks"/> already reports the same rule
/// (as <see cref="HeidenhainDeployChecks.CredentialCode"/>) through the shared parser; reporting it twice would
/// duplicate every error. The message names the field and the allowed forms, never the value.
/// </remarks>
public static class CredentialReferenceDeployChecks
{
/// <summary>A credential field holding a reference outside the driver's namespace (or a refused literal).</summary>
public const string Code = "CredentialReferenceOutOfScope";
/// <summary>Runs the gate.</summary>
/// <param name="draft">The draft.</param>
/// <returns>The errors.</returns>
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
foreach (var driver in draft.DriverInstances)
{
if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId));
foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
{
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId));
}
}
return errors;
}
}
@@ -47,6 +47,7 @@ public static class DraftValidator
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
errors.AddRange(HeidenhainDeployChecks.Errors(draft));
errors.AddRange(CredentialReferenceDeployChecks.Errors(draft));
ValidateNodeAclScopes(draft, errors);
return errors;
}
@@ -0,0 +1,302 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the
/// secret-store prefix a <c>secret:</c> reference must start with, and the environment-variable prefixes an
/// <c>env:</c> reference must start with.
/// </summary>
/// <remarks>
/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the
/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An
/// unscoped reference could name any secret or process variable the node can read —
/// <c>env:ConnectionStrings__ConfigDb</c>, <c>env:ServerHistorian__ApiKey</c>, the mesh PSK — and send it in
/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that
/// were provisioned to be sent to that driver's kind of endpoint.
/// </remarks>
/// <param name="Label">Driver label used in messages (e.g. <c>Galaxy</c>).</param>
/// <param name="SecretNamespaces">
/// The ONLY secret-store prefixes allowed, each ending in <c>/</c> (e.g. <c>galaxy/</c>). The first entry is the
/// documented form.
/// </param>
/// <param name="EnvVariablePrefixes">
/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. <c>OTOPCUA_GALAXY_</c>); the remainder
/// must be <c>[A-Z0-9_]+</c>. The first entry is the documented form.
/// </param>
public sealed record CredentialScope(string Label, IReadOnlyList<string> SecretNamespaces, IReadOnlyList<string> EnvVariablePrefixes);
/// <summary>Whether a credential field may carry a literal (non-reference) value.</summary>
public enum CredentialLiteralPolicy
{
/// <summary>A literal is refused — the field must be blank or a namespaced reference.</summary>
Refused,
/// <summary>A literal is accepted and used verbatim (the author already knows it; nothing is resolved).</summary>
Allowed,
/// <summary>Only the explicit <c>dev:VALUE</c> literal is accepted (Galaxy's opt-in cleartext marker).</summary>
DevPrefixOnly,
}
/// <summary>The kind of a parsed credential field value.</summary>
public enum CredentialReferenceKind
{
/// <summary>Null or empty.</summary>
Blank,
/// <summary>A <c>secret:</c> (or <c>${secret:…}</c>) reference inside the scope's namespace.</summary>
Secret,
/// <summary>An <c>env:</c> reference inside the scope's prefixes.</summary>
Environment,
/// <summary>Not a reference: a literal value (acceptability depends on the field's <see cref="CredentialLiteralPolicy"/>).</summary>
Literal,
/// <summary>
/// A reference (<c>secret:</c>, <c>${secret:…}</c>, <c>env:</c>, <c>file:</c>) that is outside the scope or
/// malformed. Never resolved; always refused.
/// </summary>
OutOfScope,
}
/// <summary>A credential reference that may not (or cannot) be resolved. The message never carries a resolved value.</summary>
/// <param name="message">The message.</param>
public sealed class CredentialReferenceException(string message) : InvalidOperationException(message);
/// <summary>
/// The one parser + resolver of operator-authored credential references (<c>secret:</c> / <c>env:</c>), shared by
/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803).
/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is
/// still never looked up.
/// </summary>
public static class CredentialReferences
{
/// <summary>Prefix of a secret-store reference.</summary>
public const string SecretPrefix = "secret:";
/// <summary>Prefix of an environment-variable reference.</summary>
public const string EnvPrefix = "env:";
/// <summary>Prefix of a file reference — never allowed in operator-authored config (it reads any node file).</summary>
public const string FilePrefix = "file:";
/// <summary>Galaxy's explicit cleartext marker.</summary>
public const string DevPrefix = "dev:";
private const string TokenSecretPrefix = "${secret:";
/// <summary>
/// Parses <paramref name="value"/> against <paramref name="scope"/>. A prefix in another casing
/// (<c>SECRET:x</c>) is <see cref="CredentialReferenceKind.OutOfScope"/> — never a literal sent verbatim and
/// never resolved; the prefix, the namespace and the name are matched exactly.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="name">The full secret name (incl. the namespace) or the environment variable; empty otherwise.</param>
/// <returns>The kind.</returns>
public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name)
{
ArgumentNullException.ThrowIfNull(scope);
name = "";
if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank;
// A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case
// spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured.
if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix)
|| HasPrefixOtherCase(value, EnvPrefix))
return CredentialReferenceKind.OutOfScope;
string? secret = null;
if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal))
{
if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope;
secret = value[TokenSecretPrefix.Length..^1];
}
else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
{
secret = value[SecretPrefix.Length..];
}
if (secret is not null)
{
if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope;
name = secret;
return CredentialReferenceKind.Secret;
}
if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
{
var variable = value[EnvPrefix.Length..];
if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope;
name = variable;
return CredentialReferenceKind.Environment;
}
return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
? CredentialReferenceKind.OutOfScope
: CredentialReferenceKind.Literal;
}
/// <summary>True when <paramref name="value"/> may be stored/deployed in a field with the given literal policy.</summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>Whether it is acceptable.</returns>
public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) =>
Parse(value, scope, out _) switch
{
CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true,
CredentialReferenceKind.Literal => literals switch
{
CredentialLiteralPolicy.Allowed => true,
CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase),
_ => false,
},
_ => false,
};
/// <summary>The one wording of the rule — names the allowed forms, never the value.</summary>
/// <param name="field">The field.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>The message.</returns>
public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals)
{
ArgumentNullException.ThrowIfNull(scope);
var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}<NAME>'"));
var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}<name>'"));
var forms = $"{secrets} or {env}";
return literals switch
{
CredentialLiteralPolicy.Allowed =>
$"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} "
+ "namespace is refused (lmxopcua#815).",
CredentialLiteralPolicy.DevPrefixOnly =>
$"{field} must be blank, {forms} (or 'dev:<value>' on a dev rig) — a literal, a file: reference, or a "
+ $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).",
_ =>
$"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} "
+ "namespace, is refused (lmxopcua#815).",
};
}
/// <summary>
/// Resolves a field value, fail-closed. Blank returns <c>""</c>; an acceptable literal returns verbatim; an
/// out-of-scope reference or a refused literal throws <see cref="CredentialReferenceException"/> BEFORE any
/// store or environment read; an absent secret / unset variable returns <see langword="null"/>.
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup (by full name); null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ <see cref="System.Environment.GetEnvironmentVariable(string)"/>.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value, or null when the referenced value is absent.</returns>
public static async ValueTask<string?> ResolveOrNullAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? environment,
CancellationToken ct)
{
var kind = Parse(value, scope, out var name);
if (!IsAcceptable(value, scope, literals))
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
switch (kind)
{
case CredentialReferenceKind.Blank:
return "";
case CredentialReferenceKind.Literal:
return value;
case CredentialReferenceKind.Environment:
return (environment ?? System.Environment.GetEnvironmentVariable)(name);
case CredentialReferenceKind.Secret:
if (secretLookup is null)
throw new CredentialReferenceException(
$"{field} references secret '{name}' but no secret store is available (fail-closed).");
return await secretLookup(name, ct).ConfigureAwait(false);
default:
throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
}
}
/// <summary>
/// As <see cref="ResolveOrNullAsync"/>, but an absent secret / unset (or empty) variable throws
/// <see cref="CredentialReferenceException"/> naming the secret or variable (never a value).
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup; null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ the process environment.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value (<c>""</c> for blank).</returns>
public static async ValueTask<string> ResolveAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? environment,
CancellationToken ct)
{
var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false);
var kind = Parse(value, scope, out var name);
if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal)
return resolved ?? "";
throw kind == CredentialReferenceKind.Environment
? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).")
: new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed).");
}
/// <summary>A print-safe form of a value: the reference itself, or a redaction for a literal / dev value.</summary>
/// <param name="value">The authored value.</param>
/// <returns>A string safe to log.</returns>
public static string Describe(string? value)
{
if (string.IsNullOrEmpty(value)) return "<blank>";
var v = value.Trim();
if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
? v
: "<literal redacted>";
}
private static bool HasPrefixOtherCase(string value, string prefix) =>
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal);
private static bool IsScopedSecretName(string secret, CredentialScope scope)
{
foreach (var ns in scope.SecretNamespaces)
{
if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue;
var rest = secret[ns.Length..];
if (rest.Length is 0 or > 200) continue;
if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue;
if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue;
return true;
}
return false;
}
private static bool IsScopedVariable(string variable, CredentialScope scope)
{
foreach (var prefix in scope.EnvVariablePrefixes)
{
if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue;
var rest = variable[prefix.Length..];
if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
return true;
}
return false;
}
}
@@ -0,0 +1,148 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>One credential field of a driver config and whether it may carry a literal.</summary>
/// <param name="Name">The JSON property name (matched case-insensitively, as every driver binds).</param>
/// <param name="Literals">The literal policy.</param>
public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals);
/// <summary>The credential scope + fields of one driver type.</summary>
/// <param name="DriverType">The <see cref="DriverTypeNames"/> value.</param>
/// <param name="Scope">The namespace its references are confined to.</param>
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields);
/// <summary>
/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
/// Test Connect, live browse and every driver's resolve-time check read.
/// </summary>
/// <remarks>
/// Every driver that RESOLVES a <c>secret:</c>/<c>env:</c> reference from operator-authored config and sends the
/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only —
/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's
/// <c>Sql__ConnectionStrings__&lt;ref&gt;</c>, already a fixed prefix) are not.
/// </remarks>
public static class DriverCredentialPolicy
{
/// <summary>Heidenhain LSV2 passwords: <c>secret:heidenhain/…</c> / <c>env:OTOPCUA_HEIDENHAIN_…</c> (#803).</summary>
public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]);
/// <summary>
/// Galaxy gateway API key + fallback ack password: <c>secret:galaxy/…</c> (or <c>secret:otopcua/galaxy/…</c>, the
/// form the ack-fallback docs used) / <c>env:OTOPCUA_GALAXY_…</c>, plus the
/// legacy <c>env:GALAXY_MXGW_…</c> prefix the docker-dev rig and the parity-rig docs already use
/// (<c>GALAXY_MXGW_API_KEY</c>) — a variable provisioned to be sent to an mxaccessgw.
/// </summary>
public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]);
/// <summary>OPC UA Client user / certificate passwords: <c>secret:opcuaclient/…</c> / <c>env:OTOPCUA_OPCUACLIENT_…</c>.</summary>
public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
/// <summary>Galaxy <c>Gateway.ApiKeySecretRef</c> (literal accepted for back-compat, with a warning at resolve).</summary>
public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
/// <summary>Galaxy <c>AlarmAck.FallbackPasswordSecretRef</c> (reference or explicit <c>dev:</c> only).</summary>
public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly);
private static readonly IReadOnlyDictionary<string, DriverCredentialRules> Rules =
new Dictionary<string, DriverCredentialRules>(StringComparer.OrdinalIgnoreCase)
{
[DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope,
[
new("InspectPassword", CredentialLiteralPolicy.Refused),
new("DncPassword", CredentialLiteralPolicy.Refused),
new("DataPassword", CredentialLiteralPolicy.Refused),
]),
[DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]),
[DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
[
new("Password", CredentialLiteralPolicy.Allowed),
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
]),
};
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
/// <param name="driverType">The driver type.</param>
/// <param name="rules">The rules.</param>
/// <returns>Whether the type has rules.</returns>
public static bool TryGet(string? driverType, out DriverCredentialRules rules)
{
if (driverType is not null && Rules.TryGetValue(driverType, out var r))
{
rules = r;
return true;
}
rules = null!;
return false;
}
/// <summary>
/// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of
/// <paramref name="driverType"/> whose value is not acceptable — at ANY depth (a field is bound wherever the
/// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value.
/// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty.
/// </summary>
/// <param name="driverType">The owning driver's type.</param>
/// <param name="json">The config JSON.</param>
/// <returns>The problems; empty when acceptable or the type has no credential rules.</returns>
public static IReadOnlyList<string> DescribeProblems(string? driverType, string? json)
{
var problems = new List<string>();
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems;
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
}
catch (JsonException)
{
problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked.");
return problems;
}
using (doc)
{
Scan(doc.RootElement, "", rules, problems, 0);
}
return problems;
}
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth)
{
if (depth > 32) return;
if (element.ValueKind == JsonValueKind.Array)
{
var i = 0;
foreach (var item in element.EnumerateArray())
{
Scan(item, $"{path}[{i}]", rules, problems, depth + 1);
i++;
}
return;
}
if (element.ValueKind != JsonValueKind.Object) return;
foreach (var p in element.EnumerateObject())
{
var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
if (field is null)
{
Scan(p.Value, fieldPath, rules, problems, depth + 1);
continue;
}
var ok = p.Value.ValueKind switch
{
JsonValueKind.Null => true,
JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals),
_ => false,
};
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
}
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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_&lt;NAME&gt;</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);
}
}
}
@@ -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);
}
}
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
/// <inheritdoc />
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
// Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
if (credentialProblems.Count > 0)
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
if (!_browsersByType.TryGetValue(driverType, out var browser))
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
try
@@ -21,8 +21,8 @@
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
placeholder="env:OTOPCUA_GALAXY_API_KEY" />
<div class="form-text">Forms: <code>secret:galaxy/NAME</code>, <code>env:OTOPCUA_GALAXY_NAME</code> (or legacy <code>env:GALAXY_MXGW_NAME</code>), <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and <code>file:</code> are refused (#815).</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
@@ -107,7 +107,7 @@
<label class="form-label">Fallback password secret ref</label>
<InputText @bind-Value="_form.Galaxy.AckFallbackPasswordSecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="secret:otopcua/galaxy/ack-fallback-password" />
<div class="form-text">Forms: <code>secret:NAME</code> (production), <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:VALUE</code>. A bare literal is refused.</div>
<div class="form-text">Forms: <code>secret:galaxy/NAME</code> or <code>secret:otopcua/galaxy/NAME</code> (production), <code>env:OTOPCUA_GALAXY_NAME</code>, <code>dev:VALUE</code>. A bare literal, <code>file:</code>, or a reference outside the Galaxy namespace is refused (#815).</div>
</div>
</div>
</div>
@@ -220,7 +220,7 @@
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
@@ -241,7 +241,7 @@
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
@@ -273,7 +273,7 @@
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
@@ -138,6 +138,7 @@
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>. Other references are refused (#815).</div>
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
@@ -150,6 +151,7 @@
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
<div class="form-text">Literal, <code>secret:opcuaclient/NAME</code> or <code>env:OTOPCUA_OPCUACLIENT_NAME</code>.</div>
</div>
}
</div>
@@ -1,5 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -10,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// block Save while the form is invalid.
/// </summary>
/// <remarks>
/// Today it carries one rule: a Heidenhain password field must be blank or a reference in the Heidenhain
/// namespace (<see cref="HeidenhainOptionsParser.DescribeCredentialProblems"/>) — otherwise a literal password
/// would be persisted in a draft (and replicated) even though the deploy gate would later refuse it.
/// Today it carries one rule, for every driver that resolves credential references
/// (<see cref="DriverCredentialPolicy"/> — Heidenhain, Galaxy, OpcUaClient): a credential field must be blank,
/// an acceptable literal for that field, or a reference inside the driver's namespace (lmxopcua#803/#815) —
/// otherwise an exfiltration-shaped reference (or a Heidenhain literal password) would be persisted in a draft
/// (and replicated) even though the deploy gate and every resolver would later refuse it.
/// </remarks>
public static class DriverConfigSaveGate
{
@@ -22,8 +23,7 @@ public static class DriverConfigSaveGate
/// <returns>The refusal (never containing a credential value), or null.</returns>
public static string? Validate(string? driverType, string? configJson)
{
if (!string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) return null;
var problems = HeidenhainOptionsParser.DescribeCredentialProblems(configJson);
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
return problems.Count == 0 ? null : string.Join(" ", problems);
}
}
@@ -585,6 +585,20 @@ public sealed class AdminOperationsActor : ReceiveActor
return;
}
// lmxopcua#815: refuse a credential reference outside the driver's namespace BEFORE any probe runs — the
// probe would otherwise be the seam that resolves it and sends it to the author-chosen endpoint. Nothing
// is read from the secret store or the environment on this path.
var credentialProblems = DriverCredentialPolicy.DescribeProblems(msg.DriverType, msg.ConfigJson);
if (credentialProblems.Count > 0)
{
replyTo.Tell(new TestDriverConnectResult(
false,
"Test Connect refused: " + string.Join(" ", credentialProblems),
null,
msg.CorrelationId));
return;
}
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
}
@@ -0,0 +1,61 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>lmxopcua#815: the credential-reference namespace deploy gate (Galaxy, OpcUaClient).</summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorCredentialReferenceTests
{
private static IReadOnlyList<ValidationError> Validate(string type, string driverConfig, string deviceConfig = "{}") =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = [new DriverInstance { DriverInstanceId = "drv", ClusterId = "c", Name = "drv", DriverType = type, DriverConfig = driverConfig }],
Devices = [new Device { DeviceId = "dev", DriverInstanceId = "drv", Name = "dev1", DeviceConfig = deviceConfig }],
Tags = [],
}).Where(e => e.Code == CredentialReferenceDeployChecks.Code).ToList();
[Theory]
[InlineData("GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"file:C:\\otopcua\\appsettings.json"}}""")]
[InlineData("GalaxyMxGateway", """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
[InlineData("OpcUaClient", """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData("OpcUaClient", """{"UserCertificatePassword":"env:ConnectionStrings__ConfigDb"}""")]
public void An_out_of_namespace_reference_is_refused_naming_the_allowed_form(string type, string config)
{
var e = Validate(type, config).ShouldHaveSingleItem();
e.Context.ShouldBe("drv");
e.Message.ShouldContain("secret:");
e.Message.ShouldContain("lmxopcua#815");
e.Message.ShouldNotContain("ConnectionStrings__ConfigDb");
}
[Fact]
public void A_reference_in_a_device_config_is_checked_too()
=> Validate("GalaxyMxGateway", "{}", """{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")
.ShouldHaveSingleItem().Context.ShouldBe("dev");
[Theory]
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""")]
[InlineData("GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"dev:k"}}""")]
[InlineData("OpcUaClient", """{"Password":"literal","UserCertificatePassword":"env:OTOPCUA_OPCUACLIENT_CERT_PW"}""")]
[InlineData("Modbus", """{"Password":"env:ConnectionStrings__ConfigDb"}""")]
public void Namespaced_literal_and_unrelated_configs_pass(string type, string config)
=> Validate(type, config).ShouldBeEmpty();
[Fact]
public void Heidenhain_is_reported_once_by_its_own_gate_not_twice()
=> DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
DriverInstances = [new DriverInstance { DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = "Heidenhain", DriverConfig = """{"Access":{"InspectPassword":"env:ConnectionStrings__ConfigDb"}}""" }],
Devices = [], Tags = [],
})
.Count(e => e.Code is CredentialReferenceDeployChecks.Code or HeidenhainDeployChecks.CredentialCode).ShouldBe(1);
}
@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>lmxopcua#815 — the shared credential-reference parser, resolver and per-driver policy table.</summary>
public sealed class CredentialReferencesTests
{
private static readonly CredentialScope Galaxy = DriverCredentialPolicy.GalaxyScope;
[Theory]
[InlineData(null, CredentialReferenceKind.Blank)]
[InlineData("", CredentialReferenceKind.Blank)]
[InlineData("secret:galaxy/inst/apikey", CredentialReferenceKind.Secret)]
[InlineData("${secret:galaxy/inst/apikey}", CredentialReferenceKind.Secret)]
[InlineData("secret:otopcua/galaxy/ack-fallback", CredentialReferenceKind.Secret)]
[InlineData("env:OTOPCUA_GALAXY_API_KEY", CredentialReferenceKind.Environment)]
[InlineData("env:GALAXY_MXGW_API_KEY", CredentialReferenceKind.Environment)]
[InlineData("plain-key", CredentialReferenceKind.Literal)]
[InlineData("dev:k", CredentialReferenceKind.Literal)]
[InlineData("secret:otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:otopcua/apikeys/ab/galaxy-ack-password", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:OT-MESH-PSK-MAIN", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy/", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy/../otopcua/historian/api-key", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxy//x", CredentialReferenceKind.OutOfScope)]
[InlineData("secret:galaxyx/y", CredentialReferenceKind.OutOfScope)]
[InlineData("${secret:galaxy/x", CredentialReferenceKind.OutOfScope)]
[InlineData("SECRET:galaxy/x", CredentialReferenceKind.OutOfScope)]
[InlineData("Env:OTOPCUA_GALAXY_X", CredentialReferenceKind.OutOfScope)]
[InlineData("env:ConnectionStrings__ConfigDb", CredentialReferenceKind.OutOfScope)]
[InlineData("env:ServerHistorian__ApiKey", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_GALAXY_", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_GALAXY_lower", CredentialReferenceKind.OutOfScope)]
[InlineData("env:OTOPCUA_HEIDENHAIN_X", CredentialReferenceKind.OutOfScope)]
[InlineData("file:appsettings.json", CredentialReferenceKind.OutOfScope)]
[InlineData("FILE:x", CredentialReferenceKind.OutOfScope)]
public void Parse_classifies_against_the_galaxy_scope(string? value, CredentialReferenceKind expected) =>
CredentialReferences.Parse(value, Galaxy, out _).ShouldBe(expected);
[Fact]
public void Scopes_do_not_overlap_across_drivers()
{
CredentialReferences.Parse("secret:heidenhain/x", Galaxy, out _).ShouldBe(CredentialReferenceKind.OutOfScope);
CredentialReferences.Parse("secret:galaxy/x", DriverCredentialPolicy.OpcUaClientScope, out _)
.ShouldBe(CredentialReferenceKind.OutOfScope);
CredentialReferences.Parse("env:OTOPCUA_GALAXY_X", DriverCredentialPolicy.HeidenhainScope, out _)
.ShouldBe(CredentialReferenceKind.OutOfScope);
}
[Theory]
[InlineData(CredentialLiteralPolicy.Allowed, "plain", true)]
[InlineData(CredentialLiteralPolicy.Refused, "plain", false)]
[InlineData(CredentialLiteralPolicy.Refused, "dev:x", false)]
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "dev:x", true)]
[InlineData(CredentialLiteralPolicy.DevPrefixOnly, "plain", false)]
[InlineData(CredentialLiteralPolicy.Allowed, "env:ConnectionStrings__ConfigDb", false)]
public void IsAcceptable_applies_the_literal_policy(CredentialLiteralPolicy policy, string value, bool expected) =>
CredentialReferences.IsAcceptable(value, Galaxy, policy).ShouldBe(expected);
/// <summary>The fail-closed core: an out-of-scope reference throws before either source is consulted.</summary>
[Theory]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("secret:otopcua/historian/api-key")]
[InlineData("file:appsettings.json")]
public async Task Resolve_never_reads_for_an_out_of_scope_reference(string value)
{
var reads = 0;
var ex = await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
value, Galaxy, "F", CredentialLiteralPolicy.Allowed,
(_, _) => { reads++; return ValueTask.FromResult<string?>("LEAKED"); },
_ => { reads++; return "LEAKED"; },
CancellationToken.None));
reads.ShouldBe(0);
ex.Message.ShouldContain("'secret:galaxy/<name>'");
ex.Message.ShouldContain("'env:OTOPCUA_GALAXY_<NAME>'");
ex.Message.ShouldNotContain("LEAKED");
}
[Fact]
public async Task Resolve_reads_scoped_references_and_fails_closed_when_absent()
{
(await CredentialReferences.ResolveAsync("secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
(n, _) => ValueTask.FromResult<string?>(n == "galaxy/a" ? "v" : null), _ => null, CancellationToken.None))
.ShouldBe("v");
(await CredentialReferences.ResolveAsync("env:OTOPCUA_GALAXY_A", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, n => n == "OTOPCUA_GALAXY_A" ? "e" : null, CancellationToken.None)).ShouldBe("e");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"secret:galaxy/missing", Galaxy, "F", CredentialLiteralPolicy.Refused,
(_, _) => ValueTask.FromResult<string?>(null), null, CancellationToken.None))).Message.ShouldContain("absent");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"env:OTOPCUA_GALAXY_UNSET", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, _ => null, CancellationToken.None))).Message.ShouldContain("not set");
(await Should.ThrowAsync<CredentialReferenceException>(async () => await CredentialReferences.ResolveAsync(
"secret:galaxy/a", Galaxy, "F", CredentialLiteralPolicy.Refused,
null, null, CancellationToken.None))).Message.ShouldContain("no secret store");
}
[Fact]
public void DescribeProblems_scans_every_depth_case_insensitively_and_never_echoes_the_value()
{
const string json = """
{
"gateway": { "endpoint": "http://evil:5001", "apiKeySecretRef": "env:ConnectionStrings__ConfigDb" },
"Devices": [ { "AlarmAck": { "FallbackPasswordSecretRef": "hunter2" } } ]
}
""";
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, json);
problems.Count.ShouldBe(2);
problems.ShouldContain(p => p.StartsWith("gateway.apiKeySecretRef", StringComparison.Ordinal));
problems.ShouldContain(p => p.StartsWith("Devices[0].AlarmAck.FallbackPasswordSecretRef", StringComparison.Ordinal));
problems.ShouldAllBe(p => !p.Contains("hunter2") && !p.Contains("ConnectionStrings__ConfigDb"));
}
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"},"AlarmAck":{"FallbackPasswordSecretRef":"dev:x"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"literal-key"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"plain","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"env:OTOPCUA_HEIDENHAIN_INSPECT"}}""")]
[InlineData(DriverTypeNames.Modbus, """{"Password":"env:ConnectionStrings__ConfigDb"}""")]
[InlineData(DriverTypeNames.Galaxy, "")]
public void DescribeProblems_accepts_namespaced_and_unrelated_configs(string driverType, string json) =>
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldBeEmpty();
[Theory]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"secret:galaxy/main/apikey"}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:C:\\ProgramData\\x.txt"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":42}}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"InspectPassword":"plain"}""")]
[InlineData(DriverTypeNames.Galaxy, "{not json")]
public void DescribeProblems_refuses_out_of_namespace_configs(string driverType, string json) =>
DriverCredentialPolicy.DescribeProblems(driverType, json).ShouldNotBeEmpty();
}
@@ -1,14 +1,15 @@
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 — pins the five resolution forms supported by
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// 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
@@ -38,7 +39,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact]
public async Task Env_prefix_resolves_to_environment_variable()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
const string name = "OTOPCUA_GALAXY_TEST_API_KEY";
Environment.SetEnvironmentVariable(name, "key-from-env");
try
{
@@ -55,41 +56,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact]
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
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("unset");
}
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact]
public async Task File_prefix_resolves_to_trimmed_file_contents()
{
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n");
try
{
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver(), ct: TestContext.Current.CancellationToken))
.ShouldBe("key-from-file");
}
finally
{
File.Delete(path);
}
}
/// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact]
public async Task File_prefix_missing_path_throws()
{
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist");
ex.Message.ShouldContain("not set");
}
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
@@ -161,7 +134,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact]
public async Task Env_prefix_does_not_emit_literal_warning()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
const string name = "OTOPCUA_GALAXY_TEST_API_KEY_NOWARN";
Environment.SetEnvironmentVariable(name, "v");
try
{
@@ -211,21 +184,77 @@ public sealed class GalaxyDriverApiKeyResolverTests
: null);
}
/// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact]
public async Task File_prefix_empty_file_throws()
// ===== 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 path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " \n ");
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<InvalidOperationException>(() =>
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
ex.Message.ShouldContain("empty");
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");
}
}
}
@@ -300,6 +300,48 @@ public sealed class GalaxyAckCredentialTests
.Password.ShouldBe(FallbackPassword);
}
/// <summary>
/// lmxopcua#815: the fallback password is sent to the operator-authored gateway endpoint, so a reference
/// outside the Galaxy namespace is refused — the store/environment value is never read, never sent.
/// </summary>
[Theory]
[InlineData("secret:otopcua/apikeys/0a1b/galaxy-ack-password")]
[InlineData("${secret:otopcua/historian/api-key}")]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("file:appsettings.json")]
public async Task Resolver_refuses_a_fallback_reference_outside_the_galaxy_namespace(string reference)
{
const string Stolen = "stolen-value";
var secrets = new DictSecretResolver
{
["otopcua/apikeys/0a1b/galaxy-ack-password"] = Stolen,
["otopcua/historian/api-key"] = Stolen,
};
var envReads = 0;
var logger = new CapturingLogger();
var resolver = new GalaxyAckCredentialResolver(
"drv", new GalaxyAlarmAckOptions("ack-svc", null, reference), secrets, logger,
_ => { envReads++; return Stolen; });
var result = await resolver.ResolveAsync(null, CancellationToken.None);
result.Credential.ShouldBeNull();
result.RefusalReason.ShouldNotBeNull().ShouldNotContain(Stolen);
logger.AllText.ShouldNotContain(Stolen);
envReads.ShouldBe(0);
}
[Fact]
public async Task Resolver_resolves_a_namespaced_environment_fallback()
{
var resolver = new GalaxyAckCredentialResolver(
"drv", new GalaxyAlarmAckOptions("ack-svc", null, "env:OTOPCUA_GALAXY_ACK_PASSWORD"), new DictSecretResolver(),
new CapturingLogger(), n => n == "OTOPCUA_GALAXY_ACK_PASSWORD" ? FallbackPassword : null);
(await resolver.ResolveAsync(null, CancellationToken.None)).Credential.ShouldNotBeNull()
.Password.ShouldBe(FallbackPassword);
}
[Fact]
public async Task Resolver_secret_store_fault_is_a_refusal_not_a_throw()
{
@@ -56,11 +56,11 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
{
AuthType = OpcUaAuthType.Certificate,
UserCertificatePath = path,
UserCertificatePassword = "secret:opcua/browse-cert",
UserCertificatePassword = "secret:opcuaclient/browse-cert",
};
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
identity.TokenType.ShouldBe(UserTokenType.Certificate);
}
@@ -78,7 +78,7 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
{
AuthType = OpcUaAuthType.Username,
Username = "op",
Password = "secret:opcua/pw",
Password = "secret:opcuaclient/pw",
};
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -16,9 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// </summary>
public sealed class OpcUaClientSecretResolutionTests
{
private const string KnownPwName = "opcua/inst/password";
private const string KnownPwName = "opcuaclient/inst/password";
private const string KnownPwValue = "resolved-password-plaintext";
private const string KnownCertPwName = "opcua/inst/cert-pw";
private const string KnownCertPwName = "opcuaclient/inst/cert-pw";
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
@@ -70,14 +71,68 @@ public sealed class OpcUaClientSecretResolutionTests
[Fact]
public async Task Unknown_secret_ref_is_fail_closed()
{
var options = new OpcUaClientDriverOptions { Password = "secret:opcua/inst/absent" };
var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" };
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
options, FakeResolver(), CancellationToken.None));
ex.Message.ShouldContain("Password");
ex.Message.ShouldContain("opcua/inst/absent");
ex.Message.ShouldContain("opcuaclient/inst/absent");
}
/// <summary>
/// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the
/// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent.
/// </summary>
[Theory]
[InlineData("secret:otopcua/historian/api-key")]
[InlineData("secret:galaxy/inst1/apikey")]
[InlineData("secret:OT-MESH-PSK-MAIN")]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("env:ServerHistorian__ApiKey")]
[InlineData("Secret:opcuaclient/inst/password")]
[InlineData("file:appsettings.json")]
public async Task Out_of_namespace_reference_is_refused_without_reading(string reference)
{
var store = new CountingResolver();
var envReads = 0;
foreach (var options in new[]
{
new OpcUaClientDriverOptions { Password = reference },
new OpcUaClientDriverOptions { UserCertificatePassword = reference },
})
{
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; }));
ex.Message.ShouldContain("secret:opcuaclient/<name>");
ex.Message.ShouldNotContain("LEAKED");
}
store.Calls.ShouldBe(0);
envReads.ShouldBe(0);
}
/// <summary>A namespaced environment reference resolves.</summary>
[Fact]
public async Task Namespaced_environment_reference_resolves()
{
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(),
CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null);
resolved.Password.ShouldBe("pw");
}
private sealed class CountingResolver : ISecretResolver
{
public int Calls { get; private set; }
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Calls++;
return Task.FromResult<string?>("LEAKED");
}
}
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>
/// lmxopcua#815: the AdminUI live browse (Galaxy / OpcUaClient bespoke browsers, and the Discover-backed universal
/// browser) resolves the config's credential references and sends them to the author-chosen endpoint — an
/// out-of-namespace reference is refused before any browser is opened.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BrowserSessionCredentialScopeTests
{
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"}}""")]
public async Task Out_of_namespace_reference_never_opens_a_browser(string driverType, string config)
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var bespoke2 = new RecordingBrowser(DriverTypeNames.OpcUaClient);
var universal = new RecordingUniversal();
var svc = new BrowserSessionService(
[bespoke, bespoke2], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, universal, null!, null!);
var result = await svc.OpenAsync(driverType, config, TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#815");
bespoke.Opens.ShouldBe(0);
bespoke2.Opens.ShouldBe(0);
universal.Opens.ShouldBe(0);
}
/// <summary>Positive control: a namespaced reference reaches the browser.</summary>
[Fact]
public async Task A_namespaced_reference_reaches_the_browser()
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""",
TestContext.Current.CancellationToken);
bespoke.Opens.ShouldBe(1);
}
private sealed class RecordingBrowser(string driverType) : IDriverBrowser
{
public int Opens { get; private set; }
public string DriverType => driverType;
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
Opens++;
throw new InvalidOperationException("opened");
}
}
private sealed class RecordingUniversal : IUniversalDriverBrowser
{
public int Opens { get; private set; }
public bool CanBrowse(string driverType, string configJson) => true;
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
Opens++;
throw new InvalidOperationException("opened");
}
}
}
@@ -0,0 +1,77 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// lmxopcua#815 at the SAVE path (RawTreeService): a Galaxy / OpcUaClient credential reference outside the
/// driver's namespace never reaches the config DB, through driver and device create + update.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialReferenceSavePathTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:appsettings.json"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
public async Task CreateDriver_refuses_an_out_of_namespace_reference_and_persists_nothing(string type, string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "d815", type, config, Ct);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("lmxopcua#815");
using var db = RawTreeTestDb.CreateNamed(name);
db.DriverInstances.ShouldNotContain(d => d.Name == "d815");
}
[Fact]
public async Task Update_paths_refuse_and_keep_the_stored_config()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gx", DriverTypeNames.Galaxy,
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/gx/apikey"}}""", Ct);
created.Ok.ShouldBeTrue(created.Error);
byte[] rv;
string deviceId;
byte[] deviceRv;
using (var db = RawTreeTestDb.CreateNamed(name))
{
rv = db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).RowVersion;
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
deviceId = device.DeviceId;
deviceRv = device.RowVersion;
}
(await svc.UpdateDriverAsync(created.CreatedId!, "gx",
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", null, rv, Ct)).Ok.ShouldBeFalse();
(await svc.UpdateDeviceAsync(deviceId, "dev",
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", true, deviceRv, Ct)).Ok.ShouldBeFalse();
(await svc.CreateDeviceAsync(created.CreatedId!, "dev2",
"""{"Gateway":{"ApiKeySecretRef":"secret:otopcua/historian/api-key"}}""", Ct)).Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(name);
check.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).DriverConfig.ShouldContain("secret:galaxy/gx/apikey");
check.Devices.Where(d => d.DriverInstanceId == created.CreatedId)
.ShouldAllBe(d => !d.DeviceConfig.Contains("ConnectionStrings") && !d.DeviceConfig.Contains("historian"));
}
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"literal-password","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
public async Task Namespaced_references_save(string type, string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ok815", type, config, Ct);
result.Ok.ShouldBeTrue(result.Error);
}
}
@@ -0,0 +1,66 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any
/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value
/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read".
/// </summary>
public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase
{
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")]
public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config)
{
var probe = new RecordingProbe(driverType);
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid()));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
reply.Message.ShouldContain("lmxopcua#815");
probe.Calls.ShouldBe(0);
}
/// <summary>Positive control: a namespaced reference does reach the probe (the refusal is not blanket).</summary>
[Fact]
public void A_namespaced_reference_reaches_the_probe()
{
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid()));
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
probe.Calls.ShouldBe(1);
}
private sealed class RecordingProbe(string driverType) : IDriverProbe
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public string DriverType => driverType;
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
}
}
}