From 0f3b74ad8792a013f3ce8bc2884cb43d73d48cb3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 11:25:39 -0400 Subject: [PATCH] fix(server): wire PermissionTrieCache into AuthorizationGate for generation pinning Core-002 fixed TriePermissionEvaluator to evaluate each request against the session's bound AuthGenerationId rather than whatever the cache currently holds. AuthorizationGate.BuildSessionState was not updated at the same time: it hardcoded AuthGenerationId = 0, so the evaluator's GetTrie(cluster, 0) call returned null for any generation != 0, causing every gated operation to silently fail with NotGranted regardless of actual grants. The 42 gate/matrix/deferred-hardening tests all started failing as a result. Fix: add an optional PermissionTrieCache parameter to AuthorizationGate; BuildSessionState now stamps AuthGenerationId from the cache's current generation for the session's cluster. AuthorizationBootstrap.BuildGateAsync passes the cache it creates. All 7 test MakeGate helpers updated to pass the cache so tests produce a valid AuthGenerationId. 433/433 server tests now pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Security/AuthorizationBootstrap.cs | 5 ++- .../Security/AuthorizationGate.cs | 39 ++++++++++++++++++- .../AuthorizationGateTests.cs | 3 +- .../BrowseGatingTests.cs | 2 +- .../CallGatingTests.cs | 2 +- .../DeferredGateHardeningTests.cs | 2 +- .../EquipmentIdentificationAuthzTests.cs | 2 +- .../MonitoredItemGatingTests.cs | 2 +- .../ThreeUserInteropMatrixTests.cs | 2 +- 9 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs index de02d31..05ca1f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationBootstrap.cs @@ -86,7 +86,10 @@ public sealed class AuthorizationBootstrap( var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(nodeOptions.ClusterId, generationId, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: nodeOptions.Authorization.StrictMode); + // Pass the cache so AuthorizationGate.BuildSessionState can stamp AuthGenerationId + // correctly (Core-002 generation-pinning fix requires the session to carry the + // current generation, not a placeholder 0). + return new AuthorizationGate(evaluator, strictMode: nodeOptions.Authorization.StrictMode, trieCache: cache); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs index aebeba0..22a0181 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs @@ -28,18 +28,46 @@ namespace ZB.MOM.WW.OtOpcUa.Server.Security; /// verdict from an authored deny rule is a /// definite decision and is honoured in both strict and lax mode — lax mode never /// overrides a deny. +/// +/// stamps +/// from the current generation so the +/// generation-pinning check (Core-002) evaluates the +/// session against the trie that was current when the session was resolved, not an out-of- +/// date generation 0 placeholder. /// public sealed class AuthorizationGate { private readonly IPermissionEvaluator _evaluator; private readonly bool _strictMode; private readonly TimeProvider _timeProvider; + private readonly PermissionTrieCache? _trieCache; - public AuthorizationGate(IPermissionEvaluator evaluator, bool strictMode = false, TimeProvider? timeProvider = null) + /// + /// Constructs an . + /// + /// The permission evaluator (required). + /// + /// When true, indeterminate (not-granted) decisions deny rather than allow. + /// Default false for lax-mode deployments still populating ACLs. + /// + /// Optional time provider for staleness checks. + /// + /// Optional cache reference used by to stamp the + /// session's AuthGenerationId with the current trie generation, so the + /// evaluator's generation-pinning check evaluates against the correct grant set. + /// When null, AuthGenerationId is set to 0 (acceptable only when the cache + /// was seeded with generation 0, as in some test scenarios). + /// + public AuthorizationGate( + IPermissionEvaluator evaluator, + bool strictMode = false, + TimeProvider? timeProvider = null, + PermissionTrieCache? trieCache = null) { _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); _strictMode = strictMode; _timeProvider = timeProvider ?? TimeProvider.System; + _trieCache = trieCache; } /// True when strict authorization is enabled — no-grant = denied. @@ -90,13 +118,20 @@ public sealed class AuthorizationGate return null; var sessionId = identity.DisplayName ?? Guid.NewGuid().ToString("N"); + + // Stamp the session with the cache's current generation so the evaluator's + // generation-pinning check (Core-002 / TriePermissionEvaluator.Authorize) evaluates + // against the trie that was current at resolve time, not a stale generation-0 placeholder. + // When no cache is available (some test scenarios), fall back to 0. + var authGenerationId = _trieCache?.CurrentGenerationId(clusterId) ?? 0L; + return new UserAuthorizationState { SessionId = sessionId, ClusterId = clusterId, LdapGroups = bearer.LdapGroups, MembershipResolvedUtc = _timeProvider.GetUtcNow().UtcDateTime, - AuthGenerationId = 0, + AuthGenerationId = authGenerationId, MembershipVersion = 0, }; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs index 4060232..e387ab5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs @@ -40,7 +40,8 @@ public sealed class AuthorizationGateTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: strict); + // Pass the cache so BuildSessionState stamps AuthGenerationId = 1 (Core-002 fix). + return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache); } private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs index 22ca6bc..f4d4d5b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs @@ -141,7 +141,7 @@ public sealed class BrowseGatingTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: strict); + return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs index 7735c57..40bf8b6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs @@ -209,7 +209,7 @@ public sealed class CallGatingTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: strict); + return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs index 35cccf5..84af582 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/DeferredGateHardeningTests.cs @@ -375,7 +375,7 @@ public sealed class DeferredGateHardeningTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(Cluster, 1, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: strict); + return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs index f2d5fcc..816c6cf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/EquipmentIdentificationAuthzTests.cs @@ -118,7 +118,7 @@ public sealed class EquipmentIdentificationAuthzTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths)); var evaluator = new TriePermissionEvaluator(cache); - var gate = new AuthorizationGate(evaluator, strictMode: true); + var gate = new AuthorizationGate(evaluator, strictMode: true, trieCache: cache); _ = content; return (gate, resolver); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/MonitoredItemGatingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/MonitoredItemGatingTests.cs index 0a05e27..dd39194 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/MonitoredItemGatingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/MonitoredItemGatingTests.cs @@ -128,7 +128,7 @@ public sealed class MonitoredItemGatingTests var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); var evaluator = new TriePermissionEvaluator(cache); - return new AuthorizationGate(evaluator, strictMode: strict); + return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache); } private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ThreeUserInteropMatrixTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ThreeUserInteropMatrixTests.cs index 7794a50..9e1658e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ThreeUserInteropMatrixTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ThreeUserInteropMatrixTests.cs @@ -122,7 +122,7 @@ public sealed class ThreeUserInteropMatrixTests { var cache = new PermissionTrieCache(); cache.Install(PermissionTrieBuilder.Build(ClusterId, 1, AclMatrix())); - return new AuthorizationGate(new TriePermissionEvaluator(cache), strictMode: true); + return new AuthorizationGate(new TriePermissionEvaluator(cache), strictMode: true, trieCache: cache); } private sealed class LdapBoundIdentity : UserIdentity, ILdapGroupsBearer