00aa486677
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
164 lines
6.4 KiB
C#
164 lines
6.4 KiB
C#
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);
|
|
}
|