feat(security): enforce node ACLs on read, browse and subscribe (#520 stage 6)

Completes data-plane enforcement behind Security:Acl:Enabled. Read (every
attribute, not just Value), Browse, TranslateBrowsePath, CreateMonitoredItems,
SubscribeToEvents and SetMonitoringMode now consult scoped NodeAcl grants.
With the flag off nothing changes.

Four things went beyond the stage as written, each because leaving it out
would have made the feature look like it worked:

- Browse needed ancestor visibility. The trie inherits downward, and a client
  reaches a deep node by browsing the nodes above it, so without this every
  grant narrower than the cluster was authored, shipped and unreachable. It is
  a separate non-inheriting marker, NOT a synthesized Browse grant on each
  ancestor: the additive downward walk would carry such a grant from Plant onto
  Plant/Siemens/dev9 and every other sibling subtree. Pinned by a test that
  goes through the evaluator — probing the trie helper directly passes against
  the broken design, because a sibling is absent from the trie either way.

- Revocation had to reach LIVE subscriptions. Every other operation re-asks per
  request; a subscription is authorized once and then streams, and streaming is
  how machine consumers actually read this server — so stopping only the
  re-asking operations would have made revocation close to cosmetic.
  IDataPlaneAuthoritySource gained a Changed event.

  A denied item is queued BadUserAccessDenied and then DETACHED from its
  MonitoredNode2 fan-out, not set MonitoringMode.Disabled. Disabling discards
  the item's queued notifications, so the client is silenced without ever being
  told why. The over-the-wire test caught this; the unit-level path reports the
  item as stood down either way. Detaching is also reversible, so a restored
  grant resumes the feed with no client action.

- The decision cache holds the grant WALK, not the decision. Grants and the
  trie are fixed for a generation; a key's ExpiresUtc passes with the clock, so
  caching the whole decision would let an expired key keep working until the
  next deploy — the stage-5 open-session hole in a subtler form.

- TranslateBrowsePath is gated, without which browse filtering is decorative: a
  client refused a browse asks TranslateBrowsePathsToNodeIds for the same path.

Found en route: UserAuthorizationState's staleness ceiling couples two clocks.
MembershipResolvedUtc is stamped from DataPlaneAuthorizer's TimeProvider while
IsStale is evaluated against TriePermissionEvaluator's; they are the same
instance in production and diverge the moment anything injects a test clock, at
which point the evaluator denies EVERYTHING with a verdict indistinguishable
from "no grant matched". Stage 5's claim that these predicates cannot fire was
accidentally true; they are now disabled explicitly.

Also fixed: ContinuationPoint.NodeToBrowse is the manager HANDLE, not a
NodeState. The declared type is object, so the wrong cast compiled cleanly and
silently yielded null — skipping the browse source gate entirely.

Falsified: disabling the transit marking reddens exactly the three tests that
assert transit works; implementing transit as an ancestor grant reddens the
sibling-leak test; skipping the fan-out detach reddens the revocation test.
Full solution suite green.

The stage 8 live gate has NOT run yet.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 09:22:12 -04:00
parent 1ccc74005a
commit 00aa486677
16 changed files with 2157 additions and 70 deletions
@@ -0,0 +1,448 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
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.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// #520 stage 6 over the wire — browse filtering, subscription denial, and the one that could not be
/// settled from documentation: whether revoking a grant actually stops a <b>live</b> subscription.
/// </summary>
/// <remarks>
/// <para>
/// These are here rather than in the unit project because the mechanism under test lives in the SDK.
/// <c>RevalidateMonitoredItems</c> re-authorizes each item using
/// <c>IMonitoredItem.EffectiveIdentity</c> — the identity of the session that created it — and then
/// disables the item. Both halves are assumptions about SDK behaviour, and both fail silently in the
/// dangerous direction if wrong: an unrecoverable identity denies every item, which would disable
/// every subscription on the fleet the first time anyone deployed with the flag on.
/// </para>
/// <para>
/// The client connects <b>anonymously</b>, which under #520's D3 is a normal grantable principal
/// (<c>anonymous</c>) rather than a bypass — so these tests exercise the real grant path with no
/// key-minting ceremony.
/// </para>
/// <para><b>Run note:</b> heavy in-process server+client tests; part of the serial integration pass.</para>
/// </remarks>
public sealed class AclSubscriptionEnforcementTests
{
private const string Cluster = "SITE-A";
private const string Device = "Plant/Modbus/dev1";
private const string Tag = "Plant/Modbus/dev1/Speed";
/// <summary>
/// THE test this file exists for: a deployment that removes the grant stops an already-running
/// subscription, and tells the client why.
/// </summary>
/// <remarks>
/// Without it, revocation would close the operations nobody uses (a one-shot read, an occasional
/// write) and leave the streaming feed — which is how ScadaBridge and every other machine consumer
/// actually reads this server — running indefinitely on a grant that no longer exists.
/// </remarks>
[Fact]
public async Task Revoking_a_grant_stops_a_live_subscription()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclRevoke", ct);
try
{
var state = rig.Enforce(Grant(NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe));
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
// Baseline: the feed is live.
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
// The next deployment carries no grants at all.
state.Apply(Cluster, "rev-2", [], [], NoScopes);
// The client is TOLD, rather than watching a feed go quiet for no stated reason.
await WaitUntilAsync(
() => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied),
TimeSpan.FromSeconds(10));
// And the data really has stopped: a fresh value never arrives.
received.Clear();
rig.Write(2);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
received.ShouldNotContain(v => Equals(v.Value, 2f));
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The positive control for the test above, and the one that would catch the failure mode that
/// worries me most: if the identity behind a monitored item could not be recovered, every item would
/// be denied and the fleet's subscriptions would all stop on the first deploy. Here a re-deploy that
/// KEEPS the grant must leave the feed running.
/// </summary>
[Fact]
public async Task A_redeploy_that_keeps_the_grant_leaves_the_subscription_running()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclKeep", ct);
try
{
var grant = Grant(NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe);
var state = rig.Enforce(grant);
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
received.Clear();
state.Apply(Cluster, "rev-2", [], [grant], NoScopes);
rig.Write(2);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 2f)), TimeSpan.FromSeconds(10));
received.ShouldNotContain(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// A grant restored by a later deployment brings the feed back on its own, without the client
/// doing anything.
/// </summary>
/// <remarks>
/// This is why a denied item is detached from its node's fan-out rather than set
/// <c>MonitoringMode.Disabled</c>. A disabled item can only be re-enabled by the client, and a
/// machine consumer has no reason to ever try — so an operator who revoked a grant by mistake and
/// put it straight back would have left the subscription dead until someone restarted the consumer.
/// </remarks>
[Fact]
public async Task Restoring_a_grant_brings_the_subscription_back()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclRestore", ct);
try
{
var grant = Grant(NodePermissions.Read | NodePermissions.Browse | NodePermissions.Subscribe);
var state = rig.Enforce(grant);
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
var (subscription, received) = await rig.SubscribeAsync(session, ct);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 1f)), TimeSpan.FromSeconds(10));
state.Apply(Cluster, "rev-2", [], [], NoScopes);
await WaitUntilAsync(
() => received.Any(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied),
TimeSpan.FromSeconds(10));
// The operator puts the grant back.
received.Clear();
state.Apply(Cluster, "rev-3", [], [grant], NoScopes);
rig.Write(3);
await WaitUntilAsync(() => received.Any(v => Equals(v.Value, 3f)), TimeSpan.FromSeconds(10));
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>A principal with no Subscribe bit cannot create a monitored item at all.</summary>
[Fact]
public async Task Subscribing_without_the_permission_is_refused()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclNoSub", ct);
try
{
// Read and Browse, deliberately NOT Subscribe — so a failure here cannot be "the session was
// denied everything".
rig.Enforce(Grant(NodePermissions.Read | NodePermissions.Browse));
rig.Materialise();
rig.Write(1);
using var session = await rig.ConnectAsync(ct);
// The read works, proving the session is admitted and granted.
var read = await session.ReadValueAsync(rig.TagNodeId(session), ct);
read.StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
var (subscription, _) = await rig.SubscribeAsync(session, ct, expectFailure: true);
subscription.MonitoredItems.First().Status.Error?.StatusCode.Code
.ShouldBe(StatusCodes.BadUserAccessDenied);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Browse returns only what the principal may see: a grant on one device makes that device visible
/// under its parent folder and its sibling invisible.
/// </summary>
/// <remarks>
/// The parent folder itself must remain browsable — that is the ancestor-visibility ("transit")
/// rule. If it were not, the grant on the device below would be unreachable and the operator's rule
/// would silently do nothing.
/// </remarks>
[Fact]
public async Task Browse_returns_only_the_granted_subtree()
{
var ct = TestContext.Current.CancellationToken;
var rig = await Rig.StartAsync("AclBrowse", ct);
try
{
var scoped = new NodeAcl
{
NodeAclId = "acl-dev1",
ClusterId = Cluster,
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.RawDevice,
ScopeId = "dev-1",
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
};
var scopes = new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] =
[new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
};
rig.Enforce(scoped, scopes);
rig.MaterialiseTwoDevices();
using var session = await rig.ConnectAsync(ct);
var ns = rig.RawNamespace(session);
var children = await BrowseChildrenAsync(session, new NodeId("Plant/Modbus", ns), ct);
children.ShouldContain(n => n == "Plant/Modbus/dev1");
children.ShouldNotContain(n => n == "Plant/Modbus/dev9");
}
finally { await rig.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private static readonly IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> NoScopes =
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>();
private static NodeAcl Grant(NodePermissions flags) => new()
{
NodeAclId = "acl-cluster",
ClusterId = Cluster,
Principal = OpcUaPrincipals.Anonymous,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
private static async Task<List<string>> BrowseChildrenAsync(
ISession session, NodeId parent, CancellationToken ct)
{
var response = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse:
[
new BrowseDescription
{
NodeId = parent,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All,
},
],
ct: ct);
return response.Results[0].References
.Select(r => r.NodeId.Identifier as string ?? string.Empty)
.ToList();
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
/// <summary>A booted server plus the ACL wiring, so each test states only what it is actually about.</summary>
private sealed class Rig : IAsyncDisposable
{
private readonly string _pkiRoot;
private OpcUaApplicationHost _host = null!;
private OtOpcUaSdkServer _server = null!;
private int _port;
private Rig(string pkiRoot) => _pkiRoot = pkiRoot;
private OtOpcUaNodeManager Nm => _server.NodeManager!;
public static async Task<Rig> StartAsync(string name, CancellationToken ct)
{
var rig = new Rig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
rig._port = AllocateFreePort();
rig._server = new OtOpcUaSdkServer();
rig._host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
OpcUaPort = rig._port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance);
await rig._host.StartAsync(rig._server, ct);
return rig;
}
/// <summary>Switches enforcement on with one grant, returning the state so a test can redeploy.</summary>
public DataPlaneAuthorityState Enforce(
NodeAcl grant, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopes = null)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [], [grant], scopes ?? NoScopes);
// BOTH must be set: Authorizer decides, and DataPlaneAuthority is what carries the Changed
// signal that reaches live subscriptions. Wiring only the first passes every test that makes a
// request and none that revokes.
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
return state;
}
public void Materialise()
{
Nm.EnsureFolder("Plant", null, "Plant", AddressSpaceRealm.Raw);
Nm.EnsureFolder("Plant/Modbus", "Plant", "Modbus", AddressSpaceRealm.Raw);
Nm.EnsureFolder(Device, "Plant/Modbus", "dev1", AddressSpaceRealm.Raw);
Nm.EnsureVariable(Tag, Device, "Speed", "Float", writable: false, AddressSpaceRealm.Raw);
}
public void MaterialiseTwoDevices()
{
Materialise();
Nm.EnsureFolder("Plant/Modbus/dev9", "Plant/Modbus", "dev9", AddressSpaceRealm.Raw);
Nm.EnsureVariable(
"Plant/Modbus/dev9/Torque", "Plant/Modbus/dev9", "Torque", "Float",
writable: false, AddressSpaceRealm.Raw);
}
public void Write(float value) =>
Nm.WriteValue(Tag, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
public ushort RawNamespace(ISession session)
{
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
ns.ShouldBeGreaterThan((ushort)0);
return ns;
}
public NodeId TagNodeId(ISession session) => new(Tag, RawNamespace(session));
public async Task<(Subscription Subscription, List<DataValue> Received)> SubscribeAsync(
ISession session, CancellationToken ct, bool expectFailure = false)
{
var received = new List<DataValue>();
var gate = new object();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = TagNodeId(session),
AttributeId = Attributes.Value,
SamplingInterval = 50,
QueueSize = 10,
};
item.Notification += (mi, _) =>
{
foreach (var v in mi.DequeueValues()) lock (gate) received.Add(v);
};
subscription.AddItem(item);
await subscription.ApplyChangesAsync(ct);
if (!expectFailure) item.Status.Error.ShouldBeNull();
// The list is only ever appended under `gate` and read from the test thread between awaits;
// a snapshot-on-read wrapper would hide ordering rather than help.
return (subscription, received);
}
public async Task<ISession> ConnectAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.AclClient",
ApplicationUri = $"urn:OtOpcUa.AclClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, $"opc.tcp://127.0.0.1:{_port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)),
updateBeforeConnect: false,
checkDomain: false,
sessionName: "AclSubscriptionEnforcementTests",
sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: null,
ct: ct);
}
public async ValueTask DisposeAsync()
{
await _host.DisposeAsync();
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
}
@@ -0,0 +1,345 @@
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;
/// <summary>
/// #520 stage 6 — the Read seam, through a REAL booted <see cref="OtOpcUaSdkServer"/> and the SDK's own
/// dispatch, plus the cache properties that only show up at a request-rate seam.
/// </summary>
/// <remarks>
/// Browse filtering, subscription denial and revocation-reaching-a-live-subscription are proven over
/// the wire in <c>AclSubscriptionEnforcementTests</c> in the integration project instead — they need a
/// real client session, and the parts of them most likely to be wrong (whether a monitored item can
/// even recover the identity that created it) cannot be observed without one.
/// </remarks>
public sealed class NodeManagerStage6EnforcementTests : IDisposable
{
private const string Cluster = "SITE-A";
private const string Principal = "scadabridge";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(), $"otopcua-acl6-{Guid.NewGuid():N}");
/// <summary>
/// With enforcement off, Read is exactly what it always was. This is the flag's promise and the
/// assertion that catches an accidental enforce-by-default.
/// </summary>
[Fact]
public async Task Read_is_ungated_while_enforcement_is_off()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.Authorizer = Authorizer(enabled: false, GrantOn(NodePermissions.None));
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
var (values, errors) = Read(nm, identity: null, nodeId);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// With enforcement on, a session whose grant lacks the Read bit is refused — the check Read never
/// had. The identity is real and the grant non-empty, so this cannot pass on the null-identity
/// branch alone.
/// </summary>
[Fact]
public async Task Read_is_denied_without_the_permission()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Browse));
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
var (values, errors) = Read(nm, Identity(), nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
values[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>The positive control: a granted principal reads normally, so the gate is not denying all.</summary>
[Fact]
public async Task Read_succeeds_for_a_granted_principal()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
var (values, errors) = Read(nm, Identity(), nodeId);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// Every attribute is gated, not just Value. Allowing an ungranted session to read BrowseName,
/// DataType and Description would disclose most of what the address space actually reveals.
/// </summary>
[Fact]
public async Task A_denied_node_discloses_no_attribute()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Browse));
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
var (values, _) = Read(
nm, Identity(),
(nodeId, Attributes.Value),
(nodeId, Attributes.BrowseName),
(nodeId, Attributes.DataType),
(nodeId, Attributes.Description));
values.ShouldAllBe(v => v.StatusCode.Code == StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A mixed batch is decided per node — the granted node answers and the ungranted one is denied in
/// the same request, rather than the whole read failing.
/// </summary>
[Fact]
public async Task A_mixed_read_batch_denies_only_the_ungranted_node()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var granted = Variable(nm, "Plant/Modbus/dev1/Speed");
var ungranted = Variable(nm, "Plant/Siemens/dev9/Torque");
nm.Authorizer = Authorizer(
enabled: true,
ScopedGrant(NodePermissions.Read, "dev-1"),
Scopes(("dev-1", ["Plant", "Modbus", "dev1"])));
var (_, errors) = Read(nm, Identity(), granted, ungranted);
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// ⚠️ The cache must not memoize the key check. Grants are fixed for a generation, so caching the
/// grant walk is sound; a key's expiry passes with the CLOCK, so a cached allow would keep an
/// expired key reading until the next deployment.
/// </summary>
/// <remarks>
/// The first read is what makes this test meaningful: it populates the cache with an <b>allow</b>,
/// so the second read is answered by a code path that has a cached "yes" available and must refuse
/// to use it. Without the priming read, a build that cached the whole decision would still pass.
/// </remarks>
[Fact]
public async Task An_expired_key_is_denied_even_though_its_allow_is_cached()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc));
var state = new DataPlaneAuthorityState();
var key = new ApiKey
{
ApiKeyId = "k1",
Name = "k1",
SecretHash = "hash",
Principal = Principal,
Enabled = true,
CreatedUtc = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc),
ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc),
};
state.Apply(Cluster, "rev-1", [key], [GrantOn(NodePermissions.Read)], Scopes());
nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock);
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
var identity = new RoleCarryingUserIdentity(
new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: "k1");
// Prime the cache with an allow.
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
nm.Authorizer.Decisions.Count.ShouldBeGreaterThan(0, "the allow must actually be cached");
clock.Advance(TimeSpan.FromHours(2));
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
/// <summary>
/// A deployment that removes a grant reaches an already-open session's reads, because the new
/// generation invalidates the cached allow rather than the session carrying its old verdict.
/// </summary>
[Fact]
public async Task A_revoked_grant_reaches_an_open_sessions_reads()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [], [GrantOn(NodePermissions.Read)], Scopes());
nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code
.ShouldNotBe(StatusCodes.BadUserAccessDenied);
// The next deployment carries no grants at all.
state.Apply(Cluster, "rev-2", [], [], Scopes());
Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
}
finally { await host.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private static NodeId Variable(OtOpcUaNodeManager nm, string rawPath)
{
nm.EnsureVariable(rawPath, parentFolderNodeId: null, displayName: rawPath, dataType: "Float",
writable: false, historianTagname: null, realm: AddressSpaceRealm.Raw);
return nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
}
private static (List<DataValue> Values, List<ServiceResult> Errors) Read(
OtOpcUaNodeManager nm, IUserIdentity? identity, params NodeId[] nodeIds) =>
Read(nm, identity, nodeIds.Select(id => (id, Attributes.Value)).ToArray());
private static (List<DataValue> Values, List<ServiceResult> Errors) Read(
OtOpcUaNodeManager nm, IUserIdentity? identity, params (NodeId Node, uint Attribute)[] reads)
{
var context = new OperationContext(
new RequestHeader(), secureChannelContext: null, RequestType.Read, identity);
var nodesToRead = reads
.Select(r => new ReadValueId { NodeId = r.Node, AttributeId = r.Attribute })
.ToList();
var values = nodesToRead.Select(_ => new DataValue()).ToList();
var errors = Enumerable.Repeat<ServiceResult>(null!, nodesToRead.Count).ToList();
nm.Read(context, 0, nodesToRead, values, errors);
return (values, errors);
}
private static DataPlaneAuthorizer Authorizer(
bool enabled,
NodeAcl grant,
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>? scopePaths = null)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", [], [grant], scopePaths ?? Scopes());
return new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance);
}
private static IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> Scopes(
params (string ScopeId, string[] Segments)[] entries) =>
entries.ToDictionary(
e => new AclScopeRef(NodeAclScopeKind.RawDevice, e.ScopeId),
e => (IReadOnlyList<NodeAclPath>)new[] { new NodeAclPath([NodeAclRealms.Raw, .. e.Segments]) });
private static NodeAcl GrantOn(NodePermissions flags) => new()
{
NodeAclId = "acl-cluster",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.Cluster,
PermissionFlags = flags,
};
private static NodeAcl ScopedGrant(NodePermissions flags, string scopeId) => new()
{
NodeAclId = "acl-device",
ClusterId = Cluster,
Principal = Principal,
ScopeKind = NodeAclScopeKind.RawDevice,
ScopeId = scopeId,
PermissionFlags = flags,
};
private static RoleCarryingUserIdentity Identity() =>
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Acl6Test",
ApplicationUri = $"urn:OtOpcUa.Acl6Test:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private sealed class FakeTimeProvider(DateTime utcNow) : TimeProvider
{
private DateTimeOffset _now = new(utcNow, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch (IOException) { /* best effort */ }
}
}
}
@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// #520 stage 6 — the decision cache behind the request-rate seams.
/// </summary>
/// <remarks>
/// The risk in a cache like this is not that it is slow; it is that it answers a question slightly
/// different from the one asked. Each test below pins one dimension the key must discriminate on,
/// because a key that collides across any of them hands one session another's authority.
/// </remarks>
public sealed class SessionAclDecisionCacheTests
{
private const string Session = "key-1";
private const string Node = "Plant/Modbus/dev1/Speed";
/// <summary>The second identical question is answered from the cache.</summary>
[Fact]
public void A_repeated_question_is_not_recomputed()
{
var cache = new SessionAclDecisionCache();
var calls = 0;
for (var i = 0; i < 5; i++)
Ask(cache, generation: 1, compute: () => { calls++; return true; }).ShouldBeTrue();
calls.ShouldBe(1);
}
/// <summary>
/// A new generation drops everything. This is the invalidation the whole design rests on: grants
/// only change by deployment, and a deployment always advances the generation.
/// </summary>
[Fact]
public void A_new_generation_invalidates_every_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, compute: () => true).ShouldBeTrue();
cache.Count.ShouldBe(1);
// The same question, one deployment later, must be re-decided — and the new answer must win.
Ask(cache, generation: 2, compute: () => false).ShouldBeFalse();
cache.Generation.ShouldBe(2);
}
/// <summary>
/// Two sessions asking about the same node get their own answers. A key that omitted the session
/// would let the first caller's verdict decide for everyone.
/// </summary>
[Fact]
public void Two_sessions_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, session: "granted-key", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, session: "ungranted-key", compute: () => false).ShouldBeFalse();
}
/// <summary>Read and Write on one node are separate questions.</summary>
[Fact]
public void Two_operations_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, operation: OpcUaOperation.Read, compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, operation: OpcUaOperation.WriteOperate, compute: () => false).ShouldBeFalse();
}
/// <summary>
/// The two realms are separate namespaces that can carry the SAME <c>s=</c> identifier, so the realm
/// is part of the key. Without it, a raw grant would answer for the UNS node of the same path —
/// which is exactly the cross-realm confusion the trie itself is careful to avoid.
/// </summary>
[Fact]
public void The_two_realms_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, realm: AddressSpaceRealm.Raw, compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, realm: AddressSpaceRealm.Uns, compute: () => false).ShouldBeFalse();
}
/// <summary>Different nodes are different questions.</summary>
[Fact]
public void Two_nodes_do_not_share_a_decision()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, node: "Plant/Modbus/dev1/Speed", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, node: "Plant/Modbus/dev1/Torque", compute: () => false).ShouldBeFalse();
}
/// <summary>
/// Node identifiers are path segments, so they compare ordinally — <c>Speed</c> and <c>speed</c> can
/// both exist under one device and must not collide in the cache when they do not collide in the
/// trie.
/// </summary>
[Fact]
public void Node_identifiers_are_case_sensitive()
{
var cache = new SessionAclDecisionCache();
Ask(cache, generation: 1, node: "Plant/dev1/Speed", compute: () => true).ShouldBeTrue();
Ask(cache, generation: 1, node: "Plant/dev1/speed", compute: () => false).ShouldBeFalse();
}
/// <summary>
/// The cache is bounded. A client walking a huge address space must not be able to grow it without
/// limit; clearing at the ceiling degrades to recomputation, which is the no-cache steady state.
/// </summary>
[Fact]
public void The_cache_is_bounded()
{
var cache = new SessionAclDecisionCache();
for (var i = 0; i <= SessionAclDecisionCache.MaxEntries + 10; i++)
Ask(cache, generation: 1, node: $"Plant/dev1/Tag{i}", compute: () => true);
cache.Count.ShouldBeLessThanOrEqualTo(SessionAclDecisionCache.MaxEntries);
}
/// <summary>
/// A decision taken while a deployment lands mid-walk is returned but NOT stored, so the new
/// generation never inherits a verdict its own trie did not make.
/// </summary>
[Fact]
public void A_decision_overtaken_by_a_deploy_is_not_stored()
{
var cache = new SessionAclDecisionCache();
// The compute callback simulates the deploy landing while the walk is in flight.
var decided = cache.GetOrAdd(
generation: 1, Session, OpcUaOperation.Read, AddressSpaceRealm.Raw, Node,
() =>
{
Ask(cache, generation: 2, node: "unrelated", compute: () => true);
return true;
});
decided.ShouldBeTrue("the caller still gets the answer its own walk produced");
// Nothing was filed under the superseded generation — the only entry is the unrelated one the
// callback itself made.
cache.Generation.ShouldBe(2);
cache.Count.ShouldBe(1);
}
private static bool Ask(
SessionAclDecisionCache cache,
long generation,
Func<bool> compute,
string session = Session,
OpcUaOperation operation = OpcUaOperation.Read,
AddressSpaceRealm realm = AddressSpaceRealm.Raw,
string node = Node)
=> cache.GetOrAdd(generation, session, operation, realm, node, compute);
}