fix(core): resolve High code-review findings (Core-001, Core-002)

Core-001: swap the authorization-cache defaults so
MembershipFreshnessInterval (5 min, inner re-resolve trigger) is
strictly less than AuthCacheMaxStaleness (15 min, fail-closed
ceiling), so NeedsRefresh's warm-refresh path is reachable.

Core-002: TriePermissionEvaluator.Authorize now compares the trie's
GenerationId against the session's AuthGenerationId and re-fetches the
session's bound generation on mismatch, failing closed when that
generation has been pruned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:13:01 -04:00
parent ee51878c08
commit abbf49141c
5 changed files with 90 additions and 14 deletions

View File

@@ -32,14 +32,15 @@ public sealed class TriePermissionEvaluatorTests
PermissionFlags = flags,
};
private static UserAuthorizationState Session(string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1") =>
private static UserAuthorizationState Session(
string[] groups, DateTime? resolvedUtc = null, string clusterId = "c1", long authGenerationId = 1) =>
new()
{
SessionId = "sess",
ClusterId = clusterId,
LdapGroups = groups,
MembershipResolvedUtc = resolvedUtc ?? Now,
AuthGenerationId = 1,
AuthGenerationId = authGenerationId,
MembershipVersion = 1,
};
@@ -123,7 +124,7 @@ public sealed class TriePermissionEvaluatorTests
{
var evaluator = MakeEvaluator([Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read)]);
var session = Session(["cn=ops"], resolvedUtc: Now);
_time.Utc = Now.AddMinutes(10); // well past the 5-min AuthCacheMaxStaleness default
_time.Utc = Now.AddMinutes(20); // well past the 15-min AuthCacheMaxStaleness default
var decision = evaluator.Authorize(session, OpcUaOperation.Read, Scope());
@@ -141,6 +142,46 @@ public sealed class TriePermissionEvaluatorTests
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
}
[Fact]
public void StaleGeneration_EvaluatesAgainst_SessionBoundGeneration()
{
// Core-002 regression: session is bound to generation 1 (Read granted). Another node
// publishes generation 2 with the grant removed and it becomes the cache "current".
// The evaluator must still honour the session's bound generation 1, not generation 2.
var gen1Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
gen1Row.GenerationId = 1;
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 1, [gen1Row]));
cache.Install(PermissionTrieBuilder.Build("c1", 2, [])); // gen 2 — grant revoked, now current
var evaluator = new TriePermissionEvaluator(cache, _time);
var decision = evaluator.Authorize(
Session(["cn=ops"], authGenerationId: 1), OpcUaOperation.Read, Scope());
decision.Verdict.ShouldBe(AuthorizationVerdict.Allow,
"the session bound to generation 1 must evaluate against generation 1, not the newer current generation");
decision.Provenance.Count.ShouldBe(1);
}
[Fact]
public void StaleGeneration_FailsClosed_WhenBoundGenerationPruned()
{
// Core-002 regression: session is bound to a generation no longer in the cache.
// The evaluator must fail closed rather than silently using the current generation.
var gen5Row = Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read);
gen5Row.GenerationId = 5;
var cache = new PermissionTrieCache();
cache.Install(PermissionTrieBuilder.Build("c1", 5, [gen5Row]));
var evaluator = new TriePermissionEvaluator(cache, _time);
// Session bound to generation 1, which was never installed / has been pruned.
var decision = evaluator.Authorize(
Session(["cn=ops"], authGenerationId: 1), OpcUaOperation.Read, Scope());
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted,
"a session bound to a generation absent from the cache must fail closed");
}
[Fact]
public void OperationToPermission_Mapping_IsTotal()
{

View File

@@ -33,7 +33,21 @@ public sealed class UserAuthorizationStateTests
{
var session = Fresh(Now);
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 5-min staleness ceiling — should be Stale, not NeedsRefresh");
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 15-min staleness ceiling — should be Stale, not NeedsRefresh");
}
[Fact]
public void NeedsRefresh_FiresWithin_ProductionDefault_Windows()
{
// Core-001 regression: with the production defaults the refresh window must be
// non-empty. Freshness defaults to 5 min and staleness to 15 min, so between
// those two boundaries NeedsRefresh must return true while IsStale stays false.
var session = Fresh(Now);
session.MembershipFreshnessInterval.ShouldBeLessThan(session.AuthCacheMaxStaleness);
session.NeedsRefresh(Now.AddMinutes(10)).ShouldBeTrue("10 min is past the 5-min freshness window but within the 15-min staleness ceiling");
session.IsStale(Now.AddMinutes(10)).ShouldBeFalse("10 min is still within the 15-min staleness ceiling");
}
[Fact]
@@ -55,6 +69,6 @@ public sealed class UserAuthorizationStateTests
{
var session = Fresh(Now);
session.IsStale(Now.AddMinutes(6)).ShouldBeTrue("default AuthCacheMaxStaleness is 5 min");
session.IsStale(Now.AddMinutes(16)).ShouldBeTrue("default AuthCacheMaxStaleness is 15 min");
}
}