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

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