diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index 85f6168d..66651886 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -25,7 +25,8 @@ out-of-process isolation. | 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.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 | ## Minimum deployment diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs index 42f10018..fa534016 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs @@ -54,10 +54,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession } /// - public Task> 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> 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; - return Task.FromResult>(Array.Empty()); + + 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(); + + 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()), + ]; } /// Issue a single-level Browse (plus continuation-point follow-ups) under the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index 6573e87d..845d2caa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -7,7 +7,8 @@ using Opc.Ua.Client; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; 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; @@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser }; private readonly ILogger _logger; + private readonly ISecretResolver? _secretResolver; /// Creates a new browser. Logger defaults to NullLogger when not supplied. /// Optional logger; defaults to . - public OpcUaClientDriverBrowser(ILogger? logger = null) + /// + /// The host's shared secret resolver (DI-injected where one is registered), used to resolve + /// secret:-prefixed Password / UserCertificatePassword exactly as the runtime driver does. + /// Null ⇒ a secret: ref fails closed (it is never sent as the literal). + /// + public OpcUaClientDriverBrowser(ILogger? logger = null, ISecretResolver? secretResolver = null) { _logger = logger ?? NullLogger.Instance; + _secretResolver = secretResolver; } /// @@ -58,9 +66,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser if (string.IsNullOrWhiteSpace(endpoint)) throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0]."); - if (opts.AuthType == OpcUaAuthType.Certificate) - throw new InvalidOperationException( - "Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username."); + // R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the + // same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast + // with its own message. + var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken).ConfigureAwait(false); if (opts.AutoAcceptCertificates) _logger.LogWarning( @@ -70,7 +79,6 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser endpoint); var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); - var identity = BuildBrowseUserIdentity(opts); var perEndpointBudget = TimeSpan.FromSeconds( Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30)); @@ -175,16 +183,20 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser return config; } - /// Build the OPC UA user identity from the form's auth fields. + /// + /// 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 secret:-prefixed credential (fail-closed). + /// /// Driver options carrying the form's auth fields. - private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch + /// The secret store, or null when none is available. + /// Cancellation token for the secret resolution. + /// The identity the browse session activates with. + internal static async Task BuildBrowseUserIdentityAsync( + OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) { - OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), - OpcUaAuthType.Username => new UserIdentity( - opts.Username ?? string.Empty, - System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)), - _ => new UserIdentity(new AnonymousIdentityToken()), - }; + var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false); + return OpcUaUserIdentityFactory.Build(resolved); + } /// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair. /// Application configuration used by the discovery client. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs new file mode 100644 index 00000000..16be7eed --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs @@ -0,0 +1,115 @@ +using Opc.Ua; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// The ONE place the OpcUaClient user identity is built from — 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 secret: +/// resolution of the two credential fields (fail-closed). +/// +public static class OpcUaUserIdentityFactory +{ + private const string SecretPrefix = "secret:"; + + /// + /// Build the user-identity token from options whose credential fields are already resolved plaintext + /// (see ). + /// + /// Driver options containing authentication configuration. + /// The constructed for session activation. + 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()), + }; + + /// + /// Build a carrying a client user-authentication certificate loaded from + /// . 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). + /// + /// Driver options containing certificate configuration. + /// The certificate-backed . + 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); + } + + /// + /// Return a copy of with any secret:-prefixed + /// / + /// resolved through . Non-secret / null / empty fields pass through unchanged (the + /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a + /// secret: ref with no resolver available, throws rather than sending the literal as a credential. + /// + /// The raw options (credential fields may carry secret: refs). + /// The shared secret resolver, or null when none is available. + /// Cancellation token. + /// An options copy whose credential fields carry resolved plaintext. + public static async Task 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 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)."); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs new file mode 100644 index 00000000..046fa2e1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamAttributeMapping.cs @@ -0,0 +1,65 @@ +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; + +/// +/// 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. +/// +public static class UpstreamAttributeMapping +{ + /// + /// Map an upstream OPC UA built-in DataType NodeId (via DataTypeIds.*) to a + /// . Unknown / custom types fall through to + /// which is the safest passthrough for + /// Variant-wrapped structs + enums + extension objects. + /// + /// The OPC UA data type NodeId to map. + /// The mapped . + 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; + } + + /// + /// Map an OPC UA AccessLevel/UserAccessLevel attribute value (AccessLevels bitmask) to a + /// : CurrentWrite-capable variables surface as + /// ; read-only as . + /// + /// The OPC UA access level bitmask. + /// The mapped security classification. + public static SecurityClassification MapAccessLevel(byte accessLevel) + { + const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02 + return (accessLevel & CurrentWrite) != 0 + ? SecurityClassification.Operate + : SecurityClassification.ViewOnly; + } + + /// + /// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension, + /// >1 N dimensions. Anything >= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable + /// carries a concrete dimensioned rank. + /// + /// The ValueRank attribute. + /// Whether the variable is exposed as an array. + public static bool IsArray(int valueRank) => valueRank >= 0; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj index 3ecc9663..41166a40 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj @@ -11,8 +11,9 @@ + +