using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; 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; /// /// #520 stage 5 — the node-scoped ACL gate. Every test drives the REAL /// → → /// chain rather than a fake evaluator, because the defect class /// this stage exists to close is precisely "the pieces are individually correct and nothing connects /// them". A test that stubs the evaluator would pass against a gate wired to an empty trie. /// public sealed class DataPlaneAuthorizerTests { private const string Cluster = "SITE-A"; private const string Principal = "scadabridge"; private const string KeyId = "a1b2c3d4e5f60011"; // ───────────────────────────── the fail-closed edges ───────────────────────────── /// /// A session with no role-carrying identity is denied. There is no "unauthenticated means /// unrestricted" path: an anonymous session that the host admitted still arrives here as a /// principal, so a null identity means something went wrong upstream. /// [Fact] public void A_session_with_no_identity_is_denied() { var gate = Gate(ClusterGrant(NodePermissions.WriteOperate)); Veto(gate.Authorize(null, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)); } /// /// An identity carrying no principal is denied even when a grant would otherwise match. This is the /// legacy two-argument constructor, and the safe reading of it /// is "unknown", not "infer one from the display name" — the display name for every key-bearing /// client is the same static username. /// [Fact] public void An_identity_with_no_principal_is_denied() { var gate = Gate(ClusterGrant(NodePermissions.WriteOperate)); var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" }, []); identity.Principal.ShouldBeEmpty(); Veto(gate.Authorize(identity, OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)); } /// /// A node that resolved no cluster identity has no trie to evaluate against, so it denies. Inventing /// a placeholder cluster would be the dangerous alternative — it would apply some other cluster's /// grants to a node whose own cluster is unknown. /// [Fact] public void A_node_with_no_cluster_identity_denies_everything() { var state = new DataPlaneAuthorityState(); state.Apply(clusterId: null, "rev", [], [ClusterGrant(NodePermissions.WriteOperate)], Scopes()); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)); } /// /// A NodeId whose identifier is not a string addresses no ACL scope — every node this server mints /// carries a string path — so it is denied rather than evaluated against an empty segment list, /// which would match a realm-wide grant. /// [Fact] public void A_node_id_with_no_string_identifier_is_denied() { var gate = Gate(ClusterGrant(NodePermissions.WriteOperate)); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, new NodeId(42u, 2), AddressSpaceRealm.Raw)); } // ───────────────────────────── the grant actually decides ───────────────────────────── /// A cluster-wide WriteOperate grant authorizes a write anywhere in that cluster. [Fact] public void A_cluster_grant_authorizes_a_write() { var gate = Gate(ClusterGrant(NodePermissions.WriteOperate)); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw) .ShouldBeNull(); } /// /// The permission bit is honoured, not merely the presence of a grant: a read-only principal is /// refused a write on the very node it may read. /// [Fact] public void A_read_grant_does_not_authorize_a_write() { var gate = Gate(ClusterGrant(NodePermissions.Read)); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw)); } /// /// The heart of the stage. A grant scoped to one device authorizes a tag beneath it and /// refuses a tag under a sibling device. Before this, a narrow grant conferred nothing at all — it /// was inert — so both of these would have been denied, and a test asserting only the denial would /// have passed against the broken build. /// [Fact] public void A_raw_device_scoped_grant_covers_its_own_tags_and_not_a_siblings() { var grant = new NodeAcl { NodeAclId = "acl-dev1", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.RawDevice, ScopeId = "dev-1", PermissionFlags = NodePermissions.WriteOperate, }; var gate = Gate(grant, Scopes(("dev-1", NodeAclScopeKind.RawDevice, ["raw", "Plant", "Modbus", "dev1"]))); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw) .ShouldBeNull(); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev2/Speed"), AddressSpaceRealm.Raw)); } /// /// Segment matching is case-SENSITIVE. Raw sibling names are ordinal-unique, so Speed and /// speed can both exist under one device; folding case would let a grant on either silently /// cover both. /// [Fact] public void Scope_segments_are_matched_case_sensitively() { var grant = new NodeAcl { NodeAclId = "acl-tag", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Tag, ScopeId = "tag-1", PermissionFlags = NodePermissions.WriteOperate, }; var gate = Gate(grant, Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"]))); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw) .ShouldBeNull(); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Plant/Modbus/dev1/speed"), AddressSpaceRealm.Raw)); } /// /// The realm is a hard gate at level 1 of the walk: a grant on the raw tree does not reach a UNS /// node that happens to share a path. Blocker #1 is that raw nodes were unrepresentable at all, so /// this pins that both realms now evaluate — and that they stay distinct. /// [Fact] public void A_raw_grant_does_not_reach_a_uns_node_with_the_same_path() { var grant = new NodeAcl { NodeAclId = "acl-raw-realm", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Namespace, ScopeId = NodeAclRealms.Raw, PermissionFlags = NodePermissions.WriteOperate, }; var gate = Gate(grant, Scopes((NodeAclRealms.Raw, NodeAclScopeKind.Namespace, ["raw"]))); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Raw) .ShouldBeNull(); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, RawNode("Area/Line/Eq/Speed"), AddressSpaceRealm.Uns)); } /// /// The Part 9 methods map onto distinct permissions, which the single AlarmAck role they /// replace could not express: a principal granted acknowledge alone cannot shelve. /// [Fact] public void Alarm_acknowledge_and_shelve_are_separately_grantable() { var gate = Gate(ClusterGrant(NodePermissions.AlarmAcknowledge)); var node = RawNode("Plant/Modbus/dev1/Speed"); gate.Authorize(Identity(), OpcUaOperation.AlarmAcknowledge, node, AddressSpaceRealm.Raw).ShouldBeNull(); Veto(gate.Authorize(Identity(), OpcUaOperation.AlarmShelve, node, AddressSpaceRealm.Raw)); } /// /// HistoryRead has its own bit — a Read grant is not sufficient. This is the OPC UA Part 11 /// separation, and it is the first time HistoryRead has been gated at all. /// [Fact] public void History_read_needs_its_own_permission_not_merely_read() { var node = RawNode("Plant/Modbus/dev1/Speed"); Veto(Gate(ClusterGrant(NodePermissions.Read)) .Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw)); Gate(ClusterGrant(NodePermissions.HistoryRead)) .Authorize(Identity(), OpcUaOperation.HistoryRead, node, AddressSpaceRealm.Raw) .ShouldBeNull(); } // ───────────────────────────── revocation reaches an OPEN session ───────────────────────────── /// /// A deployment that disables a key stops that key's ALREADY-OPEN session, not just future ones. /// Without the per-call re-check a revoked credential keeps its authority until the client happens /// to reconnect, which for a machine-to-machine consumer can be indefinitely — and on a driver-only /// node the artifact is the only revocation channel there is. /// [Fact] public void Revoking_a_key_denies_its_open_session_on_the_next_deployment() { var state = new DataPlaneAuthorityState(); var grant = ClusterGrant(NodePermissions.WriteOperate); var node = RawNode("Plant/Modbus/dev1/Speed"); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes()); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull(); state.Apply(Cluster, "rev-2", [Key(enabled: false)], [grant], Scopes()); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw)); } /// /// An expired key is refused on the same path, and expiry is read against the injected clock rather /// than wall time so the test pins the comparison rather than waiting for it. /// [Fact] public void An_expired_key_denies_its_open_session() { var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc)); var state = new DataPlaneAuthorityState(); var key = Key(enabled: true); key.ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); state.Apply(Cluster, "rev-1", [key], [ClusterGrant(NodePermissions.WriteOperate)], Scopes()); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock); var node = RawNode("Plant/Modbus/dev1/Speed"); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull(); clock.Advance(TimeSpan.FromHours(2)); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw)); } /// /// Anonymous carries no key id, so the revocation probe must not fire for it — otherwise every /// anonymous request would be denied by a lookup for a key that was never presented. /// [Fact] public void An_anonymous_session_is_not_denied_by_the_key_revocation_check() { var grant = ClusterGrant(NodePermissions.Read); grant.Principal = OpcUaPrincipals.Anonymous; var gate = Gate(grant); var anonymous = new RoleCarryingUserIdentity( new AnonymousIdentityToken(), [], OpcUaPrincipals.Anonymous, apiKeyId: null); gate.Authorize(anonymous, OpcUaOperation.Read, RawNode("Plant/Modbus/dev1/Speed"), AddressSpaceRealm.Raw) .ShouldBeNull(); } // ───────────────────────────── the generation stamp ───────────────────────────── /// /// A deployment that adds a grant takes effect on an open session immediately — the session is /// stamped with the current generation on every call, so it never pins itself to the generation it /// activated under and then has to be bounced to pick an ACL change up. /// [Fact] public void A_grant_added_by_a_later_deployment_reaches_an_open_session() { var state = new DataPlaneAuthorityState(); var node = RawNode("Plant/Modbus/dev1/Speed"); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [], Scopes()); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw)); state.Apply(Cluster, "rev-2", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes()); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull(); } /// /// And the reverse: revoking a GRANT (rather than the key) also lands on the open session. Both /// directions matter — an ACL edit that only widens would be the easy half. /// [Fact] public void A_grant_removed_by_a_later_deployment_stops_an_open_session() { var state = new DataPlaneAuthorityState(); var node = RawNode("Plant/Modbus/dev1/Speed"); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [ClusterGrant(NodePermissions.WriteOperate)], Scopes()); gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw).ShouldBeNull(); state.Apply(Cluster, "rev-2", [Key(enabled: true)], [], Scopes()); Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw)); } // ───────────────────────────── the cached hot path ───────────────────────────── /// /// A second identical question is answered from the decision cache without re-walking the grants — /// which on the reordered hot path also means without rebuilding the the /// walk would need, because the lookup happens before the scope is built at all. /// /// /// The evaluator count is the observable half: a hit that still built a scope would have nothing to /// do with it, so the only way to see the short-circuit from outside is that the walk never ran. The /// cache size pins that the second call was a hit rather than a second miss that happened to /// overwrite the first entry. /// [Fact] public void A_cache_hit_does_not_rebuild_the_scope() { var gate = CountingGate(ClusterGrant(NodePermissions.Read), out var evaluator); var node = RawNode("Plant/Modbus/dev1/Speed"); gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull(); gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull(); evaluator.Calls.ShouldBe(1); gate.Decisions.Count.ShouldBe(1); } /// /// Pins the ordering the hot path must keep: the key's usability is re-checked BEFORE the /// decision cache is consulted, so a cached allow cannot outlive the key that earned it. /// /// /// Expiry rather than a disabling redeploy is the falsifiable form of this. A redeploy advances the /// generation, which drops the cache wholesale — so a gate that consulted the cache first would /// still deny, and the test would pass against the broken ordering. Expiry moves with the CLOCK and /// leaves the generation (and therefore the memoized allow) exactly where it was, so the denial can /// only have come from the key check running ahead of the lookup. /// [Fact] public void Key_revocation_is_rechecked_on_a_cache_hit() { var clock = new FakeTimeProvider(new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc)); var state = new DataPlaneAuthorityState(); var key = Key(enabled: true); key.ExpiresUtc = new DateTime(2026, 8, 1, 13, 0, 0, DateTimeKind.Utc); state.Apply(Cluster, "rev-1", [key], [ClusterGrant(NodePermissions.Read)], Scopes()); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance, clock); var node = RawNode("Plant/Modbus/dev1/Speed"); gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw).ShouldBeNull(); gate.Decisions.Count.ShouldBe(1, "the allow was memoized, so the next call takes the hit path"); clock.Advance(TimeSpan.FromHours(2)); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Read, node, AddressSpaceRealm.Raw)); gate.Decisions.Generation.ShouldBe(state.Current.GenerationId); gate.Decisions.Count.ShouldBe(1, "the memoized allow is untouched — the denial came from the key check"); } /// The flag is carried faithfully; the seams read it to decide whether to consult the gate at all. [Theory] [InlineData(true)] [InlineData(false)] public void The_enabled_flag_is_reported_as_configured(bool enabled) { var state = new DataPlaneAuthorityState(); new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance) .Enabled.ShouldBe(enabled); } // ───────────────────────────── the shared root folder ───────────────────────────── /// /// The load-bearing allow. Both realm trees hang under one anchor folder /// (s=OtOpcUa, raw namespace), and the node manager's Browse gate refuses the WHOLE browse /// when its source node is denied — so a session whose only grant is a narrow Browse deep in the raw /// tree could not get past the front door at all. The anchor's identifier is a display name rather /// than a path, so the ordinary split produced a one-segment scope (["OtOpcUa"]) that no /// grant path can ever contain. /// [Fact] public void The_shared_root_folder_is_browsable_on_a_narrow_raw_browse_grant() { var gate = Gate( ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1"), Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"]))); gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw) .ShouldBeNull(); } /// /// The anchor fronts BOTH trees, so a principal whose only Browse grant lives in the UNS tree still /// enters through this raw-namespace node — the gate must consider transit in either realm. /// /// /// This is the test that falsifies the second walk: delete the UNS OR-leg from /// DataPlaneAuthorizer.Evaluate and only this fact goes red, because the first walk asks the /// raw realm root, which a purely-UNS grant never marks. /// [Fact] public void The_shared_root_folder_is_browsable_on_a_uns_only_browse_grant() { var gate = Gate( ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Equipment, "eq-1"), Scopes(("eq-1", NodeAclScopeKind.Equipment, ["uns", "Area", "Line", "Eq"]))); gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw) .ShouldBeNull(); } /// /// The anchor is not a bypass: a principal holding no grant at all still cannot browse it, so the /// mapping opens the front door only for a session that has somewhere to go. /// [Fact] public void The_shared_root_folder_is_denied_without_any_grant() { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [], Scopes()); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)); } /// /// Transit is marked only by grants that actually carry Browse. A deep Read-only grant leaves /// the anchor closed — the node is readable by NodeId, not discoverable by navigating. /// [Fact] public void The_shared_root_folder_is_denied_when_the_deep_grant_lacks_browse() { var gate = Gate( ScopedGrant(NodePermissions.Read, NodeAclScopeKind.Tag, "tag-1"), Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"]))); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)); } /// /// Transit confers navigation and nothing else, at the anchor exactly as at every other ancestor: a /// deep Browse grant does not make the root folder readable. /// [Fact] public void The_shared_root_folder_is_not_readable_on_transit_alone() { var gate = Gate( ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1"), Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"]))); gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw).ShouldBeNull(); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Read, Anchor(), AddressSpaceRealm.Raw)); } /// /// Guard order is unchanged by the anchor branch: the key's usability is re-checked before any walk /// runs, so a revoked key is refused the anchor even though its grant would confer transit. /// [Fact] public void A_revoked_key_is_denied_the_shared_root_folder() { var state = new DataPlaneAuthorityState(); var grant = ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Tag, "tag-1"); var scopes = Scopes(("tag-1", NodeAclScopeKind.Tag, ["raw", "Plant", "Modbus", "dev1", "Speed"])); var gate = new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], scopes); gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw).ShouldBeNull(); state.Apply(Cluster, "rev-2", [Key(enabled: false)], [grant], scopes); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw)); } /// /// Anchor semantics are realm-qualified. In the UNS namespace s=OtOpcUa is an ordinary Area /// that an operator happened to name after the root folder, and it must be evaluated as the path it /// is — a Browse grant in an unrelated UNS subtree must not open it. /// /// /// The discriminating grant is a UNS one deliberately. Un-qualifying /// IsSharedRootFolder maps this node onto the zero-segment UNS scope, which the unrelated /// grant's own transit marks — so the Area silently becomes browsable for a principal granted /// nothing anywhere near it. A raw-only grant would have been refused either way and would have /// proven nothing. /// [Fact] public void A_uns_area_named_like_the_anchor_gets_no_anchor_semantics() { var unsArea = new NodeId(V3NodeIds.SharedRootFolderId, 3); var gate = Gate( ScopedGrant(NodePermissions.Browse, NodeAclScopeKind.Equipment, "eq-1"), Scopes(("eq-1", NodeAclScopeKind.Equipment, ["uns", "Area", "Line", "Eq"]))); Veto(gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, unsArea, AddressSpaceRealm.Uns)); // Non-vacuity, twice over: the grant's OWN ancestor Area is browsable on transit, and the same // identifier in the RAW namespace — the real anchor — is browsable on the same grant. gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, new NodeId("Area", 3), AddressSpaceRealm.Uns) .ShouldBeNull(); gate.AuthorizeCached(Identity(), OpcUaOperation.Browse, Anchor(), AddressSpaceRealm.Raw) .ShouldBeNull(); } // ───────────────────────────── helpers ───────────────────────────── /// The single shared root folder both realm trees hang under, in the raw namespace. private static NodeId Anchor() => new(V3NodeIds.SharedRootFolderId, 2); private static NodeAcl ScopedGrant(NodePermissions flags, NodeAclScopeKind kind, string scopeId) => new() { NodeAclId = $"acl-{scopeId}", ClusterId = Cluster, Principal = Principal, ScopeKind = kind, ScopeId = scopeId, PermissionFlags = flags, }; private static void Veto(ServiceResult? result) { result.ShouldNotBeNull(); result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied); } private static DataPlaneAuthorizer Gate( NodeAcl grant, IReadOnlyDictionary>? scopePaths = null) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], scopePaths ?? Scopes()); return new DataPlaneAuthorizer( state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); } /// /// The same gate builds, with the REAL evaluator wrapped in a call counter — the /// chain under test stays end-to-end, and only the number of walks becomes observable. /// private static DataPlaneAuthorizer CountingGate(NodeAcl grant, out CountingEvaluator evaluator) { var state = new DataPlaneAuthorityState(); state.Apply(Cluster, "rev-1", [Key(enabled: true)], [grant], Scopes()); evaluator = new CountingEvaluator(new TriePermissionEvaluator(state.Tries)); return new DataPlaneAuthorizer(state, evaluator, enabled: true, NullLogger.Instance); } private static RoleCarryingUserIdentity Identity() => new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, KeyId); private static NodeId RawNode(string path) => new(path, 2); private static NodeAcl ClusterGrant(NodePermissions flags) => new() { NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal, ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = flags, }; private static ApiKey Key(bool enabled) => new() { ApiKeyId = KeyId, Name = "stage5", SecretHash = "hash", Principal = Principal, Enabled = enabled, }; private static IReadOnlyDictionary> Scopes( params (string ScopeId, NodeAclScopeKind Kind, string[] Segments)[] scopes) { var map = new Dictionary>(); foreach (var (scopeId, kind, segments) in scopes) map[new AclScopeRef(kind, scopeId)] = [new NodeAclPath(segments)]; return map; } /// Counts grant walks without changing any of their answers. private sealed class CountingEvaluator(IPermissionEvaluator inner) : IPermissionEvaluator { public int Calls { get; private set; } public AuthorizationDecision Authorize( UserAuthorizationState session, OpcUaOperation operation, NodeScope scope) { Calls++; return inner.Authorize(session, operation, scope); } } /// Minimal controllable clock — the suite needs only "now" and "advance". 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); } }