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:
@@ -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
|
||||
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
@@ -18,32 +16,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
|
||||
/// </summary>
|
||||
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>
|
||||
/// 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>
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the set of application-level roles granted to the user.
|
||||
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
|
||||
_config = config;
|
||||
_groupToRole = new Dictionary<string, string>(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<string>();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Maps an OPC UA aggregate NodeId to the corresponding Historian column name.
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
|
||||
private readonly bool _alarmTrackingEnabled;
|
||||
private readonly bool _anonymousCanWrite;
|
||||
private readonly Func<string?, IReadOnlyList<string>?>? _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<string?, IReadOnlyList<string>?>? appRoleLookup = null,
|
||||
bool ldapRolesEnabled = false)
|
||||
Func<string?, IReadOnlyList<string>?>? 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)
|
||||
|
||||
@@ -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<string?, IReadOnlyList<string>?>)GetUserAppRoles : null,
|
||||
LdapRolesEnabled);
|
||||
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null);
|
||||
|
||||
var nodeManagers = new List<INodeManager> { _nodeManager };
|
||||
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"Authentication": {
|
||||
"AllowAnonymous": true,
|
||||
"AnonymousCanWrite": false,
|
||||
"Users": [],
|
||||
"Ldap": {
|
||||
"Enabled": false,
|
||||
"Host": "localhost",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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]
|
||||
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<UserCredential>());
|
||||
(provider is IRoleProvider).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapAuthenticationProvider_ConnectionFailure_ReturnsFalse()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that the Historian good-quality sentinel is surfaced to OPC UA clients as a good status code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Quality0_MapsToGood()
|
||||
{
|
||||
HistorianDataSource.MapQuality(0).ShouldBe(StatusCodes.Good);
|
||||
}
|
||||
private static StatusCode MapHistorianQuality(byte quality)
|
||||
=> QualityMapper.MapToOpcUaStatusCode(QualityMapper.MapFromMxAccessQuality(quality));
|
||||
|
||||
/// <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]
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <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]
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user