From d9463d699891d46ea2c81e448ffc3d719f39a7d1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Mar 2026 19:23:20 -0400 Subject: [PATCH] Remove static Users auth, use shared QualityMapper for historian, simplify LDAP permission checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove ConfigUserAuthenticationProvider and Users property — LDAP is the only auth mechanism - Fix historian quality mapping to use existing QualityMapper (OPC DA quality bytes, not custom mapping) - Add AppRoles constants, unify HasWritePermission/HasAlarmAckPermission into shared HasRole helper - Hoist write permission check out of per-item loop, eliminate redundant _ldapRolesEnabled field - Update docs (Configuration.md, Security.md, OpcUaServer.md, HistoricalDataAccess.md) Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- docs/Configuration.md | 25 ++------ docs/HistoricalDataAccess.md | 17 +++-- docs/OpcUaServer.md | 6 +- docs/security.md | 16 +++-- .../AuthenticationConfiguration.cs | 26 +------- .../Configuration/ConfigurationValidator.cs | 9 --- .../ConfigUserAuthenticationProvider.cs | 25 -------- .../Domain/IUserAuthenticationProvider.cs | 11 +++- .../Domain/LdapAuthenticationProvider.cs | 14 ++--- .../Historian/HistorianDataSource.cs | 17 +---- .../OpcUa/LmxNodeManager.cs | 38 +++++------- .../OpcUa/LmxOpcUaServer.cs | 3 +- .../OpcUa/OpcUaServerHost.cs | 2 +- src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs | 10 +-- src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json | 1 - .../SampleIntegrationTest.cs | 20 ------ .../Authentication/UserAuthenticationTests.cs | 62 ------------------- .../Historian/HistorianQualityMappingTests.cs | 62 ++++++++----------- 19 files changed, 93 insertions(+), 273 deletions(-) delete mode 100644 src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs delete mode 100644 tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/SampleIntegrationTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 2c2c4b6..69df25c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec ## LDAP Authentication -The server supports LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). The `IUserAuthenticationProvider` interface is pluggable — `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference. +The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `ReadWrite` (read/write tags), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference. ## Library Preferences diff --git a/docs/Configuration.md b/docs/Configuration.md index 6b3da9f..ee2d6af 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -114,13 +114,10 @@ Controls user authentication and write authorization for the OPC UA server. Defi |----------|------|---------|-------------| | `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` | | `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` | -| `Users` | `List` | `[]` | List of username/password credentials for `UserName` token authentication | - -Each entry in the `Users` list has two properties: `Username` (string) and `Password` (string). The `Users` list is ignored when `Ldap.Enabled` is `true`. #### LDAP Authentication -When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions. The `Users` list is ignored. +When `Ldap.Enabled` is `true`, credentials are validated against the configured LDAP server and group membership determines OPC UA permissions. | Property | Type | Default | Description | |----------|------|---------|-------------| @@ -148,13 +145,12 @@ When LDAP is enabled, authenticated users receive permissions based on their LDA Users can belong to multiple groups. The `admin` user in the default GLAuth configuration belongs to all three groups. -Example with LDAP authentication: +Example configuration: ```json "Authentication": { "AllowAnonymous": true, "AnonymousCanWrite": false, - "Users": [], "Ldap": { "Enabled": true, "Host": "localhost", @@ -171,19 +167,6 @@ Example with LDAP authentication: } ``` -Example with static user list (no LDAP): - -```json -"Authentication": { - "AllowAnonymous": true, - "AnonymousCanWrite": false, - "Users": [ - { "Username": "operator", "Password": "op123" }, - { "Username": "engineer", "Password": "eng456" } - ] -} -``` - ### Security Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage. @@ -321,7 +304,9 @@ Integration tests use this constructor to inject substitute implementations of ` "Authentication": { "AllowAnonymous": true, "AnonymousCanWrite": true, - "Users": [] + "Ldap": { + "Enabled": false + } }, "Security": { "Profiles": ["None"], diff --git a/docs/HistoricalDataAccess.md b/docs/HistoricalDataAccess.md index 2288141..eb36392 100644 --- a/docs/HistoricalDataAccess.md +++ b/docs/HistoricalDataAccess.md @@ -44,7 +44,7 @@ The `TOP` clause is included only when `maxValues > 0` (the OPC UA client specif - `Value` column (double) takes priority over `vValue` (string). If both are null, the value is null. - `SourceTimestamp` and `ServerTimestamp` are both set to the `DateTime` column. -- `StatusCode` is mapped from the Historian `Quality` byte via `MapQuality`. +- `StatusCode` is mapped from the Historian `Quality` byte via `QualityMapper` (the same OPC DA quality byte mapping used for live MXAccess data). ## Aggregate Reads @@ -65,16 +65,15 @@ Null aggregate values return `BadNoData` status rather than `Good` with a null v ## Quality Mapping -`MapQuality` converts Wonderware Historian quality bytes to OPC UA status codes: +The Historian stores standard OPC DA quality bytes, the same format used by MXAccess at runtime. The quality byte is passed through the shared `QualityMapper` pipeline (`MapFromMxAccessQuality` → `MapToOpcUaStatusCode`), which maps the OPC DA quality families to OPC UA status codes: -| Historian Quality | OPC UA StatusCode | -|---|---| -| 0 | `Good` | -| 1 | `Bad` | -| 2-127 | `Bad` | -| 128+ | `Uncertain` | +| Historian Quality Byte | OPC DA Family | OPC UA StatusCode | +|---|---|---| +| 0-63 | Bad | `Bad` (with sub-code when an exact enum match exists) | +| 64-191 | Uncertain | `Uncertain` (with sub-code when an exact enum match exists) | +| 192+ | Good | `Good` (with sub-code when an exact enum match exists) | -This follows the Wonderware convention where quality 0 indicates a good sample, 1 indicates explicitly bad data, and values at or above 128 represent uncertain quality (e.g., interpolated or suspect values). +See `Domain/QualityMapper.cs` and `Domain/Quality.cs` for the full mapping table and sub-code definitions. ## Aggregate Function Mapping diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 0f95c37..39e4686 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -63,15 +63,15 @@ The `ServiceLevel` is updated whenever MXAccess connection state changes or Gala `UserTokenPolicies` are dynamically configured based on the `Authentication` settings in `appsettings.json`: - An `Anonymous` user token policy is added when `AllowAnonymous` is `true` (the default). -- A `UserName` user token policy is added when the `Users` list contains at least one entry. +- A `UserName` user token policy is added when `Ldap.Enabled` is `true`. Both policies can be active simultaneously, allowing clients to connect with or without credentials. ### Session impersonation -When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If the credentials do not match any entry in the configured `Users` list, the session is rejected. +When a client presents `UserName` credentials, the server validates them through `IUserAuthenticationProvider`. If LDAP authentication is enabled, credentials are validated via LDAP bind and group membership determines the user's application-level roles (`ReadOnly`, `ReadWrite`, `AlarmAck`). If validation fails, the session is rejected. -On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. These roles are used downstream by the write override to enforce `AnonymousCanWrite` restrictions. +On successful validation, the session identity is set to a `RoleBasedIdentity` that carries the user's granted role IDs. Authenticated users receive the `WellKnownRole_AuthenticatedUser` role. Anonymous connections receive the `WellKnownRole_Anonymous` role. When LDAP is enabled, application-level roles from group membership control write and alarm-ack permissions. Without LDAP, `AnonymousCanWrite` controls whether anonymous users can write. ## Certificate handling diff --git a/docs/security.md b/docs/security.md index 2493528..9ba09c0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -147,19 +147,23 @@ Remove `None` from the `Profiles` list to prevent unencrypted connections: } ``` -### 3. Configure named users +### 3. Configure LDAP authentication -Disable anonymous access and define named users in the `Authentication` section. Use `AnonymousCanWrite` to control whether anonymous clients (if still allowed) can write: +Enable LDAP authentication to validate credentials against the GLAuth server. LDAP group membership controls what each user can do (read, write, alarm acknowledgment). See [Configuration Guide](Configuration.md) for the full LDAP property reference. ```json { "Authentication": { "AllowAnonymous": false, "AnonymousCanWrite": false, - "Users": [ - { "Username": "operator", "Password": "secure-password" }, - { "Username": "viewer", "Password": "read-only-password" } - ] + "Ldap": { + "Enabled": true, + "Host": "localhost", + "Port": 3893, + "BaseDN": "dc=lmxopcua,dc=local", + "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local", + "ServiceAccountPassword": "serviceaccount123" + } } } ``` diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs index a6cad7a..6a327f8 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/AuthenticationConfiguration.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { /// @@ -18,32 +16,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration /// public bool AnonymousCanWrite { get; set; } = true; - /// - /// Gets or sets the list of username/password pairs accepted for authenticated access. - /// Ignored when Ldap.Enabled is true. - /// - public List Users { get; set; } = new List(); - /// /// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true, - /// credentials are validated against the LDAP server and the Users list is ignored. + /// credentials are validated against the LDAP server and group membership determines permissions. /// public LdapConfiguration Ldap { get; set; } = new LdapConfiguration(); } - - /// - /// A username/password pair for OPC UA user authentication. - /// - public class UserCredential - { - /// - /// Gets or sets the username. - /// - public string Username { get; set; } = ""; - - /// - /// Gets or sets the password. - /// - public string Password { get; set; } = ""; - } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs index 4a02499..57ddca8 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/ConfigurationValidator.cs @@ -119,15 +119,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration { Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail"); } - - if (config.Authentication.Users.Count > 0) - { - Log.Warning("Authentication.Users list is ignored when Ldap is enabled"); - } - } - else if (config.Authentication.Users.Count > 0) - { - Log.Information("Authentication.Users configured: {Count} user(s)", config.Authentication.Users.Count); } // Redundancy diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs deleted file mode 100644 index d8c86dd..0000000 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/ConfigUserAuthenticationProvider.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using ZB.MOM.WW.LmxOpcUa.Host.Configuration; - -namespace ZB.MOM.WW.LmxOpcUa.Host.Domain -{ - /// - /// Validates credentials against a static list from appsettings.json configuration. - /// - public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider - { - private readonly Dictionary _users; - - public ConfigUserAuthenticationProvider(List users) - { - _users = users.ToDictionary(u => u.Username, u => u.Password, StringComparer.OrdinalIgnoreCase); - } - - public bool ValidateCredentials(string username, string password) - { - return _users.TryGetValue(username, out var expected) && expected == password; - } - } -} diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs index 6fddb53..35c9e9c 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/IUserAuthenticationProvider.cs @@ -22,8 +22,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain { /// /// Returns the set of application-level roles granted to the user. - /// Known roles: "ReadOnly", "ReadWrite", "AlarmAck". /// IReadOnlyList GetUserRoles(string username); } + + /// + /// Well-known application-level role names used for permission enforcement. + /// + public static class AppRoles + { + public const string ReadOnly = "ReadOnly"; + public const string ReadWrite = "ReadWrite"; + public const string AlarmAck = "AlarmAck"; + } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs index 096643b..7b5bd53 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Domain/LdapAuthenticationProvider.cs @@ -23,9 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain _config = config; _groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { config.ReadOnlyGroup, "ReadOnly" }, - { config.ReadWriteGroup, "ReadWrite" }, - { config.AlarmAckGroup, "AlarmAck" } + { config.ReadOnlyGroup, AppRoles.ReadOnly }, + { config.ReadWriteGroup, AppRoles.ReadWrite }, + { config.AlarmAckGroup, AppRoles.AlarmAck } }; } @@ -73,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain if (response.Entries.Count == 0) { Log.Warning("LDAP search returned no entries for {Username}", username); - return new[] { "ReadOnly" }; // safe fallback + return new[] { AppRoles.ReadOnly }; // safe fallback } var entry = response.Entries[0]; @@ -81,7 +81,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain if (memberOf == null || memberOf.Count == 0) { Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username); - return new[] { "ReadOnly" }; + return new[] { AppRoles.ReadOnly }; } var roles = new List(); @@ -99,7 +99,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain if (roles.Count == 0) { Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username); - roles.Add("ReadOnly"); + roles.Add(AppRoles.ReadOnly); } Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles)); @@ -109,7 +109,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain catch (Exception ex) { Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username); - return new[] { "ReadOnly" }; + return new[] { AppRoles.ReadOnly }; } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs index 5143d41..aea8cf4 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/Historian/HistorianDataSource.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Opc.Ua; using Serilog; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Host.Historian { @@ -72,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian Value = new Variant(value), SourceTimestamp = timestamp, ServerTimestamp = timestamp, - StatusCode = MapQuality(quality) + StatusCode = QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality)) }); } @@ -131,20 +132,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian return results; } - /// - /// Maps Wonderware Historian quality codes to OPC UA StatusCodes. - /// - /// The raw Wonderware Historian quality byte stored with a historical sample. - public static StatusCode MapQuality(byte quality) - { - if (quality == 0) - return StatusCodes.Good; - if (quality == 1) - return StatusCodes.Bad; - if (quality >= 128) - return StatusCodes.Uncertain; - return StatusCodes.Bad; - } /// /// Maps an OPC UA aggregate NodeId to the corresponding Historian column name. diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index 0b7a827..9fee836 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -26,7 +26,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private readonly bool _alarmTrackingEnabled; private readonly bool _anonymousCanWrite; private readonly Func?>? _appRoleLookup; - private readonly bool _ldapRolesEnabled; private readonly string _namespaceUri; // NodeId → full_tag_reference for read/write resolution @@ -195,8 +194,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa HistorianDataSource? historianDataSource = null, bool alarmTrackingEnabled = false, bool anonymousCanWrite = true, - Func?>? appRoleLookup = null, - bool ldapRolesEnabled = false) + Func?>? appRoleLookup = null) : base(server, configuration, namespaceUri) { _namespaceUri = namespaceUri; @@ -206,7 +204,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa _alarmTrackingEnabled = alarmTrackingEnabled; _anonymousCanWrite = anonymousCanWrite; _appRoleLookup = appRoleLookup; - _ldapRolesEnabled = ldapRolesEnabled; // Wire up data change delivery _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; @@ -1103,6 +1100,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { base.Write(context, nodesToWrite, errors); + var canWrite = HasWritePermission(context); + for (int i = 0; i < nodesToWrite.Count; i++) { if (nodesToWrite[i].AttributeId != Attributes.Value) @@ -1112,8 +1111,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable) continue; - // Enforce role-based write access - if (!HasWritePermission(context)) + if (!canWrite) { errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); continue; @@ -1165,14 +1163,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private bool HasWritePermission(OperationContext context) { - // When LDAP roles are active, check for ReadWrite role - if (_ldapRolesEnabled && _appRoleLookup != null) - { - var username = context.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null; - var roles = _appRoleLookup(username); - if (roles == null) return false; // unknown user - return roles.Contains("ReadWrite"); - } + if (_appRoleLookup != null) + return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite); // Legacy behavior: reject anonymous writes when AnonymousCanWrite is false if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null && @@ -1186,14 +1178,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa private bool HasAlarmAckPermission(ISystemContext context) { - if (!_ldapRolesEnabled || _appRoleLookup == null) - return true; // no LDAP restrictions without LDAP roles + if (_appRoleLookup == null) + return true; - var opContext = context as SystemContext; - var username = opContext?.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null; - var roles = _appRoleLookup(username); - if (roles == null) return false; - return roles.Contains("AlarmAck"); + var identity = (context as SystemContext)?.UserIdentity; + return HasRole(identity, Domain.AppRoles.AlarmAck); + } + + private bool HasRole(IUserIdentity? identity, string requiredRole) + { + var username = identity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null; + var roles = _appRoleLookup!(username); + return roles != null && roles.Contains(requiredRole); } private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray) diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs index 63c1585..bfbc2af 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs @@ -88,8 +88,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, - LdapRolesEnabled ? (Func?>)GetUserAppRoles : null, - LdapRolesEnabled); + LdapRolesEnabled ? (Func?>)GetUserAppRoles : null); var nodeManagers = new List { _nodeManager }; return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs index 54c7a86..fb7ce83 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs @@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa var policies = new UserTokenPolicyCollection(); if (_authConfig.AllowAnonymous) policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); - if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled) + if (_authConfig.Ldap.Enabled) policies.Add(new UserTokenPolicy(UserTokenType.UserName)); if (policies.Count == 0) diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs index efd355d..666c6c7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUaService.cs @@ -161,21 +161,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host var historianDataSource = _config.Historian.Enabled ? new Historian.HistorianDataSource(_config.Historian) : null; - Domain.IUserAuthenticationProvider? authProvider; + Domain.IUserAuthenticationProvider? authProvider = null; if (_config.Authentication.Ldap.Enabled) { authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap); Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", _config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.BaseDN); } - else if (_config.Authentication.Users.Count > 0) - { - authProvider = new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users); - } - else - { - authProvider = null; - } _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource, _config.Authentication, authProvider, _config.Security, _config.Redundancy); diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json index 1c539d9..d7d8c94 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/appsettings.json @@ -36,7 +36,6 @@ "Authentication": { "AllowAnonymous": true, "AnonymousCanWrite": false, - "Users": [], "Ldap": { "Enabled": false, "Host": "localhost", diff --git a/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/SampleIntegrationTest.cs b/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/SampleIntegrationTest.cs deleted file mode 100644 index 6233ba8..0000000 --- a/tests/ZB.MOM.WW.LmxOpcUa.IntegrationTests/SampleIntegrationTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests -{ - /// - /// Placeholder integration test that keeps the integration test project wired into the solution. - /// - public class SampleIntegrationTest - { - /// - /// Confirms that the integration test assembly is executing. - /// - [Fact] - public void Placeholder_ShouldPass() - { - true.ShouldBeTrue(); - } - } -} diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs index 8b0749e..b45795c 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Authentication/UserAuthenticationTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Shouldly; using Xunit; using ZB.MOM.WW.LmxOpcUa.Host.Configuration; @@ -8,59 +7,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication { public class UserAuthenticationTests { - [Fact] - public void ValidCredentials_ReturnsTrue() - { - var provider = new ConfigUserAuthenticationProvider(new List - { - new UserCredential { Username = "operator", Password = "op123" } - }); - - provider.ValidateCredentials("operator", "op123").ShouldBeTrue(); - } - - [Fact] - public void WrongPassword_ReturnsFalse() - { - var provider = new ConfigUserAuthenticationProvider(new List - { - new UserCredential { Username = "operator", Password = "op123" } - }); - - provider.ValidateCredentials("operator", "wrong").ShouldBeFalse(); - } - - [Fact] - public void UnknownUsername_ReturnsFalse() - { - var provider = new ConfigUserAuthenticationProvider(new List - { - new UserCredential { Username = "operator", Password = "op123" } - }); - - provider.ValidateCredentials("unknown", "op123").ShouldBeFalse(); - } - - [Fact] - public void Username_IsCaseInsensitive() - { - var provider = new ConfigUserAuthenticationProvider(new List - { - new UserCredential { Username = "Operator", Password = "op123" } - }); - - provider.ValidateCredentials("operator", "op123").ShouldBeTrue(); - provider.ValidateCredentials("OPERATOR", "op123").ShouldBeTrue(); - } - - [Fact] - public void EmptyUserList_RejectsAll() - { - var provider = new ConfigUserAuthenticationProvider(new List()); - - provider.ValidateCredentials("anyone", "anything").ShouldBeFalse(); - } - [Fact] public void AuthenticationConfiguration_Defaults() { @@ -68,7 +14,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication config.AllowAnonymous.ShouldBeTrue(); config.AnonymousCanWrite.ShouldBeTrue(); - config.Users.ShouldBeEmpty(); } [Fact] @@ -232,13 +177,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication (provider is IRoleProvider).ShouldBeTrue(); } - [Fact] - public void ConfigUserAuthenticationProvider_DoesNotImplementIRoleProvider() - { - var provider = new ConfigUserAuthenticationProvider(new List()); - (provider is IRoleProvider).ShouldBeFalse(); - } - [Fact] public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse() { diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianQualityMappingTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianQualityMappingTests.cs index a178405..7d8e2a9 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianQualityMappingTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Historian/HistorianQualityMappingTests.cs @@ -1,54 +1,44 @@ using Opc.Ua; using Shouldly; using Xunit; -using ZB.MOM.WW.LmxOpcUa.Host.Historian; +using ZB.MOM.WW.LmxOpcUa.Host.Domain; namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian { public class HistorianQualityMappingTests { - /// - /// Verifies that the Historian good-quality sentinel is surfaced to OPC UA clients as a good status code. - /// - [Fact] - public void Quality0_MapsToGood() - { - HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good); - } + private static StatusCode MapHistorianQuality(byte quality) + => QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality)); - /// - /// Verifies that the Historian bad-quality sentinel is surfaced to OPC UA clients as a bad status code. - /// - [Fact] - public void Quality1_MapsToBad() - { - HistorianDataSource.MapQuality(1).ShouldBe(StatusCodes.Bad); - } - - /// - /// Verifies that Historian uncertainty quality bands are translated into OPC UA uncertain results. - /// - /// A Wonderware Historian quality byte in the uncertain range. [Theory] - [InlineData(128)] - [InlineData(133)] - [InlineData(192)] - public void QualityAbove128_MapsToUncertain(byte quality) + [InlineData(192)] // Quality.Good + [InlineData(216)] // Quality.GoodLocalOverride + public void GoodQualityRange_MapsToGood(byte quality) { - HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Uncertain); + StatusCode.IsGood(MapHistorianQuality(quality)).ShouldBeTrue(); } - /// - /// Verifies that nonzero non-uncertain Historian quality values are treated as bad historical samples. - /// - /// A Wonderware Historian quality byte that should map to an OPC UA bad status. [Theory] - [InlineData(2)] - [InlineData(50)] - [InlineData(127)] - public void OtherBadQualities_MapToBad(byte quality) + [InlineData(64)] // Quality.Uncertain + [InlineData(68)] // Quality.UncertainLastUsable + [InlineData(80)] // Quality.UncertainSensorNotAccurate + [InlineData(88)] // Quality.UncertainSubNormal + [InlineData(128)] // Uncertain range (no exact enum match) + public void UncertainQualityRange_MapsToUncertain(byte quality) { - HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Bad); + StatusCode.IsUncertain(MapHistorianQuality(quality)).ShouldBeTrue(); + } + + [Theory] + [InlineData(0)] // Quality.Bad + [InlineData(1)] // Bad range + [InlineData(4)] // Quality.BadConfigError + [InlineData(8)] // Quality.BadNotConnected + [InlineData(20)] // Quality.BadCommFailure + [InlineData(50)] // Bad range (no exact enum match) + public void BadQualityRange_MapsToBad(byte quality) + { + StatusCode.IsBad(MapHistorianQuality(quality)).ShouldBeTrue(); } } }