Consolidate LDAP roles into OPC UA session roles with granular write permissions

Map LDAP groups to custom OPC UA role NodeIds on RoleBasedIdentity.GrantedRoleIds
during authentication, replacing the username-to-role side cache. Split ReadWrite
into WriteOperate/WriteTune/WriteConfigure so write access is gated per Galaxy
security classification. AnonymousCanWrite now behaves consistently regardless
of LDAP state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-29 01:50:16 -04:00
parent 50b9603465
commit 50b85d41bd
21 changed files with 549 additions and 94 deletions

View File

@@ -112,8 +112,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
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);
Log.Information("Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
config.Authentication.Ldap.AlarmAckGroup);
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
{

View File

@@ -55,9 +55,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
public string ReadOnlyGroup { get; set; } = "ReadOnly";
/// <summary>
/// Gets or sets the LDAP group name that grants read-write access.
/// Gets or sets the LDAP group name that grants write access for FreeAccess/Operate attributes.
/// </summary>
public string ReadWriteGroup { get; set; } = "ReadWrite";
public string WriteOperateGroup { get; set; } = "WriteOperate";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Tune attributes.
/// </summary>
public string WriteTuneGroup { get; set; } = "WriteTune";
/// <summary>
/// Gets or sets the LDAP group name that grants write access for Configure attributes.
/// </summary>
public string WriteConfigureGroup { get; set; } = "WriteConfigure";
/// <summary>
/// Gets or sets the LDAP group name that grants alarm acknowledgment access.

View File

@@ -32,7 +32,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
public static class AppRoles
{
public const string ReadOnly = "ReadOnly";
public const string ReadWrite = "ReadWrite";
public const string WriteOperate = "WriteOperate";
public const string WriteTune = "WriteTune";
public const string WriteConfigure = "WriteConfigure";
public const string AlarmAck = "AlarmAck";
}
}

View File

@@ -24,7 +24,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
_groupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ config.ReadOnlyGroup, AppRoles.ReadOnly },
{ config.ReadWriteGroup, AppRoles.ReadWrite },
{ config.WriteOperateGroup, AppRoles.WriteOperate },
{ config.WriteTuneGroup, AppRoles.WriteTune },
{ config.WriteConfigureGroup, AppRoles.WriteConfigure },
{ config.AlarmAckGroup, AppRoles.AlarmAck }
};
}

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxOpcUa.Host.Domain
{
/// <summary>
/// Stable identifiers for custom OPC UA roles mapped from LDAP groups.
/// The namespace URI is registered in the server namespace table at startup,
/// and the string identifiers are resolved to runtime NodeIds before use.
/// </summary>
public static class LmxRoleIds
{
public const string NamespaceUri = "urn:zbmom:lmxopcua:roles";
public const string ReadOnly = "Role.ReadOnly";
public const string WriteOperate = "Role.WriteOperate";
public const string WriteTune = "Role.WriteTune";
public const string WriteConfigure = "Role.WriteConfigure";
public const string AlarmAck = "Role.AlarmAck";
}
}

View File

@@ -25,7 +25,10 @@ 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 NodeId? _writeOperateRoleId;
private readonly NodeId? _writeTuneRoleId;
private readonly NodeId? _writeConfigureRoleId;
private readonly NodeId? _alarmAckRoleId;
private readonly string _namespaceUri;
// NodeId → full_tag_reference for read/write resolution
@@ -70,6 +73,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Gets or sets the declared array length from Galaxy metadata when the attribute is modeled as an array.
/// </summary>
public int? ArrayDimension { get; set; }
/// <summary>
/// Gets or sets the Galaxy security classification (0=FreeAccess, 1=Operate, 4=Tune, 5=Configure, etc.).
/// Used at write time to determine which write role is required.
/// </summary>
public int SecurityClassification { get; set; }
}
// Alarm tracking: maps InAlarm tag reference → alarm source info
@@ -194,7 +203,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
HistorianDataSource? historianDataSource = null,
bool alarmTrackingEnabled = false,
bool anonymousCanWrite = true,
Func<string?, IReadOnlyList<string>?>? appRoleLookup = null)
NodeId? writeOperateRoleId = null,
NodeId? writeTuneRoleId = null,
NodeId? writeConfigureRoleId = null,
NodeId? alarmAckRoleId = null)
: base(server, configuration, namespaceUri)
{
_namespaceUri = namespaceUri;
@@ -203,7 +215,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_historianDataSource = historianDataSource;
_alarmTrackingEnabled = alarmTrackingEnabled;
_anonymousCanWrite = anonymousCanWrite;
_appRoleLookup = appRoleLookup;
_writeOperateRoleId = writeOperateRoleId;
_writeTuneRoleId = writeTuneRoleId;
_writeConfigureRoleId = writeConfigureRoleId;
_alarmAckRoleId = alarmAckRoleId;
// Wire up data change delivery
_mxAccessClient.OnTagValueChanged += OnMxAccessDataChange;
@@ -955,7 +970,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
MxDataType = attr.MxDataType,
IsArray = attr.IsArray,
ArrayDimension = attr.ArrayDimension
ArrayDimension = attr.ArrayDimension,
SecurityClassification = attr.SecurityClassification
};
// Track gobject → tag references for incremental sync
@@ -1085,8 +1101,6 @@ 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)
@@ -1096,19 +1110,23 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (errors[i] != null && errors[i].StatusCode == StatusCodes.BadNotWritable)
continue;
if (!canWrite)
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
var nodeId = nodesToWrite[i].NodeId;
if (nodeId.NamespaceIndex != NamespaceIndex) continue;
var nodeIdStr = nodeId.Identifier as string;
if (nodeIdStr == null) continue;
if (_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
if (!_nodeIdToTagReference.TryGetValue(nodeIdStr, out var tagRef))
continue;
// Check write permission based on the node's security classification
var secClass = _tagMetadata.TryGetValue(tagRef, out var meta) ? meta.SecurityClassification : 1;
if (!HasWritePermission(context, secClass))
{
errors[i] = new ServiceResult(StatusCodes.BadUserAccessDenied);
continue;
}
{
try
{
@@ -1146,35 +1164,55 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
}
}
private bool HasWritePermission(OperationContext context)
private bool HasWritePermission(OperationContext context, int securityClassification)
{
if (_appRoleLookup != null)
return HasRole(context.UserIdentity, Domain.AppRoles.ReadWrite);
var identity = context.UserIdentity;
// Legacy behavior: reject anonymous writes when AnonymousCanWrite is false
if (!_anonymousCanWrite && context.UserIdentity?.GrantedRoleIds != null &&
!context.UserIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_AuthenticatedUser))
{
return false;
}
// Check anonymous sessions against AnonymousCanWrite
if (identity?.GrantedRoleIds?.Contains(ObjectIds.WellKnownRole_Anonymous) == true)
return _anonymousCanWrite;
// When role-based auth is active, require the role matching the security classification
var requiredRoleId = GetRequiredWriteRole(securityClassification);
if (requiredRoleId != null)
return HasGrantedRole(identity, requiredRoleId);
// No role-based auth — authenticated users can write
return true;
}
private NodeId? GetRequiredWriteRole(int securityClassification)
{
switch (securityClassification)
{
case 0: // FreeAccess
case 1: // Operate
return _writeOperateRoleId;
case 4: // Tune
return _writeTuneRoleId;
case 5: // Configure
return _writeConfigureRoleId;
default:
// SecuredWrite (2), VerifiedWrite (3), ViewOnly (6) are read-only by AccessLevel
// but if somehow reached, require the most restrictive role
return _writeConfigureRoleId;
}
}
private bool HasAlarmAckPermission(ISystemContext context)
{
if (_appRoleLookup == null)
if (_alarmAckRoleId == null)
return true;
var identity = (context as SystemContext)?.UserIdentity;
return HasRole(identity, Domain.AppRoles.AlarmAck);
return HasGrantedRole(identity, _alarmAckRoleId);
}
private bool HasRole(IUserIdentity? identity, string requiredRole)
private static bool HasGrantedRole(IUserIdentity? identity, NodeId? roleId)
{
var username = identity?.GetIdentityToken() is UserNameIdentityToken token ? token.UserName : null;
var roles = _appRoleLookup!(username);
return roles != null && roles.Contains(requiredRole);
return roleId != null &&
identity?.GrantedRoleIds != null &&
identity.GrantedRoleIds.Contains(roleId);
}
private static void EnableEventNotifierUpChain(NodeState node)

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using Opc.Ua;
@@ -30,7 +29,14 @@ 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);
// Resolved custom role NodeIds (populated in CreateMasterNodeManager)
private NodeId? _readOnlyRoleId;
private NodeId? _writeOperateRoleId;
private NodeId? _writeTuneRoleId;
private NodeId? _writeConfigureRoleId;
private NodeId? _alarmAckRoleId;
private LmxNodeManager? _nodeManager;
/// <summary>
@@ -38,22 +44,6 @@ 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>
@@ -85,15 +75,29 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(IServerInternal server, ApplicationConfiguration configuration)
{
// Resolve custom role NodeIds from the roles namespace
ResolveRoleNodeIds(server);
var namespaceUri = $"urn:{_galaxyName}:LmxOpcUa";
_nodeManager = new LmxNodeManager(server, configuration, namespaceUri, _mxAccessClient, _metrics,
_historianDataSource, _alarmTrackingEnabled, _authConfig.AnonymousCanWrite,
LdapRolesEnabled ? (Func<string?, IReadOnlyList<string>?>)GetUserAppRoles : null);
_writeOperateRoleId, _writeTuneRoleId, _writeConfigureRoleId, _alarmAckRoleId);
var nodeManagers = new List<INodeManager> { _nodeManager };
return new MasterNodeManager(server, configuration, null, nodeManagers.ToArray());
}
private void ResolveRoleNodeIds(IServerInternal server)
{
var nsIndex = server.NamespaceUris.GetIndexOrAppend(LmxRoleIds.NamespaceUri);
_readOnlyRoleId = new NodeId(LmxRoleIds.ReadOnly, nsIndex);
_writeOperateRoleId = new NodeId(LmxRoleIds.WriteOperate, nsIndex);
_writeTuneRoleId = new NodeId(LmxRoleIds.WriteTune, nsIndex);
_writeConfigureRoleId = new NodeId(LmxRoleIds.WriteConfigure, nsIndex);
_alarmAckRoleId = new NodeId(LmxRoleIds.AlarmAck, nsIndex);
Log.Debug("Resolved custom role NodeIds in namespace index {NsIndex}", nsIndex);
}
/// <inheritdoc />
protected override void OnServerStarted(IServerInternal server)
{
@@ -155,8 +159,6 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
/// Updates the server's ServiceLevel based on current runtime health.
/// Called by the service layer when MXAccess or DB health changes.
/// </summary>
/// <param name="mxAccessConnected">Whether the MXAccess connection is healthy.</param>
/// <param name="dbConnected">Whether the Galaxy repository database is reachable.</param>
public void UpdateServiceLevel(bool mxAccessConnected, bool dbConnected)
{
var level = CalculateCurrentServiceLevel(mxAccessConnected, dbConnected);
@@ -206,11 +208,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
if (!_authConfig.AllowAnonymous)
throw new ServiceResultException(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled");
var roles = new List<Role> { Role.Anonymous };
if (_authConfig.AnonymousCanWrite)
roles.Add(Role.AuthenticatedUser);
args.Identity = new RoleBasedIdentity(new UserIdentity(anonymousToken), roles);
args.Identity = new RoleBasedIdentity(
new UserIdentity(anonymousToken),
new List<Role> { Role.Anonymous });
Log.Debug("Anonymous session accepted (canWrite={CanWrite})", _authConfig.AnonymousCanWrite);
return;
}
@@ -227,11 +227,32 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
var roles = new List<Role> { Role.AuthenticatedUser };
// Resolve LDAP-based roles when the provider supports it
if (_authProvider is Domain.IRoleProvider roleProvider)
if (_authProvider is IRoleProvider roleProvider)
{
var appRoles = roleProvider.GetUserRoles(userNameToken.UserName);
_userAppRoles[userNameToken.UserName] = appRoles;
foreach (var appRole in appRoles)
{
switch (appRole)
{
case AppRoles.ReadOnly:
if (_readOnlyRoleId != null) roles.Add(new Role(_readOnlyRoleId, AppRoles.ReadOnly));
break;
case AppRoles.WriteOperate:
if (_writeOperateRoleId != null) roles.Add(new Role(_writeOperateRoleId, AppRoles.WriteOperate));
break;
case AppRoles.WriteTune:
if (_writeTuneRoleId != null) roles.Add(new Role(_writeTuneRoleId, AppRoles.WriteTune));
break;
case AppRoles.WriteConfigure:
if (_writeConfigureRoleId != null) roles.Add(new Role(_writeConfigureRoleId, AppRoles.WriteConfigure));
break;
case AppRoles.AlarmAck:
if (_alarmAckRoleId != null) roles.Add(new Role(_alarmAckRoleId, AppRoles.AlarmAck));
break;
}
}
Log.Information("User {Username} authenticated with roles [{Roles}]",
userNameToken.UserName, string.Join(", ", appRoles));
}

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

View File

@@ -24,6 +24,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private readonly IGalaxyRepository? _galaxyRepository;
private readonly IMxAccessClient? _mxAccessClientOverride;
private readonly bool _hasMxAccessClientOverride;
private readonly IUserAuthenticationProvider? _authProviderOverride;
private readonly bool _hasAuthProviderOverride;
private CancellationTokenSource? _cts;
private PerformanceMetrics? _metrics;
@@ -74,13 +76,16 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// <param name="mxAccessClientOverride">An optional direct MXAccess client substitute that bypasses STA thread setup and COM interop.</param>
/// <param name="hasMxAccessClientOverride">A value indicating whether the override client should be used instead of creating a client from <paramref name="mxProxy"/>.</param>
internal OpcUaService(AppConfiguration config, IMxProxy? mxProxy, IGalaxyRepository? galaxyRepository,
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false)
IMxAccessClient? mxAccessClientOverride = null, bool hasMxAccessClientOverride = false,
IUserAuthenticationProvider? authProviderOverride = null, bool hasAuthProviderOverride = false)
{
_config = config;
_mxProxy = mxProxy;
_galaxyRepository = galaxyRepository;
_mxAccessClientOverride = mxAccessClientOverride;
_hasMxAccessClientOverride = hasMxAccessClientOverride;
_authProviderOverride = authProviderOverride;
_hasAuthProviderOverride = hasAuthProviderOverride;
}
/// <summary>
@@ -162,7 +167,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
? new Historian.HistorianDataSource(_config.Historian)
: null;
Domain.IUserAuthenticationProvider? authProvider = null;
if (_config.Authentication.Ldap.Enabled)
if (_hasAuthProviderOverride)
{
authProvider = _authProviderOverride;
}
else if (_config.Authentication.Ldap.Enabled)
{
authProvider = new Domain.LdapAuthenticationProvider(_config.Authentication.Ldap);
Log.Information("LDAP authentication enabled (server={Host}:{Port}, baseDN={BaseDN})",

View File

@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
private bool _mxProxySet;
private bool _galaxyRepositorySet;
private bool _mxAccessClientSet;
private IUserAuthenticationProvider? _authProvider;
private bool _authProviderSet;
/// <summary>
/// Replaces the default service configuration used by the test host.
@@ -116,6 +118,25 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
/// Disables the embedded dashboard so tests can focus on the runtime bridge without binding the HTTP listener.
/// </summary>
/// <returns>The current builder so additional overrides can be chained.</returns>
/// <summary>
/// Injects a custom authentication provider for tests that need deterministic role resolution.
/// </summary>
public OpcUaServiceBuilder WithAuthProvider(IUserAuthenticationProvider? provider)
{
_authProvider = provider;
_authProviderSet = true;
return this;
}
/// <summary>
/// Sets the authentication configuration for the test host.
/// </summary>
public OpcUaServiceBuilder WithAuthentication(AuthenticationConfiguration authConfig)
{
_config.Authentication = authConfig;
return this;
}
public OpcUaServiceBuilder DisableDashboard()
{
_config.Dashboard.Enabled = false;
@@ -176,7 +197,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
_mxProxySet ? _mxProxy : null,
_galaxyRepositorySet ? _galaxyRepository : null,
_mxAccessClientSet ? _mxAccessClient : null,
_mxAccessClientSet);
_mxAccessClientSet,
_authProviderSet ? _authProvider : null,
_authProviderSet);
}
/// <summary>

View File

@@ -46,7 +46,9 @@
"ServiceAccountPassword": "serviceaccount123",
"TimeoutSeconds": 5,
"ReadOnlyGroup": "ReadOnly",
"ReadWriteGroup": "ReadWrite",
"WriteOperateGroup": "WriteOperate",
"WriteTuneGroup": "WriteTune",
"WriteConfigureGroup": "WriteConfigure",
"AlarmAckGroup": "AlarmAck"
}
},