Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Sdk2ApplicationAuditPermissionsTests.cs
T

434 lines
22 KiB
C#

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();
}
}