Remove static Users auth, use shared QualityMapper for historian, simplify LDAP permission checks

- 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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 19:23:20 -04:00
parent 74107ea95e
commit d9463d6998
19 changed files with 93 additions and 273 deletions

View File

@@ -87,7 +87,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
## LDAP Authentication ## 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 ## Library Preferences

View File

@@ -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` | | `AllowAnonymous` | `bool` | `true` | Accepts anonymous client connections when `true` |
| `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` | | `AnonymousCanWrite` | `bool` | `true` | Permits anonymous users to write when `true` |
| `Users` | `List<UserCredential>` | `[]` | 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 #### 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 | | 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. 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 ```json
"Authentication": { "Authentication": {
"AllowAnonymous": true, "AllowAnonymous": true,
"AnonymousCanWrite": false, "AnonymousCanWrite": false,
"Users": [],
"Ldap": { "Ldap": {
"Enabled": true, "Enabled": true,
"Host": "localhost", "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 ### Security
Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage. 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": { "Authentication": {
"AllowAnonymous": true, "AllowAnonymous": true,
"AnonymousCanWrite": true, "AnonymousCanWrite": true,
"Users": [] "Ldap": {
"Enabled": false
}
}, },
"Security": { "Security": {
"Profiles": ["None"], "Profiles": ["None"],

View File

@@ -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. - `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. - `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 ## Aggregate Reads
@@ -65,16 +65,15 @@ Null aggregate values return `BadNoData` status rather than `Good` with a null v
## Quality Mapping ## 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 | | Historian Quality Byte | OPC DA Family | OPC UA StatusCode |
|---|---| |---|---|---|
| 0 | `Good` | | 0-63 | Bad | `Bad` (with sub-code when an exact enum match exists) |
| 1 | `Bad` | | 64-191 | Uncertain | `Uncertain` (with sub-code when an exact enum match exists) |
| 2-127 | `Bad` | | 192+ | Good | `Good` (with sub-code when an exact enum match exists) |
| 128+ | `Uncertain` |
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 ## Aggregate Function Mapping

View File

@@ -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`: `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). - 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. Both policies can be active simultaneously, allowing clients to connect with or without credentials.
### Session impersonation ### 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 ## Certificate handling

View File

@@ -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 ```json
{ {
"Authentication": { "Authentication": {
"AllowAnonymous": false, "AllowAnonymous": false,
"AnonymousCanWrite": false, "AnonymousCanWrite": false,
"Users": [ "Ldap": {
{ "Username": "operator", "Password": "secure-password" }, "Enabled": true,
{ "Username": "viewer", "Password": "read-only-password" } "Host": "localhost",
] "Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123"
}
} }
} }
``` ```

View File

@@ -1,5 +1,3 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{ {
/// <summary> /// <summary>
@@ -18,32 +16,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary> /// </summary>
public bool AnonymousCanWrite { get; set; } = true; public bool AnonymousCanWrite { get; set; } = true;
/// <summary>
/// Gets or sets the list of username/password pairs accepted for authenticated access.
/// Ignored when Ldap.Enabled is true.
/// </summary>
public List<UserCredential> Users { get; set; } = new List<UserCredential>();
/// <summary> /// <summary>
/// Gets or sets the LDAP authentication settings. When Ldap.Enabled is true, /// 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.
/// </summary> /// </summary>
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration(); public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
} }
/// <summary>
/// A username/password pair for OPC UA user authentication.
/// </summary>
public class UserCredential
{
/// <summary>
/// Gets or sets the username.
/// </summary>
public string Username { get; set; } = "";
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = "";
}
} }

View File

@@ -119,15 +119,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{ {
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail"); 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 // Redundancy

View File

@@ -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
{
/// <summary>
/// Validates credentials against a static list from appsettings.json configuration.
/// </summary>
public class ConfigUserAuthenticationProvider : IUserAuthenticationProvider
{
private readonly Dictionary<string, string> _users;
public ConfigUserAuthenticationProvider(List<UserCredential> 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;
}
}
}

View File

@@ -22,8 +22,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{ {
/// <summary> /// <summary>
/// Returns the set of application-level roles granted to the user. /// Returns the set of application-level roles granted to the user.
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
/// </summary> /// </summary>
IReadOnlyList<string> GetUserRoles(string username); IReadOnlyList<string> GetUserRoles(string username);
} }
/// <summary>
/// Well-known application-level role names used for permission enforcement.
/// </summary>
public static class AppRoles
{
public const string ReadOnly = "ReadOnly";
public const string ReadWrite = "ReadWrite";
public const string AlarmAck = "AlarmAck";
}
} }

View File

@@ -23,9 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
_config = config; _config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) _groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
{ config.ReadOnlyGroup, "ReadOnly" }, { config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.ReadWriteGroup, "ReadWrite" }, { config.ReadWriteGroup, AppRoles.ReadWrite },
{ config.AlarmAckGroup, "AlarmAck" } { config.AlarmAckGroup, AppRoles.AlarmAck }
}; };
} }
@@ -73,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (response.Entries.Count == 0) if (response.Entries.Count == 0)
{ {
Log.Warning("LDAP search returned no entries for {Username}", username); 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]; var entry = response.Entries[0];
@@ -81,7 +81,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (memberOf == null || memberOf.Count == 0) if (memberOf == null || memberOf.Count == 0)
{ {
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username); Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" }; return new[] { AppRoles.ReadOnly };
} }
var roles = new List<string>(); var roles = new List<string>();
@@ -99,7 +99,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
if (roles.Count == 0) if (roles.Count == 0)
{ {
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username); 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)); 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) catch (Exception ex)
{ {
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username); Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" }; return new[] { AppRoles.ReadOnly };
} }
} }

View File

@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Opc.Ua; using Opc.Ua;
using Serilog; using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Host.Historian namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
{ {
@@ -72,7 +73,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Historian
Value = new Variant(value), Value = new Variant(value),
SourceTimestamp = timestamp, SourceTimestamp = timestamp,
ServerTimestamp = 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; return results;
} }
/// <summary>
/// Maps Wonderware Historian quality codes to OPC UA StatusCodes.
/// </summary>
/// <param name="quality">The raw Wonderware Historian quality byte stored with a historical sample.</param>
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;
}
/// <summary> /// <summary>
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name. /// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.

View File

@@ -26,7 +26,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly bool _alarmTrackingEnabled; private readonly bool _alarmTrackingEnabled;
private readonly bool _anonymousCanWrite; private readonly bool _anonymousCanWrite;
private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup; private readonly Func<string?, IReadOnlyList<string>?>? _appRoleLookup;
private readonly bool _ldapRolesEnabled;
private readonly string _namespaceUri; private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution // NodeId → full_tag_reference for read/write resolution
@@ -195,8 +194,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null, HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false, bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true, bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null, Func<string?, IReadOnlyList<string>?>? appRoleLookup = null)
bool ldapRolesEnabled = false)
: base(server, configuration, namespaceUri) : base(server, configuration, namespaceUri)
{ {
_namespaceUri = namespaceUri; _namespaceUri = namespaceUri;
@@ -206,7 +204,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_alarmTrackingEnabled = alarmTrackingEnabled; _alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite; _anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup; _appRoleLookup = appRoleLookup;
_ldapRolesEnabled = ldapRolesEnabled;
// Wire up data change delivery // Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange; _mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -1103,6 +1100,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{ {
base.Write(context, nodesToWrite, errors); base.Write(context, nodesToWrite, errors);
var canWrite = HasWritePermission(context);
for (int i = 0; i < nodesToWrite.Count; i++) for (int i = 0; i < nodesToWrite.Count; i++)
{ {
if (nodesToWrite[i].AttributeId != Attributes.Value) 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) if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue; continue;
// Enforce role-based write access if (!canWrite)
if (!HasWritePermission(context))
{ {
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied); errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue; continue;
@@ -1165,14 +1163,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private bool HasWritePermission(OperationContext context) private bool HasWritePermission(OperationContext context)
{ {
// When LDAP roles are active, check for ReadWrite role if (_appRoleLookup != null)
if (_ldapRolesEnabled && _appRoleLookup != null) return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite);
{
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");
}
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false // Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null && if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
@@ -1186,14 +1178,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private bool HasAlarmAckPermission(ISystemContext context) private bool HasAlarmAckPermission(ISystemContext context)
{ {
if (!_ldapRolesEnabled || _appRoleLookup == null) if (_appRoleLookup == null)
return true; // no LDAP restrictions without LDAP roles return true;
var opContext = context as SystemContext; var identity = (context as SystemContext)?.UserIdentity;
var username = opContext?.UserIdentity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null; return HasRole(identity, Domain.AppRoles.AlarmAck);
var roles = _appRoleLookup(username); }
if (roles == null) return false;
return roles.Contains("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) private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)

View File

@@ -88,8 +88,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa"; var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics, _nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite, _historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null, LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null);
LdapRolesEnabled);
var nodeManagers = new List<INodeManager> { _nodeManager }; var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray()); return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());

View File

@@ -231,7 +231,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var policies = new UserTokenPolicyCollection(); var policies = new UserTokenPolicyCollection();
if (_authConfig.AllowAnonymous) if (_authConfig.AllowAnonymous)
policies.Add(new UserTokenPolicy(UserTokenType.Anonymous)); policies.Add(new UserTokenPolicy(UserTokenType.Anonymous));
if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled) if (_authConfig.Ldap.Enabled)
policies.Add(new UserTokenPolicy(UserTokenType.UserName)); policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0) if (policies.Count == 0)

View File

@@ -161,21 +161,13 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
var historianDataSource = _config.Historian.Enabled var historianDataSource = _config.Historian.Enabled
? new Historian.HistorianDataSource(_config.Historian) ? new Historian.HistorianDataSource(_config.Historian)
: null; : null;
Domain.IUserAuthenticationProvider? authProvider; Domain.IUserAuthenticationProvider? authProvider = null;
if (_config.Authentication.Ldap.Enabled) if (_config.Authentication.Ldap.Enabled)
{ {
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap); authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})", Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",
_config.Authentication.Ldap.Host, _config.Authentication.Ldap.Port, _config.Authentication.Ldap.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, _serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider, _config.Security, _config.Redundancy); _config.Authentication, authProvider, _config.Security, _config.Redundancy);

View File

@@ -36,7 +36,6 @@
"Authentication": { "Authentication": {
"AllowAnonymous": true, "AllowAnonymous": true,
"AnonymousCanWrite": false, "AnonymousCanWrite": false,
"Users": [],
"Ldap": { "Ldap": {
"Enabled": false, "Enabled": false,
"Host": "localhost", "Host": "localhost",

View File

@@ -1,20 +0,0 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.LmxOpcUa.IntegrationTests
{
/// <summary>
/// Placeholder integration test that keeps the integration test project wired into the solution.
/// </summary>
public class SampleIntegrationTest
{
/// <summary>
/// Confirms that the integration test assembly is executing.
/// </summary>
[Fact]
public void Placeholder_ShouldPass()
{
true.ShouldBeTrue();
}
}
}

View File

@@ -1,4 +1,3 @@
using System.Collections.Generic;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration; using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
@@ -8,59 +7,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
{ {
public class UserAuthenticationTests public class UserAuthenticationTests
{ {
[Fact]
public void ValidCredentials_ReturnsTrue()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "op123").ShouldBeTrue();
}
[Fact]
public void WrongPassword_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("operator", "wrong").ShouldBeFalse();
}
[Fact]
public void UnknownUsername_ReturnsFalse()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
new UserCredential { Username = "operator", Password = "op123" }
});
provider.ValidateCredentials("unknown", "op123").ShouldBeFalse();
}
[Fact]
public void Username_IsCaseInsensitive()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>
{
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<UserCredential>());
provider.ValidateCredentials("anyone", "anything").ShouldBeFalse();
}
[Fact] [Fact]
public void AuthenticationConfiguration_Defaults() public void AuthenticationConfiguration_Defaults()
{ {
@@ -68,7 +14,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
config.AllowAnonymous.ShouldBeTrue(); config.AllowAnonymous.ShouldBeTrue();
config.AnonymousCanWrite.ShouldBeTrue(); config.AnonymousCanWrite.ShouldBeTrue();
config.Users.ShouldBeEmpty();
} }
[Fact] [Fact]
@@ -232,13 +177,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Authentication
(provider is IRoleProvider).ShouldBeTrue(); (provider is IRoleProvider).ShouldBeTrue();
} }
[Fact]
public void ConfigUserAuthenticationProvider_DoesNotImplementIRoleProvider()
{
var provider = new ConfigUserAuthenticationProvider(new List<UserCredential>());
(provider is IRoleProvider).ShouldBeFalse();
}
[Fact] [Fact]
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse() public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
{ {

View File

@@ -1,54 +1,44 @@
using Opc.Ua; using Opc.Ua;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Historian; using ZB.MOM.WW.LmxOpcUa.Host.Domain;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian namespace ZB.MOM.WW.LmxOpcUa.Tests.Historian
{ {
public class HistorianQualityMappingTests public class HistorianQualityMappingTests
{ {
/// <summary> private static StatusCode MapHistorianQuality(byte quality)
/// Verifies that the Historian good-quality sentinel is surfaced to OPC UA clients as a good status code. => QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality));
/// </summary>
[Fact]
public void Quality0_MapsToGood()
{
HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good);
}
/// <summary>
/// Verifies that the Historian bad-quality sentinel is surfaced to OPC UA clients as a bad status code.
/// </summary>
[Fact]
public void Quality1_MapsToBad()
{
HistorianDataSource.MapQuality(1).ShouldBe(StatusCodes.Bad);
}
/// <summary>
/// Verifies that Historian uncertainty quality bands are translated into OPC UA uncertain results.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte in the uncertain range.</param>
[Theory] [Theory]
[InlineData(128)] [InlineData(192)] // Quality.Good
[InlineData(133)] [InlineData(216)] // Quality.GoodLocalOverride
[InlineData(192)] public void GoodQualityRange_MapsToGood(byte quality)
public void QualityAbove128_MapsToUncertain(byte quality)
{ {
HistorianDataSource.MapQuality(quality).ShouldBe(StatusCodes.Uncertain); StatusCode.IsGood(MapHistorianQuality(quality)).ShouldBeTrue();
} }
/// <summary>
/// Verifies that nonzero non-uncertain Historian quality values are treated as bad historical samples.
/// </summary>
/// <param name="quality">A Wonderware Historian quality byte that should map to an OPC UA bad status.</param>
[Theory] [Theory]
[InlineData(2)] [InlineData(64)] // Quality.Uncertain
[InlineData(50)] [InlineData(68)] // Quality.UncertainLastUsable
[InlineData(127)] [InlineData(80)] // Quality.UncertainSensorNotAccurate
public void OtherBadQualities_MapToBad(byte quality) [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();
} }
} }
} }