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

@@ -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; } = "";
}
}

View File

@@ -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

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>
/// 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";
}
}

View File

@@ -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 };
}
}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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());

View File

@@ -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)

View File

@@ -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);

View File

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