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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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; } = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
/// <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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user