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)
@@ -0,0 +1,433 @@
using System.Reflection;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
public sealed class Sdk2ApplicationAuditPermissionsTests : IAsyncLifetime
{
private const string Principal = "audit-reader";
private const string KeyId = "reader-key";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-audit-{Guid.NewGuid():N}");
private OpcUaApplicationHost _host = null!;
private OtOpcUaSdkServer _server = null!;
private OtOpcUaNodeManager _manager = null!;
private BaseObjectTypeState _auditType = null!;
private BaseDataVariableState _node = null!;
private readonly DataPlaneAuthorityState _authority = new();
private readonly ManualTime _clock = new();
private AuditWriteUpdateEventState Audit => _manager.BuildWriteFailureAuditEvent(
_node, new NodeWriteOutcome(false, "device refused"), new short[] { 1, 2 }, new short[] { 7, 8 }, Principal);
public async ValueTask InitializeAsync()
{
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "SDK2.AuditContracts", ApplicationUri = $"urn:SDK2.AuditContracts:{Guid.NewGuid():N}",
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pki,
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), Ct);
_host = started.Host;
_server = started.Server;
_manager = _server.NodeManager!;
_auditType = ((DiagnosticsNodeManager)_server.CurrentInstance.DiagnosticsNodeManager)
.FindPredefinedNode<BaseObjectTypeState>(ObjectTypeIds.AuditWriteUpdateEventType)!;
_manager.EnsureVariable("v", null, "Value", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
_node = _manager.TryGetVariable("v", AddressSpaceRealm.Raw)!;
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, _clock.GetUtcNow().UtcDateTime, AddressSpaceRealm.Raw);
Apply();
_manager.Authorizer = new DataPlaneAuthorizer(_authority, new TriePermissionEvaluator(_authority.Tries), true,
NullLogger.Instance, _clock);
SetAuditing(true);
}
public async ValueTask DisposeAsync()
{
await _host.DisposeAsync();
Directory.Delete(_pki, true);
}
[Fact]
public void Actual_audit_type_grants_only_the_private_application_validation_and_never_mutates_permissions()
{
var item = Item();
var roles = _auditType.RolePermissions;
var before = roles.ToArray()!.Select(x => (x.RoleId, x.Permissions)).ToArray();
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
_auditType.RolePermissions.ShouldBe(roles);
_auditType.RolePermissions.ToArray()!.Select(x => (x.RoleId, x.Permissions)).ShouldBe(before);
using var context = new OperationContext(item);
_manager.ValidateRolePermissions(context, ObjectTypeIds.AuditWriteUpdateEventType, PermissionType.Write).StatusCode
.ShouldBe(StatusCodes.BadUserAccessDenied);
_manager.ValidateEventReceivePermissions(context, ObjectTypeIds.AuditUpdateEventType, _node.NodeId).StatusCode
.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Theory]
[InlineData("expired")]
[InlineData("revoked")]
[InlineData("no-read")]
[InlineData("auditing-off")]
[InlineData("unencrypted")]
[InlineData("restricted")]
[InlineData("identity-changed")]
[InlineData("wrong-type")]
[InlineData("unknown-source")]
[InlineData("same-namespace-unknown-source")]
public void Current_authority_source_type_and_transport_are_rechecked(string denial)
{
var item = Item();
var audit = Audit;
_manager.ValidateApplicationAuditEventReceivePermissions(item, audit).ShouldBe(ServiceResult.Good);
switch (denial)
{
case "expired": _clock.Advance(TimeSpan.FromHours(2)); break;
case "revoked": Apply(keys: false); break;
case "no-read": Apply(NodePermissions.Browse); break;
case "auditing-off": SetAuditing(false); break;
case "unencrypted": item.State.Endpoint.SecurityMode = MessageSecurityMode.Sign; break;
case "restricted":
item.State.Identity = RoleBasedIdentity.CreateRestricted(item.State.Identity, [Role.AuthenticatedUser],
_server.CurrentInstance.NamespaceUris);
break;
case "identity-changed": item.Identity = Identity(); break;
case "wrong-type": audit.EventType!.Value = ObjectTypeIds.AuditUpdateEventType; break;
case "unknown-source": audit.SourceNode!.Value = new NodeId("unowned", (ushort)123); break;
case "same-namespace-unknown-source": audit.SourceNode!.Value = new NodeId("missing", _node.NodeId.NamespaceIndex); break;
}
_manager.ValidateApplicationAuditEventReceivePermissions(item, audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Fact]
public void User_role_permission_restrictions_are_preserved()
{
var item = Item();
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
_auditType.UserRolePermissions = [new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_AuthenticatedUser, Permissions = (uint)PermissionType.Read,
}];
var original = _auditType.UserRolePermissions;
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
_auditType.UserRolePermissions.ShouldBe(original);
}
[Fact]
public void Authority_revoked_during_source_metadata_read_cannot_use_the_earlier_type_grant()
{
var item = Item();
_node.OnReadRolePermissions = (ISystemContext _, NodeState node, ref ArrayOf<RolePermissionType> permissions) =>
{
Apply(keys: false);
return ServiceResult.Good;
};
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Fact]
public void Existing_source_receive_restrictions_still_deny_the_scoped_validation()
{
var item = Item();
_node.RolePermissions = [new RolePermissionType
{
RoleId = ObjectIds.WellKnownRole_AuthenticatedUser, Permissions = (uint)PermissionType.Read,
}];
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
[InlineData(true, false, false)]
[InlineData(true, true, false)]
[InlineData(false, false, true)]
[InlineData(true, false, true)]
public void Prior_callback_veto_or_fault_cleans_up_the_exact_context(bool userAttribute, bool uncertain, bool throws)
{
var item = Item();
OperationContext? captured = null;
var fault = new InvalidOperationException("controlled permission callback fault");
NodeAttributeEventHandler<ArrayOf<RolePermissionType>> previous =
(ISystemContext context, NodeState _, ref ArrayOf<RolePermissionType> permissions) =>
{
captured = ((ServerSystemContext)context).OperationContext;
if (throws) throw fault;
return uncertain ? StatusCodes.Uncertain : StatusCodes.BadNotSupported;
};
_auditType.OnReadRolePermissions = userAttribute ? null : previous;
_auditType.OnReadUserRolePermissions = userAttribute ? previous : null;
_manager.InstallApplicationAuditPermissions(_auditType);
var result = _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit);
result.StatusCode.ShouldBe(throws ? StatusCodes.BadUnexpectedError
: uncertain ? StatusCodes.Uncertain : StatusCodes.BadNotSupported);
ServiceResult.IsGood(result).ShouldBeFalse();
captured.ShouldNotBeNull();
_auditType.OnReadRolePermissions = null;
_auditType.OnReadUserRolePermissions = null;
_manager.InstallApplicationAuditPermissions(_auditType);
_manager.ValidateEventReceivePermissions(captured!, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
.ShouldBe(StatusCodes.BadUserAccessDenied);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void Removed_or_recreated_source_cannot_use_the_original_variable_capability(bool sourceMetadata, bool recreate)
{
var item = Item();
var audit = Audit;
NodeAttributeEventHandler<ArrayOf<RolePermissionType>> previous =
(ISystemContext _, NodeState node, ref ArrayOf<RolePermissionType> permissions) =>
{
_manager.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue();
if (recreate)
_manager.EnsureVariable("v", null, "New Value", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
return ServiceResult.Good;
};
if (sourceMetadata) _node.OnReadRolePermissions = previous;
else
{
_auditType.OnReadRolePermissions = previous;
_manager.InstallApplicationAuditPermissions(_auditType);
}
_manager.ValidateApplicationAuditEventReceivePermissions(item, audit).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
if (recreate) _manager.TryGetVariable("v", AddressSpaceRealm.Raw).ShouldNotBeSameAs(_node);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void An_uncertain_permission_callback_never_queues_a_manual_audit(bool userAttribute)
{
var item = Item();
var queued = 0;
item.OnQueue = _ => queued++;
NodeAttributeEventHandler<ArrayOf<RolePermissionType>> previous =
(ISystemContext _, NodeState node, ref ArrayOf<RolePermissionType> permissions) => StatusCodes.Uncertain;
_auditType.OnReadRolePermissions = userAttribute ? null : previous;
_auditType.OnReadUserRolePermissions = userAttribute ? previous : null;
_manager.InstallApplicationAuditPermissions(_auditType);
using var context = new OperationContext(item);
_manager.SubscribeToAllEvents(context, 1, item, false).ShouldBe(ServiceResult.Good);
_manager.EnsureVariable("scalar", null, "Scalar", "Int32", true, AddressSpaceRealm.Raw);
_manager.WriteValue("scalar", 2, OpcUaStatus.Good, _clock.GetUtcNow().UtcDateTime, AddressSpaceRealm.Raw);
// This synchronous internal correction exercises the real manual dispatcher without a timing assertion.
_manager.RevertOptimisticWriteIfNeeded("scalar", new NodeWriteOutcome(false, "refused"), 2, 1,
StatusCodes.Good, Principal, AddressSpaceRealm.Raw);
queued.ShouldBe(0);
SdkValueBoundary.ToClr(_manager.TryGetVariable("scalar", AddressSpaceRealm.Raw)!.Value).ShouldBe(1);
}
[Fact]
public void Nested_validation_uses_independent_contexts_and_calls_the_prior_callback_first()
{
var item = Item();
var depth = 0;
var nested = 0;
var captured = new List<OperationContext>();
_auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf<RolePermissionType> permissions) =>
{
var operation = ((ServerSystemContext)context).OperationContext!;
if (operation.GetType() == typeof(OperationContext)) return ServiceResult.Good;
captured.Add(operation);
// The composition has not run yet: the prior callback sees only the persisted value.
permissions.ShouldBe(_auditType.RolePermissions);
if (depth++ == 0)
{
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
nested++;
}
depth--;
return ServiceResult.Good;
};
_manager.InstallApplicationAuditPermissions(_auditType);
_manager.ValidateApplicationAuditEventReceivePermissions(item, Audit).ShouldBe(ServiceResult.Good);
nested.ShouldBe(1);
captured.Count.ShouldBe(2);
captured[0].ShouldNotBeSameAs(captured[1]);
_auditType.OnReadRolePermissions = null;
_manager.InstallApplicationAuditPermissions(_auditType);
foreach (var operation in captured)
_manager.ValidateEventReceivePermissions(operation, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Fact]
public async Task Parallel_ordinary_context_never_inherits_a_scoped_permission()
{
var item = Item();
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
_auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf<RolePermissionType> permissions) =>
{
if (((ServerSystemContext)context).OperationContext!.GetType() != typeof(OperationContext))
{
entered.Set();
release.Wait(Ct);
}
return ServiceResult.Good;
};
_manager.InstallApplicationAuditPermissions(_auditType);
var scoped = Task.Run(() => _manager.ValidateApplicationAuditEventReceivePermissions(item, Audit), Ct);
entered.Wait(TimeSpan.FromSeconds(5), Ct).ShouldBeTrue();
try { Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); }
finally { release.Set(); }
(await scoped).ShouldBe(ServiceResult.Good);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
[Fact]
public async Task Actual_failed_array_write_queues_one_application_audit_after_revoking_the_capability()
{
var item = Item();
OperationContext? captured = null;
var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var count = 0;
_auditType.OnReadRolePermissions = (ISystemContext context, NodeState _, ref ArrayOf<RolePermissionType> permissions) =>
{
captured ??= ((ServerSystemContext)context).OperationContext;
return ServiceResult.Good;
};
_manager.InstallApplicationAuditPermissions(_auditType);
item.OnQueue = instance =>
{
try
{
instance.ShouldBeOfType<AuditWriteUpdateEventState>().EventType!.Value.ShouldBe(ObjectTypeIds.AuditWriteUpdateEventType);
_manager.ValidateEventReceivePermissions(captured!, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId).StatusCode
.ShouldBe(StatusCodes.BadUserAccessDenied);
Interlocked.Increment(ref count);
queued.TrySetResult();
}
catch (Exception error) { queued.TrySetException(error); }
};
using var context = new OperationContext(item);
_manager.SubscribeToAllEvents(context, 1, item, false).ShouldBe(ServiceResult.Good);
_manager.NodeWriteGateway = new RefusingGateway();
var data = new DataValue(SdkValueBoundary.FromClr(new short[] { 1, 2 }));
_node.WriteAttribute(new ServerSystemContext(_server.CurrentInstance, context), Attributes.Value, default, data)
.ShouldBe(ServiceResult.Good);
await queued.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
count.ShouldBe(1);
SdkValueBoundary.ToClr(_node.Value).ShouldBeOfType<short[]>().ShouldBe([7, 8]);
Ordinary(item).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
}
private ServiceResult Ordinary(EventItem item)
{
using var context = new OperationContext(item);
return _manager.ValidateEventReceivePermissions(context, ObjectTypeIds.AuditWriteUpdateEventType, _node.NodeId);
}
private void SetAuditing(bool enabled)
{
var value = Variant.From(enabled);
_server.CurrentInstance.ServerObject.Auditing!.OnSimpleWriteValue!(
_manager.SystemContext, _server.CurrentInstance.ServerObject.Auditing, ref value).ShouldBe(ServiceResult.Good);
_server.CurrentInstance.Auditing.ShouldBe(enabled);
}
private RoleCarryingUserIdentity Identity() => (RoleCarryingUserIdentity)new RoleCarryingUserIdentity(
new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId)
.WithAdditionalRoles([Role.AuthenticatedUser], _server.CurrentInstance.NamespaceUris);
private EventItem Item()
{
var session = DispatchProxy.Create<ISession, SessionState>();
var state = (SessionState)(object)session;
state.Identity = Identity();
return new EventItem(session, state);
}
private void Apply(NodePermissions permissions = NodePermissions.Read | NodePermissions.WriteOperate, bool keys = true) =>
_authority.Apply("audit-cluster", Guid.NewGuid().ToString(), keys ? [new ApiKey
{
ApiKeyId = KeyId, Name = KeyId, SecretHash = "hash", Principal = Principal, Enabled = true,
CreatedUtc = _clock.GetUtcNow().UtcDateTime, ExpiresUtc = _clock.GetUtcNow().AddHours(1).UtcDateTime,
}] : [], [new NodeAcl
{
NodeAclId = "audit-grant", ClusterId = "audit-cluster", Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = permissions,
}], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
private sealed class ManualTime : TimeProvider
{
private DateTimeOffset _utc = new(2026, 11, 10, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _utc;
internal void Advance(TimeSpan delta) => _utc += delta;
}
private sealed class RefusingGateway : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "device refused"));
}
public class SessionState : DispatchProxy
{
public IUserIdentity Identity { get; set; } = null!;
public EndpointDescription Endpoint { get; } = new() { SecurityMode = MessageSecurityMode.SignAndEncrypt };
protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name switch
{
"get_EffectiveIdentity" or "get_Identity" => Identity,
"get_EndpointDescription" => Endpoint,
"get_PreferredLocales" => Array.Empty<string>(),
"get_Id" => new NodeId("audit-test-session", 1),
_ => throw new NotSupportedException(method?.Name),
};
}
private sealed class EventItem(ISession session, SessionState state) : IEventMonitoredItem
{
internal SessionState State => state;
internal IUserIdentity? Identity { get; set; }
internal Action<IFilterTarget>? OnQueue { get; set; }
public IUserIdentity EffectiveIdentity => Identity ?? state.Identity;
public ISession Session => session;
public bool MonitoringAllEvents => true;
public NodeId NodeId => ObjectIds.Server;
public object ManagerHandle => null!;
public void QueueEvent(IFilterTarget instance) => OnQueue?.Invoke(instance);
public void QueueEvent(IFilterTarget instance, bool bypassFilter) => QueueEvent(instance);
public void Dispose() { }
public IAsyncNodeManager NodeManager => throw new NotSupportedException();
public uint Id => 1;
public uint SubscriptionId => 1;
public bool IsDurable => false;
public uint ClientHandle => 1;
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public int MonitoredItemType => MonitoredItemTypeMask.Events;
public bool IsReadyToPublish => false;
public bool IsReadyToTrigger { get => false; set { } }
public bool IsResendData => false;
public MonitoringMode MonitoringMode => MonitoringMode.Reporting;
public double SamplingInterval => 0;
public EventFilter EventFilter => null!;
public void SetupResendDataTrigger() { }
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
public bool Publish(OperationContext context, Queue<EventFieldList> notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException();
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn,
uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range,
double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
}
}