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

@@ -37,6 +37,21 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
var trie = _cache.GetTrie(scope.ClusterId);
if (trie is null) return AuthorizationDecision.NotGranted();
// Decision #153 / Phase 6.2 adversarial-review item #3 (redundancy-safe invalidation):
// the GetTrie shortcut returns whatever generation the cache currently holds, which may
// have advanced past the generation this session was bound to (another node published).
// Evaluate against the session's *bound* generation so a grant added or removed in a
// newer generation cannot silently take effect mid-session, and so the provenance in the
// AuthorizationDecision reports the generation that actually produced the verdict.
if (trie.GenerationId != session.AuthGenerationId)
{
trie = _cache.GetTrie(scope.ClusterId, session.AuthGenerationId);
// The session's bound generation has been pruned out of the cache — fail closed and
// force the caller to re-resolve the session's auth state before retrying.
if (trie is null) return AuthorizationDecision.NotGranted();
}
var matches = trie.CollectMatches(scope, session.LdapGroups);
if (matches.Count == 0) return AuthorizationDecision.NotGranted();

View File

@@ -7,13 +7,19 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
/// </summary>
/// <remarks>
/// Per decision #151 the membership is bounded by <see cref="MembershipFreshnessInterval"/>
/// (default 15 min). After that, the next hot-path authz call re-resolves LDAP group
/// (default 5 min). After that, the next hot-path authz call re-resolves LDAP group
/// memberships; failure to re-resolve (LDAP unreachable) flips the session to fail-closed
/// until a refresh succeeds.
///
/// Per decision #152 <see cref="AuthCacheMaxStaleness"/> (default 5 min) is separate from
/// Per decision #152 <see cref="AuthCacheMaxStaleness"/> (default 15 min) is separate from
/// Phase 6.1's availability-oriented 24h cache — beyond this window the evaluator returns
/// <see cref="AuthorizationVerdict.NotGranted"/> regardless of config-cache warmth.
///
/// The freshness window is the inner trigger and the staleness ceiling the outer hard
/// limit: <see cref="MembershipFreshnessInterval"/> MUST be strictly less than
/// <see cref="AuthCacheMaxStaleness"/> so that <see cref="NeedsRefresh"/> ("re-resolve
/// while still serving cached memberships") has a non-empty window before
/// <see cref="IsStale"/> fails the session closed.
/// </remarks>
public sealed record UserAuthorizationState
{
@@ -47,10 +53,10 @@ public sealed record UserAuthorizationState
public required long MembershipVersion { get; init; }
/// <summary>Bounded membership freshness window; past this the next authz call refreshes.</summary>
public TimeSpan MembershipFreshnessInterval { get; init; } = TimeSpan.FromMinutes(15);
public TimeSpan MembershipFreshnessInterval { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>Hard staleness ceiling — beyond this, the evaluator fails closed.</summary>
public TimeSpan AuthCacheMaxStaleness { get; init; } = TimeSpan.FromMinutes(5);
public TimeSpan AuthCacheMaxStaleness { get; init; } = TimeSpan.FromMinutes(15);
/// <summary>
/// True when <paramref name="utcNow"/> - <see cref="MembershipResolvedUtc"/> exceeds