215 lines
12 KiB
C#
215 lines
12 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #588 over the wire — with <c>Security:Acl:Enabled</c> on, the server-wide events this node manager
|
|
/// raises (<c>GeneralModelChangeEvent</c>s, <c>AuditWriteUpdateEvent</c>s) reach a Server-object
|
|
/// (<c>i=2253</c>) item only when its session may see the node the event names.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// They used to go straight to <c>Server.ReportEvent</c> and the core node manager's Server object, which
|
|
/// queues to every all-events item with no ACL decision — so a session with no grant learned, from a
|
|
/// model-change event, the RawPath of every node added, deleted or retyped under it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The withheld session is granted <see cref="ServerEventRig.EqA"/> only, so the absence of the raw
|
|
/// event on its item is paired with a positive control <b>on the same item</b>: the sentinel event about
|
|
/// <see cref="ServerEventRig.EqA"/> is reported after the withheld one, through the same path, and must
|
|
/// arrive.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class AclServerObjectModelChangeTests
|
|
{
|
|
private const string RawVariable = "Plant/Modbus/dev1/Speed";
|
|
|
|
private const NodePermissions See = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe;
|
|
|
|
/// <summary>
|
|
/// A NodeAdded / NodeDeleted / DataTypeChanged model-change event naming a raw node reaches the
|
|
/// cluster-granted session and not the session granted only an equipment folder — which still receives
|
|
/// the one naming its own folder.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("NodeAdded")]
|
|
[InlineData("NodeDeleted")]
|
|
[InlineData("DataTypeChanged")]
|
|
public async Task A_model_change_naming_a_node_the_session_cannot_browse_is_withheld(string verb)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"AclModelChange{verb}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See), EquipmentGrant(EqA, See));
|
|
rig.EnsureEquipment();
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: false, AddressSpaceRealm.Raw);
|
|
|
|
var (granted, grantedEvents, grantedItem, grantedSubscription) = await ServerObjectAsync(rig, anonymous: false, ct);
|
|
var (narrow, narrowEvents, narrowItem, narrowSubscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
|
|
|
switch (verb)
|
|
{
|
|
case "NodeAdded": rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw); break;
|
|
case "NodeDeleted": rig.Sink.RemoveVariableNode(RawVariable, AddressSpaceRealm.Raw).ShouldBeTrue(); break;
|
|
default:
|
|
rig.Sink.UpdateTagAttributes(RawVariable, false, null, "Double", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
break;
|
|
}
|
|
|
|
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
|
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
grantedEvents.Of(grantedItem).Count(e => NamesEqA(e)) == 1
|
|
&& narrowEvents.Of(narrowItem).Any(NamesEqA));
|
|
|
|
var rawNames = verb == "NodeAdded" ? RawDeviceFolder : RawVariable;
|
|
grantedEvents.Of(grantedItem).Count(e => Names(e, rawNames)).ShouldBe(1, "the granted control receives the raw change");
|
|
narrowEvents.Of(narrowItem).Where(e => Names(e, rawNames)).ShouldBeEmpty(
|
|
"a session that cannot browse the node must not learn its name from a model-change event");
|
|
narrowEvents.Of(narrowItem).Count.ShouldBe(1, "only the change to its own equipment folder");
|
|
|
|
await narrowSubscription.DeleteAsync(true, ct);
|
|
await grantedSubscription.DeleteAsync(true, ct);
|
|
narrow.Dispose();
|
|
granted.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// With enforcement off nothing changes: a session holding no grant at all still receives the raw
|
|
/// model-change event through the Server object, exactly once.
|
|
/// </summary>
|
|
/// <remarks>Without this the test above also passes on a build that withholds every model change from everyone.</remarks>
|
|
[Fact]
|
|
public async Task With_acl_enforcement_off_every_server_object_item_receives_the_model_change()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("AclModelChangeOff", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See));
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
|
|
var (session, events, item, subscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
|
|
|
rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Of(item).Any(e => Names(e, RawDeviceFolder)));
|
|
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
|
events.Of(item).Count(e => Names(e, RawDeviceFolder)).ShouldBe(1);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
session.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// With auditing on over an encrypted channel, a rejected device write's <c>AuditWriteUpdateEvent</c> —
|
|
/// which carries the written value and the node's prior one — reaches a session granted <c>Read</c> on
|
|
/// the node and not one granted only an equipment folder.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Auditing is off by default (<c>ServerConfiguration.AuditingEnabled</c>, which this repo never sets),
|
|
/// and audit events only ever leave over SignAndEncrypt — so both sessions are encrypted here, or the
|
|
/// withheld one would be denied by the channel rather than by the ACL and the test would prove nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task An_audit_write_event_reaches_only_sessions_that_may_read_the_node()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("AclAuditWrite", encrypted: true, ct);
|
|
try
|
|
{
|
|
rig.Enforce(
|
|
true, [Key()],
|
|
ClusterGrant(KeyPrincipal, "acl-key", See | NodePermissions.WriteOperate), EquipmentGrant(EqA, See));
|
|
rig.SetAuditing(true);
|
|
rig.EnsureEquipment();
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
|
rig.Sink.WriteValue(RawVariable, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
|
|
|
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
|
await ServerObjectAsync(rig, anonymous: false, ct, encrypted: true);
|
|
var (narrow, narrowEvents, narrowItem, narrowSubscription) =
|
|
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
|
|
|
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single(s =>
|
|
s.Identity is ZB.MOM.WW.OtOpcUa.OpcUaServer.Security.RoleCarryingUserIdentity { ApiKeyId: KeyId });
|
|
using var permissionContext = new Opc.Ua.Server.OperationContext(new RequestHeader(), null!,
|
|
Opc.Ua.Server.RequestType.Read, RequestLifetime.None, serverSession);
|
|
var typePermission = rig.Nm.ValidateRolePermissions(permissionContext, ObjectTypeIds.AuditWriteUpdateEventType, PermissionType.ReceiveEvents);
|
|
var sourcePermission = rig.Nm.ValidateRolePermissions(permissionContext, variable, PermissionType.ReceiveEvents);
|
|
typePermission.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied,
|
|
"ordinary SDK audit emission must stay denied; only the scoped application path may grant receive permission");
|
|
ServiceResult.IsGood(sourcePermission).ShouldBeTrue($"Audit source permission: {sourcePermission}");
|
|
var write = await granted.WriteAsync(
|
|
null,
|
|
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
|
ct);
|
|
StatusCode.IsGood(write.Results[0]).ShouldBeTrue();
|
|
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
grantedEvents.HasType(grantedItem, ObjectTypeIds.AuditWriteUpdateEventType));
|
|
|
|
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
|
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
|
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA)
|
|
&& grantedEvents.Of(grantedItem).Any(NamesEqA));
|
|
|
|
grantedEvents.Of(grantedItem).Count(f => f[RecordedEvents.EventTypeField].GetNodeId() == ObjectTypeIds.AuditWriteUpdateEventType)
|
|
.ShouldBe(1, "one application audit is delivered without an automatic SDK duplicate");
|
|
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
|
"a session that may not read the node must not receive its written values through an audit event");
|
|
|
|
await narrowSubscription.DeleteAsync(true, ct);
|
|
await grantedSubscription.DeleteAsync(true, ct);
|
|
narrow.Dispose();
|
|
granted.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static async Task<(ISession Session, RecordedEvents Events, MonitoredItem Item, Subscription Subscription)>
|
|
ServerObjectAsync(ServerEventRig rig, bool anonymous, CancellationToken ct, bool encrypted = false)
|
|
{
|
|
var session = await rig.ConnectAsync(anonymous, ct, encrypted);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
item.Status.Error.ShouldBeNull();
|
|
return (session, events, item, subscription);
|
|
}
|
|
|
|
private static bool NamesEqA(ArrayOf<Variant> e) => Names(e, EqA);
|
|
|
|
/// <summary>Whether a model-change event's <c>Changes</c> name the node whose identifier is <paramref name="id"/>.</summary>
|
|
private static bool Names(ArrayOf<Variant> e, string id) =>
|
|
e[RecordedEvents.ChangesField].ClrValue()! is ExtensionObject[] changes
|
|
&& changes.Any(c => ExtensionObject.ToEncodeable(c) is ModelChangeStructureDataType { Affected: { } affected }
|
|
&& affected.IdentifierAsString == id);
|
|
|
|
/// <summary>Every device write fails, so the node manager reverts and audits it.</summary>
|
|
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
|
Task.FromResult(new NodeWriteOutcome(false, "rejected by the test device"));
|
|
}
|
|
}
|