fix(opcua): scope application audit receive permissions to validation

This commit is contained in:
Joseph Doherty
2026-11-10 11:21:05 -05:00
parent 344bc9df8a
commit a21104d27c
3 changed files with 558 additions and 2 deletions
@@ -4949,7 +4949,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </para>
/// <para>
/// Called outside <c>Lock</c>, like the <c>Server.ReportEvent</c> call it replaces; the decision
/// takes no node-manager lock.
/// briefly resolves the audit's source variable under the manager lock; permission evaluation
/// and queueing happen outside that lock.
/// </para>
/// </remarks>
private void ReportToServerObject(BaseEventState e)
@@ -4966,11 +4967,122 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var item in _allEventsItems.Keys)
{
if (ServerObjectEventFor(item, e) is not { } visible) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
if (visible is AuditWriteUpdateEventState audit)
{
if (!ServiceResult.IsGood(ValidateApplicationAuditEventReceivePermissions(item, audit))) continue;
}
else if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
item.QueueEvent(visible);
}
}
// Exact SDK 2.0.1 checks ReceiveEvents on the event type and source. The i=2100 defaults
// exclude ordinary application readers. Compose that one permission only while our manual Read-filtered
// delivery is being validated. SDK-generated audits use ordinary contexts and keep their defaults.
internal void InstallApplicationAuditPermissions(BaseObjectTypeState auditType)
{
if (auditType.NodeId != ObjectTypeIds.AuditWriteUpdateEventType)
throw new ArgumentException("Only AuditWriteUpdateEventType may receive this callback.", nameof(auditType));
var previous = auditType.OnReadRolePermissions;
auditType.OnReadRolePermissions = (ISystemContext context, NodeState node, ref ArrayOf<RolePermissionType> permissions) =>
{
var result = ReadAuditPermissions(previous, context, node, ref permissions);
var audit = (context as ServerSystemContext)?.OperationContext as ApplicationAuditOperationContext;
if (!ServiceResult.IsGood(result) || audit is null || node.NodeId != audit.EventType
|| !CanComposeApplicationAuditPermissions(audit)) return result;
// Append fresh entries to a fresh value; neither the node's arrays nor existing entries change.
permissions = [.. permissions, .. (audit.UserIdentity.GrantedRoleIds.ToArray() ?? []).Select(role => new RolePermissionType
{
RoleId = role,
Permissions = (uint)PermissionType.ReceiveEvents,
})];
return result;
};
// Observe this attribute's veto as well: SDK metadata discards either attribute's read error.
// Its callback result and value are passed through unchanged; no permissions are composed here.
var previousUser = auditType.OnReadUserRolePermissions;
auditType.OnReadUserRolePermissions = (ISystemContext context, NodeState node, ref ArrayOf<RolePermissionType> permissions) =>
ReadAuditPermissions(previousUser, context, node, ref permissions);
}
private static ServiceResult ReadAuditPermissions(NodeAttributeEventHandler<ArrayOf<RolePermissionType>>? previous,
ISystemContext context, NodeState node, ref ArrayOf<RolePermissionType> permissions)
{
var audit = (context as ServerSystemContext)?.OperationContext as ApplicationAuditOperationContext;
ServiceResult result;
try { result = previous?.Invoke(context, node, ref permissions) ?? ServiceResult.Good; }
catch (Exception error)
{
// ReadAttribute maps this exception to BadUnexpectedError, but metadata discards that status.
audit?.RecordPermissionReadFailure(ServiceResult.Create(error, StatusCodes.BadUnexpectedError,
"Failed to read non value attribute from node."));
throw;
}
if (!ServiceResult.IsGood(result)) audit?.RecordPermissionReadFailure(result);
return result;
}
internal ServiceResult ValidateApplicationAuditEventReceivePermissions(
IEventMonitoredItem item, AuditWriteUpdateEventState audit)
{
var type = audit.EventType?.Value ?? NodeId.Null;
var source = audit.SourceNode?.Value ?? NodeId.Null;
if (type != ObjectTypeIds.AuditWriteUpdateEventType || !OwnsNamespace(source)
|| !source.TryGetValue(out string _)) return StatusCodes.BadUserAccessDenied;
BaseDataVariableState sourceVariable;
lock (Lock)
{
if (!_variables.TryGetValue(MapKey(source), out sourceVariable!)) return StatusCodes.BadUserAccessDenied;
}
using var context = new ApplicationAuditOperationContext(item, type, source, sourceVariable);
try
{
if (!CanComposeApplicationAuditPermissions(context)) return StatusCodes.BadUserAccessDenied;
var result = ValidateEventReceivePermissions(context, type, source);
// Metadata attribute reads discard callback errors; preserve a prior callback's veto.
if (context.PermissionReadFailure is { } veto) return veto;
// Source metadata callbacks may have changed authority after the type was checked.
return CanComposeApplicationAuditPermissions(context) ? result : StatusCodes.BadUserAccessDenied;
}
finally
{
// The capability is gone before QueueEvent (including nested callbacks) can run.
context.Revoke();
}
}
private bool CanComposeApplicationAuditPermissions(ApplicationAuditOperationContext context) =>
context.IsActive && context.EventType == ObjectTypeIds.AuditWriteUpdateEventType
&& IsCurrentApplicationAuditSource(context) && AclEnforced
&& ReferenceEquals(context.UserIdentity, context.Item.EffectiveIdentity)
&& (context.Session is null || ReferenceEquals(context.UserIdentity, context.Session.EffectiveIdentity))
&& context.UserIdentity is RoleCarryingUserIdentity identity
&& IsAuditDeliverableTo(context.Item) && Permits(identity, OpcUaOperation.Read, context.Source);
private bool IsCurrentApplicationAuditSource(ApplicationAuditOperationContext context)
{
lock (Lock) return _variables.TryGetValue(MapKey(context.Source), out var current)
&& ReferenceEquals(current, context.SourceVariable);
}
private sealed class ApplicationAuditOperationContext(
IEventMonitoredItem item, NodeId eventType, NodeId source, BaseDataVariableState sourceVariable) : OperationContext(item)
{
private int _active = 1;
internal IEventMonitoredItem Item { get; } = item;
internal NodeId EventType { get; } = eventType;
internal NodeId Source { get; } = source;
internal BaseDataVariableState SourceVariable { get; } = sourceVariable;
internal bool IsActive => Volatile.Read(ref _active) != 0;
internal ServiceResult? PermissionReadFailure { get; private set; }
internal void RecordPermissionReadFailure(ServiceResult result)
{
if (IsActive) PermissionReadFailure ??= result;
}
internal void Revoke() => Interlocked.Exchange(ref _active, 0);
}
/// <summary>
/// What of a server-wide event <paramref name="item"/>'s session may receive with enforcement on: the
/// event itself, a copy narrowed to what it may see, or nothing.
@@ -303,6 +303,17 @@ public sealed class OtOpcUaSdkServer : StandardServer
}
}
/// <inheritdoc />
protected override async ValueTask OnNodeManagerStartedAsync(
IServerInternal server, CancellationToken cancellationToken = default)
{
await base.OnNodeManagerStartedAsync(server, cancellationToken).ConfigureAwait(false);
var auditType = server.DiagnosticsNodeManager?
.FindPredefinedNode<BaseObjectTypeState>(ObjectTypeIds.AuditWriteUpdateEventType)
?? throw new InvalidOperationException("AuditWriteUpdateEventType is unavailable during server startup.");
_otOpcUaNodeManager!.InstallApplicationAuditPermissions(auditType);
}
/// <inheritdoc />
protected override MasterNodeManager CreateMasterNodeManager(
IServerInternal server, ApplicationConfiguration configuration)