Add LDAP authentication with role-based OPC UA permissions

Replace static user list with GLAuth LDAP authentication. Group
membership (ReadOnly, ReadWrite, AlarmAck) maps to granular OPC UA
permissions for write and alarm-ack operations. Anonymous can still
browse and read but not write.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-28 18:57:30 -04:00
parent 9d3599fbb6
commit 74107ea95e
16 changed files with 726 additions and 17 deletions

View File

@@ -20,8 +20,15 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// <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.
/// </summary>
public LdapConfiguration Ldap { get; set; } = new LdapConfiguration();
}
/// <summary>

View File

@@ -104,6 +104,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
}
// Authentication
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
if (config.Authentication.Ldap.Enabled)
{
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port, config.Authentication.Ldap.BaseDN);
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, ReadWrite={ReadWrite}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.ReadWriteGroup, config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
{
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
if (config.OpcUa.ApplicationUri != null)
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);

View File

@@ -0,0 +1,67 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// LDAP authentication and group-to-role mapping settings.
/// </summary>
public class LdapConfiguration
{
/// <summary>
/// Gets or sets whether LDAP authentication is enabled.
/// When true, user credentials are validated against the configured LDAP server
/// and group membership determines OPC UA permissions.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the LDAP server hostname or IP address.
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the LDAP server port.
/// </summary>
public int Port { get; set; } = 3893;
/// <summary>
/// Gets or sets the base DN for LDAP operations.
/// </summary>
public string BaseDN { get; set; } = "dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the bind DN template. Use {username} as a placeholder.
/// </summary>
public string BindDnTemplate { get; set; } = "cn={username},dc=lmxopcua,dc=local";
/// <summary>
/// Gets or sets the service account DN used for LDAP searches (group lookups).
/// </summary>
public string ServiceAccountDn { get; set; } = "";
/// <summary>
/// Gets or sets the service account password.
/// </summary>
public string ServiceAccountPassword { get; set; } = "";
/// <summary>
/// Gets or sets the LDAP connection timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the LDAP group name that grants read-only access.
/// </summary>
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants read-write access.
/// </summary>
public string ReadWriteGroup { get; set; } = "ReadWrite";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.
/// </summary>
public string AlarmAckGroup { get; set; } = "AlarmAck";
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
@@ -10,4 +12,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
/// </summary>
bool ValidateCredentials(string username, string password);
}
/// <summary>
/// Extended interface for providers that can resolve application-level roles for authenticated users.
/// When the auth provider implements this interface, OnImpersonateUser uses the returned roles
/// to control write and alarm-ack permissions.
/// </summary>
public interface IRoleProvider
{
/// <summary>
/// Returns the set of application-level roles granted to the user.
/// Known roles: "ReadOnly", "ReadWrite", "AlarmAck".
/// </summary>
IReadOnlyList<string> GetUserRoles(string username);
}
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.DirectoryServices.Protocols;
using System.Linq;
using System.Net;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Validates credentials via LDAP bind and resolves group membership to application roles.
/// </summary>
public class LdapAuthenticationProvider : IUserAuthenticationProvider, IRoleProvider
{
private static readonly ILogger Log = Serilog.Log.ForContext<LdapAuthenticationProvider>();
private readonly LdapConfiguration _config;
private readonly Dictionary<string, string> _groupToRole;
public LdapAuthenticationProvider(LdapConfiguration config)
{
_config = config;
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, "ReadOnly" },
{ config.ReadWriteGroup, "ReadWrite" },
{ config.AlarmAckGroup, "AlarmAck" }
};
}
public bool ValidateCredentials(string username, string password)
{
try
{
var bindDn = _config.BindDnTemplate.Replace("{username}", username);
using (var connection = CreateConnection())
{
connection.Bind(new NetworkCredential(bindDn, password));
}
Log.Debug("LDAP bind succeeded for {Username}", username);
return true;
}
catch (LdapException ex)
{
Log.Debug("LDAP bind failed for {Username}: {Error}", username, ex.Message);
return false;
}
catch (Exception ex)
{
Log.Warning(ex, "LDAP error during credential validation for {Username}", username);
return false;
}
}
public IReadOnlyList<string> GetUserRoles(string username)
{
try
{
using (var connection = CreateConnection())
{
// Bind with service account to search
connection.Bind(new NetworkCredential(_config.ServiceAccountDn, _config.ServiceAccountPassword));
var request = new SearchRequest(
_config.BaseDN,
$"(cn={EscapeLdapFilter(username)})",
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)connection.SendRequest(request);
if (response.Entries.Count == 0)
{
Log.Warning("LDAP search returned no entries for {Username}", username);
return new[] { "ReadOnly" }; // safe fallback
}
var entry = response.Entries[0];
var memberOf = entry.Attributes["memberOf"];
if (memberOf == null || memberOf.Count == 0)
{
Log.Debug("No memberOf attributes for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
}
var roles = new List<string>();
for (int i = 0; i < memberOf.Count; i++)
{
var dn = memberOf[i]?.ToString() ?? "";
// Extract the OU/CN from the memberOf DN (e.g., "ou=ReadWrite,ou=groups,dc=...")
var groupName = ExtractGroupName(dn);
if (groupName != null && _groupToRole.TryGetValue(groupName, out var role))
{
roles.Add(role);
}
}
if (roles.Count == 0)
{
Log.Debug("No matching role groups for {Username}, defaulting to ReadOnly", username);
roles.Add("ReadOnly");
}
Log.Debug("LDAP roles for {Username}: [{Roles}]", username, string.Join(", ", roles));
return roles;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to resolve LDAP roles for {Username}, defaulting to ReadOnly", username);
return new[] { "ReadOnly" };
}
}
private LdapConnection CreateConnection()
{
var identifier = new LdapDirectoryIdentifier(_config.Host, _config.Port);
var connection = new LdapConnection(identifier)
{
AuthType = AuthType.Basic,
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds)
};
connection.SessionOptions.ProtocolVersion = 3;
return connection;
}
private static string? ExtractGroupName(string dn)
{
// Parse "ou=ReadWrite,ou=groups,dc=..." or "cn=ReadWrite,..."
if (string.IsNullOrEmpty(dn)) return null;
var parts = dn.Split(',');
if (parts.Length == 0) return null;
var first = parts[0].Trim();
var eqIdx = first.IndexOf('=');
return eqIdx >= 0 ? first.Substring(eqIdx + 1) : null;
}
private static string EscapeLdapFilter(string input)
{
return input
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
}
}

View File

@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly HistorianDataSource? _historianDataSource;
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
@@ -192,7 +194,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true)
bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null,
bool ldapRolesEnabled = false)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
@@ -201,6 +205,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup;
_ldapRolesEnabled = ldapRolesEnabled;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -467,6 +473,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private ServiceResult OnAlarmAcknowledge(
ISystemContext context, ConditionState condition, byte[] eventId, LocalizedText comment)
{
if (!HasAlarmAckPermission(context))
return new ServiceResult(StatusCodes.BadUserAccessDenied);
var alarmInfo = _alarmInAlarmTags.Values
.FirstOrDefault(a => a.ConditionNode == condition);
if (alarmInfo == null)
@@ -1103,9 +1112,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue;
// Enforce role-based write access: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
// Enforce role-based write access
if (!HasWritePermission(context))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
@@ -1155,6 +1163,39 @@ 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");
}
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
return false;
}
return true;
}
private bool HasAlarmAckPermission(ISystemContext context)
{
if (!_ldapRolesEnabled || _appRoleLookup == null)
return true; // no LDAP restrictions without LDAP roles
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");
}
private bool TryApplyArrayElementWrite(string tagRef, object? writeValue, string indexRange, out object updatedArray)
{
updatedArray = null!;

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using Opc.Ua;
@@ -29,6 +30,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly RedundancyConfiguration _redundancyConfig;
private readonly string? _applicationUri;
private readonly ServiceLevelCalculator _serviceLevelCalculator = new ServiceLevelCalculator();
private readonly ConcurrentDictionary<string, IReadOnlyList<string>> _userAppRoles = new ConcurrentDictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
private LmxNodeManager? _nodeManager;
/// <summary>
@@ -36,6 +38,22 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// </summary>
public LmxNodeManager? NodeManager => _nodeManager;
/// <summary>
/// Returns the application-level roles cached for the given username, or null if no roles are stored.
/// Called by LmxNodeManager to enforce per-role write and alarm-ack permissions.
/// </summary>
public IReadOnlyList<string>? GetUserAppRoles(string? username)
{
if (username != null && _userAppRoles.TryGetValue(username, out var roles))
return roles;
return null;
}
/// <summary>
/// Gets whether LDAP role-based access control is active.
/// </summary>
public bool LdapRolesEnabled => _authProvider is Domain.IRoleProvider;
/// <summary>
/// Gets the number of active OPC UA sessions currently connected to the server.
/// </summary>
@@ -69,7 +87,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite);
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null,
LdapRolesEnabled);
var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
@@ -206,10 +226,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
throw new ServiceResultException(StatusCodes.BadUserAccessDenied, "Invalid username or password");
}
var roles = new List<Role> { Role.AuthenticatedUser };
// Resolve LDAP-based roles when the provider supports it
if (_authProvider is Domain.IRoleProvider roleProvider)
{
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
_userAppRoles[userNameToken.UserName] = appRoles;
Log.Information("User {Username} authenticated with roles [{Roles}]",
userNameToken.UserName, string.Join(", ", appRoles));
}
else
{
Log.Information("User {Username} authenticated", userNameToken.UserName);
}
args.Identity = new RoleBasedIdentity(
new UserIdentity(userNameToken),
new List<Role> { Role.AuthenticatedUser });
Log.Information("User {Username} authenticated", userNameToken.UserName);
new UserIdentity(userNameToken), roles);
return;
}

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)
if (_authConfig.Users.Count > 0 || _authConfig.Ldap.Enabled)
policies.Add(new UserTokenPolicy(UserTokenType.UserName));
if (policies.Count == 0)

View File

@@ -161,9 +161,21 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
var historianDataSource = _config.Historian.Enabled
? new Historian.HistorianDataSource(_config.Historian)
: null;
var authProvider = _config.Authentication.Users.Count > 0
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null;
Domain.IUserAuthenticationProvider? authProvider;
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

@@ -34,6 +34,11 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<!-- LDAP authentication -->
<Reference Include="System.DirectoryServices.Protocols" />
</ItemGroup>
<ItemGroup>
<!-- MXAccess COM interop -->
<Reference Include="ArchestrA.MxAccess">

View File

@@ -35,8 +35,21 @@
},
"Authentication": {
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
"AnonymousCanWrite": false,
"Users": [],
"Ldap": {
"Enabled": false,
"Host": "localhost",
"Port": 3893,
"BaseDN": "dc=lmxopcua,dc=local",
"BindDnTemplate": "cn={username},dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite",
"AlarmAckGroup": "AlarmAck"
}
},
"Security": {
"Profiles": ["None"],