fix(opcua): scope application audit receive permissions to validation
This commit is contained in:
@@ -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)
|
||||
|
||||
+433
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user