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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user