Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
Joseph Doherty 6b04a85f86 Phase 3 PR 26 — server-layer write authorization gating by role. Per the user's ACL-at-server-layer directive (saved as feedback_acl_at_server_layer.md in memory), write authorization is enforced in DriverNodeManager.OnWriteValue and never delegated to the driver or to driver-specific auth (the v1 Galaxy-provided security path is explicitly not part of v2 — drivers report SecurityClassification as discovery metadata only). New WriteAuthzPolicy static class in Server/Security/ maps SecurityClassification → required role per the table documented in docs/Configuration.md: FreeAccess = no role required (anonymous sessions can write), Operate + SecuredWrite = WriteOperate, Tune = WriteTune, VerifiedWrite + Configure = WriteConfigure, ViewOnly = deny regardless of roles. Role matching is case-insensitive and role requirements do NOT cascade — a session with WriteConfigure can write Configure attributes but needs WriteOperate separately to write Operate attributes; this is deliberate so escalation is an explicit LDAP group assignment, not a hierarchy the policy silently grants. DriverNodeManager gains a _securityByFullRef Dictionary populated during Variable() registration (parallel to the existing _variablesByFullRef) so OnWriteValue can look up the classification in O(1) on the hot path. OnWriteValue casts the session's context.UserIdentity to the new IRoleBearer interface (implemented by OtOpcUaServer.RoleBasedIdentity from PR 19) — empty Roles collection when the session is anonymous; the same WriteAuthzPolicy.IsAllowed check then either short-circuits true (FreeAccess), false (ViewOnly), or walks the roles list looking for the required one. On deny, OnWriteValue logs 'Write denied for {FullRef}: classification=X userRoles=[...]' at Information level (readable trail for operator complaints) and returns BadUserAccessDenied without touching IWritable.WriteAsync — drivers never see a request we'd have refused. IRoleBearer kept as a minimal server-side interface rather than reusing some abstraction from Core.Abstractions because the concept is OPC-UA-session-scoped and doesn't generalize (the driver side has no notion of a user session). Tests — WriteAuthzPolicyTests (17 new cases): FreeAccess allows write with empty role set + arbitrary roles; ViewOnly denies write even with every role; Operate requires WriteOperate; role match is case-insensitive; Operate denies empty role set + wrong role; SecuredWrite shares Operate's requirement; Tune requires WriteTune; Tune denies WriteOperate-only (asserts roles don't cascade — this is the test that catches a future regression where someone 'helpfully' adds a role-escalation table); Configure requires WriteConfigure; VerifiedWrite shares Configure's requirement; multi-role session allowed when any role matches; unrelated roles denied; RequiredRole theory covering all 5 classified-and-mapped rows + null for FreeAccess/ViewOnly special cases. lmx-followups.md follow-up #2 marked DONE with a back-reference to this PR and the memory note. Full Server.Tests Unit suite: 38 pass / 0 fail (17 new WriteAuthz + 14 SecurityConfiguration from PR 19 + 2 NodeBootstrap + 5 others). Server.Tests Integration (Category=Integration) 2 pass — existing PR 17 anonymous-endpoint smoke tests stay green since the read path doesn't hit OnWriteValue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 13:01:01 -04:00

388 lines
17 KiB
C#

using System.Globalization;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
/// <summary>
/// Concrete <see cref="CustomNodeManager2"/> that materializes the driver's address space
/// into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
/// <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
/// OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
/// <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
/// <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
/// Acknowledge / Deactivate transitions.
/// </summary>
/// <remarks>
/// Read / Subscribe / Write are routed to the driver's capability interfaces — the node
/// manager holds references to <see cref="IReadable"/>, <see cref="ISubscribable"/>, and
/// <see cref="IWritable"/> when present. Nodes with no driver backing (plain folders) are
/// served directly from the internal PredefinedNodes table.
/// </remarks>
public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
private readonly IDriver _driver;
private readonly IReadable? _readable;
private readonly IWritable? _writable;
private readonly ILogger<DriverNodeManager> _logger;
/// <summary>The driver whose address space this node manager exposes.</summary>
public IDriver Driver => _driver;
private FolderState? _driverRoot;
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
// PR 26: SecurityClassification per variable, populated during Variable() registration.
// OnWriteValue looks up the classification here to gate the write by the session's roles.
// Drivers never enforce authz themselves — the classification is discovery-time metadata
// only (feedback_acl_at_server_layer.md).
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
// Active building folder — set per Folder() call so Variable() lands under the right parent.
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
// returns a child builder per Folder call and the caller threads nesting through those references.
private FolderState _currentFolder = null!;
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
IDriver driver, ILogger<DriverNodeManager> logger)
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
{
_driver = driver;
_readable = driver as IReadable;
_writable = driver as IWritable;
_logger = logger;
}
protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
_driverRoot = new FolderState(null)
{
SymbolicName = _driver.DriverInstanceId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
DisplayName = new LocalizedText(_driver.DriverInstanceId),
EventNotifier = EventNotifiers.None,
};
// Link under Objects folder so clients see the driver subtree at browse root.
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var references))
{
references = new List<IReference>();
externalReferences[ObjectIds.ObjectsFolder] = references;
}
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, _driverRoot.NodeId));
AddPredefinedNode(SystemContext, _driverRoot);
_currentFolder = _driverRoot;
}
}
// ------- IAddressSpaceBuilder implementation (PR 15 contract) -------
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
lock (Lock)
{
var folder = new FolderState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = ObjectTypeIds.FolderType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
};
_currentFolder.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
return new NestedBuilder(this, folder);
}
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
lock (Lock)
{
var v = new BaseDataVariableState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.Organizes,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
DataType = MapDataType(attributeInfo.DriverDataType),
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Historizing = attributeInfo.IsHistorized,
};
_currentFolder.AddChild(v);
AddPredefinedNode(SystemContext, v);
_variablesByFullRef[attributeInfo.FullName] = v;
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
v.OnReadValue = OnReadValue;
v.OnWriteValue = OnWriteValue;
return new VariableHandle(this, v, attributeInfo.FullName);
}
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
lock (Lock)
{
var p = new PropertyState(_currentFolder)
{
SymbolicName = browseName,
ReferenceTypeId = ReferenceTypeIds.HasProperty,
TypeDefinitionId = VariableTypeIds.PropertyType,
NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(browseName),
DataType = MapDataType(dataType),
ValueRank = ValueRanks.Scalar,
Value = value,
};
_currentFolder.AddChild(p);
AddPredefinedNode(SystemContext, p);
}
}
private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_readable is null)
{
statusCode = StatusCodes.BadNotReadable;
return ServiceResult.Good;
}
try
{
var fullRef = node.NodeId.Identifier as string ?? "";
var result = _readable.ReadAsync([fullRef], CancellationToken.None).GetAwaiter().GetResult();
if (result.Count == 0)
{
statusCode = StatusCodes.BadNoData;
return ServiceResult.Good;
}
var snap = result[0];
value = snap.Value;
statusCode = snap.StatusCode;
timestamp = snap.ServerTimestampUtc;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OnReadValue failed for {NodeId}", node.NodeId);
statusCode = StatusCodes.BadInternalError;
}
return ServiceResult.Good;
}
private static NodeId MapDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => DataTypeIds.Boolean,
DriverDataType.Int32 => DataTypeIds.Int32,
DriverDataType.Float32 => DataTypeIds.Float,
DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime,
_ => DataTypeIds.BaseDataType,
};
/// <summary>
/// Nested builder returned by <see cref="Folder"/>. Temporarily retargets the parent's
/// <see cref="_currentFolder"/> during each call so Variable/Folder calls land under the
/// correct subtree. Not thread-safe if callers drive Discovery concurrently — but
/// <c>GenericDriverNodeManager</c> discovery is sequential per driver.
/// </summary>
private sealed class NestedBuilder(DriverNodeManager owner, FolderState folder) : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Folder(browseName, displayName); }
finally { owner._currentFolder = prior; }
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { return owner.Variable(browseName, displayName, attributeInfo); }
finally { owner._currentFolder = prior; }
}
public void AddProperty(string browseName, DriverDataType dataType, object? value)
{
var prior = owner._currentFolder;
owner._currentFolder = folder;
try { owner.AddProperty(browseName, dataType, value); }
finally { owner._currentFolder = prior; }
}
}
private sealed class VariableHandle : IVariableHandle
{
private readonly DriverNodeManager _owner;
private readonly BaseDataVariableState _variable;
public string FullReference { get; }
public VariableHandle(DriverNodeManager owner, BaseDataVariableState variable, string fullRef)
{
_owner = owner;
_variable = variable;
FullReference = fullRef;
}
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
lock (_owner.Lock)
{
var alarm = new AlarmConditionState(_variable)
{
SymbolicName = _variable.BrowseName.Name + "_Condition",
ReferenceTypeId = ReferenceTypeIds.HasComponent,
NodeId = new NodeId(FullReference + ".Condition", _owner.NamespaceIndex),
BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
DisplayName = new LocalizedText(info.SourceName),
};
alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, false);
alarm.SourceName.Value = info.SourceName;
alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
alarm.EnabledState.Value = new LocalizedText("Enabled");
alarm.EnabledState.Id.Value = true;
alarm.Retain.Value = false;
alarm.AckedState.Value = new LocalizedText("Acknowledged");
alarm.AckedState.Id.Value = true;
alarm.ActiveState.Value = new LocalizedText("Inactive");
alarm.ActiveState.Id.Value = false;
_variable.AddChild(alarm);
_owner.AddPredefinedNode(_owner.SystemContext, alarm);
return new ConditionSink(_owner, alarm);
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
}
private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
: IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args)
{
lock (owner.Lock)
{
alarm.Severity.Value = (ushort)MapSeverity(args.Severity);
alarm.Time.Value = args.SourceTimestampUtc;
alarm.Message.Value = new LocalizedText(args.Message);
// Map the driver's transition type to OPC UA Part 9 state. The driver uses
// AlarmEventArgs but the state transition kind is encoded in AlarmType by
// convention — Galaxy's GalaxyAlarmTracker emits "Active"/"Acknowledged"/"Inactive".
switch (args.AlarmType)
{
case "Active":
alarm.SetActiveState(owner.SystemContext, true);
alarm.SetAcknowledgedState(owner.SystemContext, false);
alarm.Retain.Value = true;
break;
case "Acknowledged":
alarm.SetAcknowledgedState(owner.SystemContext, true);
break;
case "Inactive":
alarm.SetActiveState(owner.SystemContext, false);
// Retain stays true until the condition is both Inactive and Acknowledged
// so alarm clients keep the record in their condition refresh snapshot.
if (alarm.AckedState.Id.Value) alarm.Retain.Value = false;
break;
}
alarm.ClearChangeMasks(owner.SystemContext, true);
alarm.ReportEvent(owner.SystemContext, alarm);
}
}
private static int MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 250,
AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700,
AlarmSeverity.Critical => 900,
_ => 500,
};
}
/// <summary>
/// Per-variable write hook wired on each <see cref="BaseDataVariableState"/>. Routes the
/// value into the driver's <see cref="IWritable"/> and surfaces its per-tag status code.
/// </summary>
private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
{
if (_writable is null) return StatusCodes.BadNotWritable;
var fullRef = node.NodeId.Identifier as string;
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
// PR 26: server-layer write authorization. Look up the attribute's classification
// (populated during Variable() in Discover) and check the session's roles against the
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
// never sees a request we'd have refused here.
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
{
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
{
_logger.LogInformation(
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
fullRef, classification, string.Join(",", roles));
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
}
try
{
var results = _writable.WriteAsync(
[new DriverWriteRequest(fullRef!, value)],
CancellationToken.None).GetAwaiter().GetResult();
if (results.Count > 0 && results[0].StatusCode != 0)
{
statusCode = results[0].StatusCode;
return ServiceResult.Good;
}
return ServiceResult.Good;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Write failed for {FullRef}", fullRef);
return new ServiceResult(StatusCodes.BadInternalError);
}
}
// Diagnostics hook for tests — number of variables registered in this node manager.
internal int VariableCount => _variablesByFullRef.Count;
internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
=> _variablesByFullRef.TryGetValue(fullRef, out v!);
}