feat(opcuaclient-browser): real AttributesAsync + certificate-auth browse (R06:O-5)

- AttributesAsync was a stub returning nothing; it now reads NodeClass /
  DisplayName / DataType / ValueRank / UserAccessLevel in one request and maps them
  through UpstreamAttributeMapping, the same mapping discovery registers with (moved
  into the shared .Session leaf; the driver forwards to it). Objects have no value
  attributes.
- Certificate auth was refused "in v1". The browse now builds its identity through
  OpcUaUserIdentityFactory (shared .Session leaf, the driver forwards to it),
  including the secret: resolution of Password / UserCertificatePassword through the
  host's ISecretResolver (optional ctor param, DI-injected). Fail-closed: a secret:
  ref with no store throws instead of being sent as the literal (the browse used to
  send a secret: Password verbatim). The identity is built before any network I/O.

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:33:08 -04:00
parent d98f4a5bf1
commit fcdc44f9f7
11 changed files with 492 additions and 134 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ out-of-process isolation.
| Project | Target | Role | | Project | Target | Role |
|---------|--------|------| |---------|--------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough | | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI | | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI. Its side-panel `AttributesAsync` reads a variable's DataType / ValueRank / UserAccessLevel and maps them through the same `UpstreamAttributeMapping` discovery uses; it authenticates Anonymous, Username **or Certificate** through the driver's own `OpcUaUserIdentityFactory`, resolving `secret:` credentials through the host's secret store (fail-closed) — review 2026-10-21 O-5 (it was a stub, and Certificate was refused) |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/` | net10.0 | The SDK-typed layer the driver AND the browser share: `NamespaceMap`, `UpstreamAttributeMapping`, `OpcUaUserIdentityFactory` |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON | | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
## Minimum deployment ## Minimum deployment
@@ -54,10 +54,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) // R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
// maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
// side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
// Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
throw new ArgumentException(
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
uint[] attributeIds =
[
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
Attributes.UserAccessLevel,
];
var toRead = new ReadValueIdCollection(attributeIds.Length);
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
DataValueCollection values;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var resp = await _session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: toRead,
ct: cancellationToken).ConfigureAwait(false);
values = resp?.Results ?? [];
}
finally
{
_gate.Release();
}
LastUsedUtc = DateTime.UtcNow; LastUsedUtc = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
? values[i] : null;
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
var dataType = At(2)?.Value is NodeId dt
? UpstreamAttributeMapping.MapDataType(dt)
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
var access = At(4)?.Value is byte ab ? ab : (byte)0;
return
[
new AttributeInfo(
Name: name,
DriverDataType: dataType.ToString(),
IsArray: UpstreamAttributeMapping.IsArray(valueRank),
SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
];
} }
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the /// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
using Opc.Ua.Configuration; using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
}; };
private readonly ILogger<OpcUaClientDriverBrowser> _logger; private readonly ILogger<OpcUaClientDriverBrowser> _logger;
private readonly ISecretResolver? _secretResolver;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary> /// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param> /// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null) /// <param name="secretResolver">
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
/// </param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
{ {
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance; _logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
_secretResolver = secretResolver;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -58,9 +66,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(endpoint)) if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0]."); throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
if (opts.AuthType == OpcUaAuthType.Certificate) // R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
throw new InvalidOperationException( // same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username."); // with its own message.
var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken).ConfigureAwait(false);
if (opts.AutoAcceptCertificates) if (opts.AutoAcceptCertificates)
_logger.LogWarning( _logger.LogWarning(
@@ -70,7 +79,6 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
endpoint); endpoint);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var identity = BuildBrowseUserIdentity(opts);
var perEndpointBudget = TimeSpan.FromSeconds( var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30)); Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
@@ -175,16 +183,20 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
return config; return config;
} }
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary> /// <summary>
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
/// </summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param> /// <param name="opts">Driver options carrying the form's auth fields.</param>
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch /// <param name="secretResolver">The secret store, or null when none is available.</param>
/// <param name="ct">Cancellation token for the secret resolution.</param>
/// <returns>The identity the browse session activates with.</returns>
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
{ {
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
OpcUaAuthType.Username => new UserIdentity( return OpcUaUserIdentityFactory.Build(resolved);
opts.Username ?? string.Empty, }
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary> /// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
/// <param name="appConfig">Application configuration used by the discovery client.</param> /// <param name="appConfig">Application configuration used by the discovery client.</param>
@@ -0,0 +1,115 @@
using Opc.Ua;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — shared by
/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
/// browsable with exactly the credentials the deployed driver will present. Includes the <c>secret:</c>
/// resolution of the two credential fields (fail-closed).
/// </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
/// (see <see cref="ResolveSecretRefsAsync"/>).
/// </summary>
/// <param name="options">Driver options containing authentication configuration.</param>
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
public static UserIdentity Build(OpcUaClientDriverOptions options) =>
options.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
OpcUaAuthType.Certificate => FromCertificate(options),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary>
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
/// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
throw new InvalidOperationException(
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
if (!File.Exists(options.UserCertificatePath))
throw new FileNotFoundException(
$"User certificate not found at '{options.UserCertificatePath}'.",
options.UserCertificatePath);
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
// since the legacy X509Certificate2 ctors are marked obsolete on net10.
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
if (!cert.HasPrivateKey)
throw new InvalidOperationException(
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
"the private key is required to sign the OPC UA user-token challenge at session activation.");
return new UserIdentity(cert);
}
/// <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.
/// </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>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
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).");
}
}
@@ -0,0 +1,65 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
/// </summary>
public static class UpstreamAttributeMapping
{
/// <summary>
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
/// Variant-wrapped structs + enums + extension objects.
/// </summary>
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
public static DriverDataType MapDataType(NodeId dataType)
{
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
// confuses range/validation logic keyed off DriverDataType).
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
if (dataType == DataTypeIds.String) return DriverDataType.String;
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
return DriverDataType.DateTime;
return DriverDataType.String;
}
/// <summary>
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> bitmask) to a
/// <see cref="SecurityClassification"/>: CurrentWrite-capable variables surface as
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
/// </summary>
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
/// <returns>The mapped security classification.</returns>
public static SecurityClassification MapAccessLevel(byte accessLevel)
{
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
return (accessLevel & CurrentWrite) != 0
? SecurityClassification.Operate
: SecurityClassification.ViewOnly;
}
/// <summary>
/// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
/// &gt;1 N dimensions. Anything &gt;= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
/// carries a concrete dimensioned rank.
/// </summary>
/// <param name="valueRank">The ValueRank attribute.</param>
/// <returns>Whether the variable is exposed as an array.</returns>
public static bool IsArray(int valueRank) => valueRank >= 0;
}
@@ -11,8 +11,9 @@
</PropertyGroup> </PropertyGroup>
<!-- <!--
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly browser both need that is typed in ISession / NodeId / NamespaceTable / UserIdentity: NamespaceMap,
NamespaceMap. UpstreamAttributeMapping (DataType/AccessLevel/ValueRank -> driver shape) and OpcUaUserIdentityFactory
(credential secret: resolution + the SDK UserIdentity, incl. certificate auth).
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
@@ -35,6 +36,10 @@
--> -->
<ItemGroup> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
credentials the deployed driver will. Abstractions only (one interface + a name type). -->
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions <!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
@@ -967,15 +967,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="options">Driver options containing authentication configuration.</param> /// <param name="options">Driver options containing authentication configuration.</param>
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns> /// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) => internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) =>
options.AuthType switch OpcUaUserIdentityFactory.Build(options);
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
OpcUaAuthType.Certificate => BuildCertificateIdentity(options),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary> /// <summary>
/// Open a session against a single endpoint URL. Bounded by /// Open a session against a single endpoint URL. Bounded by
@@ -1095,30 +1087,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary> /// </summary>
/// <param name="options">Driver options containing certificate configuration.</param> /// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns> /// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options) internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options) =>
{ OpcUaUserIdentityFactory.FromCertificate(options);
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
throw new InvalidOperationException(
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
if (!System.IO.File.Exists(options.UserCertificatePath))
throw new System.IO.FileNotFoundException(
$"User certificate not found at '{options.UserCertificatePath}'.",
options.UserCertificatePath);
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
// since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the
// password through verbatim; PEM files with external keys fall back to
// LoadCertificateFromFile which picks up the adjacent .key if present.
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
if (!cert.HasPrivateKey)
throw new InvalidOperationException(
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
"the private key is required to sign the OPC UA user-token challenge at session activation.");
return new UserIdentity(cert);
}
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary> /// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
/// <param name="policy">The driver security policy to map.</param> /// <param name="policy">The driver security policy to map.</param>
@@ -1804,7 +1774,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated // array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated
// as scalar — array-of-one is exposed as scalar to the local address space until // as scalar — array-of-one is exposed as scalar to the local address space until
// the upstream variable carries a concrete dimensioned rank. // the upstream variable carries a concrete dimensioned rank.
var isArray = valueRank >= 0; var isArray = UpstreamAttributeMapping.IsArray(valueRank);
var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0; var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0;
var securityClass = MapAccessLevelToSecurityClass(access); var securityClass = MapAccessLevelToSecurityClass(access);
var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b; var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b;
@@ -1842,26 +1812,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary> /// </summary>
/// <param name="dataType">The OPC UA data type NodeId to map.</param> /// <param name="dataType">The OPC UA data type NodeId to map.</param>
/// <returns>The mapped <see cref="DriverDataType"/>.</returns> /// <returns>The mapped <see cref="DriverDataType"/>.</returns>
internal static DriverDataType MapUpstreamDataType(NodeId dataType) internal static DriverDataType MapUpstreamDataType(NodeId dataType) =>
{ UpstreamAttributeMapping.MapDataType(dataType);
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
// confuses range/validation logic keyed off DriverDataType).
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
if (dataType == DataTypeIds.String) return DriverDataType.String;
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
return DriverDataType.DateTime;
return DriverDataType.String;
}
/// <summary> /// <summary>
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> /// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c>
@@ -1871,13 +1823,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary> /// </summary>
/// <param name="accessLevel">The OPC UA access level bitmask.</param> /// <param name="accessLevel">The OPC UA access level bitmask.</param>
/// <returns>The mapped security classification.</returns> /// <returns>The mapped security classification.</returns>
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel) internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel) =>
{ UpstreamAttributeMapping.MapAccessLevel(accessLevel);
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
return (accessLevel & CurrentWrite) != 0
? SecurityClassification.Operate
: SecurityClassification.ViewOnly;
}
// ---- ISubscribable ---- // ---- ISubscribable ----
@@ -21,8 +21,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// </remarks> /// </remarks>
internal static class OpcUaClientSecretResolution internal static class OpcUaClientSecretResolution
{ {
private const string SecretPrefix = "secret:";
/// <summary> /// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed /// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> / /// <see cref="OpcUaClientDriverOptions.Password"/> /
@@ -37,54 +35,12 @@ internal static class OpcUaClientSecretResolution
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed). /// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception> /// </exception>
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync( internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct) OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver); ArgumentNullException.ThrowIfNull(resolver);
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct) return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
// Only re-materialize when something actually changed — a `with` on the reference-equal
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
/// <summary>
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
/// </summary>
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
/// <param name="resolver">The shared secret resolver.</param>
/// <param name="ct">Cancellation token for the async resolution.</param>
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
return value;
}
var name = value[SecretPrefix.Length..];
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).");
} }
} }
@@ -0,0 +1,103 @@
using Moq;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
/// <summary>
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> was a stub returning nothing,
/// so the operator saw no DataType / array / access information for an upstream variable. It now reads the
/// variable's attributes and maps them through the same <see cref="UpstreamAttributeMapping"/> the runtime
/// driver's discovery uses.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientBrowseSessionAttributesTests
{
private const string Speed = "ns=0;s=Speed";
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
{
var mock = new Mock<ISession>(MockBehavior.Loose);
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mock.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [.. nodes.Select(n => n.AttributeId switch
{
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
Attributes.DataType => new DataValue(new Variant(dataType)),
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
})],
DiagnosticInfos = [],
});
return mock;
}
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
[Fact]
public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access()
{
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03);
await using var session = NewSession(mock);
var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken);
var a = attrs.ShouldHaveSingleItem();
a.Name.ShouldBe("Speed");
a.DriverDataType.ShouldBe("Float64");
a.IsArray.ShouldBeTrue();
a.SecurityClass.ShouldBe("Operate");
a.IsAlarm.ShouldBeFalse();
}
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
[Fact]
public async Task A_read_only_scalar_reports_view_only()
{
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
await using var session = NewSession(mock);
var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.DriverDataType.ShouldBe("Int32");
a.IsArray.ShouldBeFalse();
a.SecurityClass.ShouldBe("ViewOnly");
}
/// <summary>
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
/// through the same mock and read.
/// </summary>
[Fact]
public async Task An_object_node_has_no_attributes()
{
var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
await using var session = NewSession(mock);
(await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
[Fact]
public async Task An_unresolvable_node_id_throws()
{
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
await using var session = NewSession(mock);
await Should.ThrowAsync<ArgumentException>(() =>
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
/// <summary>
/// Review 2026-10-21 R06:O-5 — the AdminUI browse refused <c>Certificate</c> auth "in v1", so a
/// certificate-authenticated upstream could not be browsed at all. The browse now builds its identity through
/// the same <see cref="OpcUaUserIdentityFactory"/> the runtime driver uses (incl. the <c>secret:</c> resolution
/// of the credential fields, fail-closed), so it presents exactly the credentials the deployed driver will.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientBrowserCertificateAuthTests
{
private sealed class OneSecret(string name, string value) : ISecretResolver
{
public Task<string?> GetAsync(SecretName secretName, CancellationToken ct) =>
Task.FromResult<string?>(secretName.Value == name ? value : null);
}
private static string WritePfx(string password)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("CN=OpcUaClientBrowserCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
var path = Path.Combine(Path.GetTempPath(), $"opcua-browse-cert-{Guid.NewGuid():N}.pfx");
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password));
return path;
}
/// <summary>Certificate auth is no longer refused: a missing cert path fails on the certificate itself.</summary>
[Fact]
public async Task OpenAsync_with_certificate_auth_fails_on_the_certificate_not_a_refusal()
{
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
var ex = await Should.ThrowAsync<InvalidOperationException>(
() => new OpcUaClientDriverBrowser().OpenAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("UserCertificatePath");
ex.Message.ShouldNotContain("does not support");
}
/// <summary>A valid PFX whose password is a <c>secret:</c> ref resolves through the injected store.</summary>
[Fact]
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
{
var path = WritePfx("s3cret-pw");
try
{
var opts = new OpcUaClientDriverOptions
{
AuthType = OpcUaAuthType.Certificate,
UserCertificatePath = path,
UserCertificatePassword = "secret:opcua/browse-cert",
};
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
identity.TokenType.ShouldBe(UserTokenType.Certificate);
}
finally
{
try { File.Delete(path); } catch { /* best-effort */ }
}
}
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
[Fact]
public async Task A_secret_ref_with_no_secret_store_fails_closed()
{
var opts = new OpcUaClientDriverOptions
{
AuthType = OpcUaAuthType.Username,
Username = "op",
Password = "secret:opcua/pw",
};
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("fail-closed");
}
/// <summary>Positive control: a literal password is used as-is (no store needed).</summary>
[Fact]
public async Task A_literal_username_password_needs_no_store()
{
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Username, Username = "op", Password = "pw" };
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken);
identity.TokenType.ShouldBe(UserTokenType.UserName);
}
}
@@ -41,10 +41,9 @@ public sealed class OpcUaClientDriverBrowserTests
ex.Message.ShouldContain("null"); ex.Message.ShouldContain("null");
} }
/// <summary>Certificate auth is not supported by the browser; the failure message /// <summary>Certificate auth with no certificate configured fails before any network I/O with a message
/// must say so explicitly rather than surfacing a downstream COM/SDK error. /// naming the certificate (review 2026-10-21 O-5: Certificate auth is browsable now; it used to be refused),
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the /// rather than surfacing a downstream SDK error. Numeric enum form (<c>OpcUaAuthType.Certificate</c> == 2).</summary>
/// browser's default System.Text.Json options (no string-enum converter).</summary>
[Fact] [Fact]
public async Task OpenAsync_with_certificate_auth_throws_clear_message() public async Task OpenAsync_with_certificate_auth_throws_clear_message()
{ {
@@ -76,7 +75,7 @@ public sealed class OpcUaClientDriverBrowserTests
{ {
// "AuthType":"Certificate" — STRING form, as AdminUI emits. // "AuthType":"Certificate" — STRING form, as AdminUI emits.
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}"""; var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
// The browser should reach its own Certificate-auth guard and throw // The browser should reach its own Certificate-identity check (no UserCertificatePath) and throw
// InvalidOperationException with a message that mentions "Certificate". // InvalidOperationException with a message that mentions "Certificate".
// Without JsonStringEnumConverter the deserialization itself throws JsonException, // Without JsonStringEnumConverter the deserialization itself throws JsonException,
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails. // which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
@@ -118,8 +117,10 @@ public sealed class OpcUaClientDriverBrowserTests
var ct = TestContext.Current.CancellationToken; var ct = TestContext.Current.CancellationToken;
// Arrange: build a minimal OpcUaClientBrowseSession without a live server. // Arrange: build a minimal OpcUaClientBrowseSession without a live server.
// AttributesAsync does not call ISession — MockBehavior.Loose is safe. // A loose mock answers the attribute read with no response, so nothing is known about the node.
var mockSession = new Mock<ISession>(MockBehavior.Loose); var mockSession = new Mock<ISession>(MockBehavior.Loose);
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
var nsMap = NamespaceMap.FromTable(new NamespaceTable()); var nsMap = NamespaceMap.FromTable(new NamespaceTable());
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder); var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
@@ -132,7 +133,7 @@ public sealed class OpcUaClientDriverBrowserTests
var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct); var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
// Assert: LastUsedUtc must have been refreshed, and the result must be empty // Assert: LastUsedUtc must have been refreshed, and the result must be empty
// (OPC UA picker treats variables as leaves, no attribute side-panel). // (a node not known to be a Variable has no value attributes to show).
attrs.ShouldBeEmpty(); attrs.ShouldBeEmpty();
sut.LastUsedUtc.ShouldBeGreaterThan(before, sut.LastUsedUtc.ShouldBeGreaterThan(before,
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract"); "AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");