fix(acl): revalidation memo rejects empty node identifiers, mirroring the authorizer's guard
This commit is contained in:
@@ -3298,9 +3298,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// identifier spaces, so an identifier alone does not name a node.
|
/// identifier spaces, so an identifier alone does not name a node.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Anything the key cannot be built from — no role-carrying identity, no principal, a non-string
|
/// Anything the key cannot be built from — no role-carrying identity, no principal, a node
|
||||||
/// node identifier — falls through to the un-memoized call and keeps its existing verdict. Those
|
/// identifier that is not a non-empty string — falls through to the un-memoized call and keeps
|
||||||
/// are the cases the authorizer denies anyway; memoizing them would only make a deny cheaper.
|
/// its existing verdict. Those are the cases the authorizer denies anyway; memoizing them would
|
||||||
|
/// only make a deny cheaper. The empty identifier is excluded for the same reason
|
||||||
|
/// <see cref="Security.DataPlaneAuthorizer"/> excludes it from its own decision cache: it
|
||||||
|
/// addresses no ACL scope, so it is not an identity to key on.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
|
/// This memo does cover the key expiry check that the authorizer's own cache excludes, but only
|
||||||
@@ -3318,7 +3321,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
|
|
||||||
if (identity is null
|
if (identity is null
|
||||||
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
|
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
|
||||||
|| nodeId.Identifier is not string identifier)
|
|| nodeId.Identifier is not string { Length: > 0 } identifier)
|
||||||
{
|
{
|
||||||
return _authorizer!.IsAllowedCached(
|
return _authorizer!.IsAllowedCached(
|
||||||
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
|
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
|
||||||
|
|||||||
+65
-2
@@ -519,6 +519,62 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
|||||||
finally { await host.DisposeAsync(); }
|
finally { await host.DisposeAsync(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An EMPTY <c>s=</c> identifier names no node, so it must not be memoized: every item on such a
|
||||||
|
/// node asks the authorizer for itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// An empty identifier addresses no ACL scope at all —
|
||||||
|
/// <see cref="Security.DataPlaneAuthorizer"/> rejects it before its own decision cache for
|
||||||
|
/// exactly that reason — so collapsing every item on one onto a single pass-local verdict is
|
||||||
|
/// keying the memo on something that is not an identity. The guard mirrors the authorizer's:
|
||||||
|
/// an empty identifier falls through to the un-memoized call, which is fail-safe (each item
|
||||||
|
/// gets its own verdict) rather than merely cheaper.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Two items on DISTINCT empty-identifier nodes cannot be constructed — an empty identifier in
|
||||||
|
/// one realm is one node id — so what is pinned here is the bypass itself: the authorizer is
|
||||||
|
/// asked once per item, where the memoized path asks once per pass.
|
||||||
|
/// <see cref="Revalidation_evaluates_one_verdict_per_session_and_node"/> is the control that
|
||||||
|
/// the memo still engages for a non-empty identifier, so this cannot pass by the memo having
|
||||||
|
/// been removed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task Revalidation_does_not_memoize_an_empty_node_identifier()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var clock = new CountingTimeProvider(new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc));
|
||||||
|
nm.Authorizer = SubscribeAuthorizer(clock);
|
||||||
|
|
||||||
|
// The manager's raw namespace index, borrowed from a real node, with an empty identifier.
|
||||||
|
var rawNamespace = Variable(nm, "Plant/Modbus/dev1/Speed").NamespaceIndex;
|
||||||
|
var emptyId = new NodeId(string.Empty, rawNamespace);
|
||||||
|
|
||||||
|
// Non-vacuity: this really is a STRING identifier of length zero, the one case the guard used
|
||||||
|
// to let through — not a null identifier the guard already rejected.
|
||||||
|
emptyId.Identifier.ShouldBeOfType<string>().ShouldBeEmpty();
|
||||||
|
|
||||||
|
var identity = Identity();
|
||||||
|
var items = Enumerable.Range(0, 2)
|
||||||
|
.Select(_ => TrackItem(nm, new NodeHandle { NodeId = emptyId }, identity))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
clock.Reset();
|
||||||
|
nm.RevalidateMonitoredItems();
|
||||||
|
|
||||||
|
clock.Calls.ShouldBe(2, "an empty identifier is not a memo key, so each item is asked for itself");
|
||||||
|
|
||||||
|
// And the un-memoized verdict is the authorizer's own deny, reaching every item.
|
||||||
|
items.ShouldAllBe(i => i.Denials == 1);
|
||||||
|
}
|
||||||
|
finally { await host.DisposeAsync(); }
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────── helpers ─────────────────────────────
|
// ───────────────────────────── helpers ─────────────────────────────
|
||||||
|
|
||||||
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
|
/// <summary>A cluster-wide Subscribe grant — what <c>CreateMonitoredItems</c> resolves to.</summary>
|
||||||
@@ -531,9 +587,16 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Registers a stub data-change item against <paramref name="nodeId"/> as if the SDK had created it.</summary>
|
/// <summary>Registers a stub data-change item against <paramref name="nodeId"/> as if the SDK had created it.</summary>
|
||||||
private static StubSampledItem TrackItem(OtOpcUaNodeManager nm, NodeId nodeId, IUserIdentity identity)
|
private static StubSampledItem TrackItem(OtOpcUaNodeManager nm, NodeId nodeId, IUserIdentity identity) =>
|
||||||
|
TrackItem(nm, (NodeHandle)nm.GetManagerHandle(nodeId), identity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a stub data-change item against a hand-built <paramref name="handle"/> — the only way
|
||||||
|
/// to reach a node id the address space cannot mint, such as an empty <c>s=</c> identifier.
|
||||||
|
/// </summary>
|
||||||
|
private static StubSampledItem TrackItem(
|
||||||
|
OtOpcUaNodeManager nm, NodeHandle handle, IUserIdentity identity)
|
||||||
{
|
{
|
||||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
|
||||||
var item = new StubSampledItem(handle, identity);
|
var item = new StubSampledItem(handle, identity);
|
||||||
nm.TrackLiveMonitoredItem(handle, item);
|
nm.TrackLiveMonitoredItem(handle, item);
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
Reference in New Issue
Block a user