perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons

This commit is contained in:
Joseph Doherty
2026-08-16 04:19:47 -04:00
parent 7320586512
commit d8a17f2e34
3 changed files with 338 additions and 78 deletions
@@ -320,6 +320,66 @@ public sealed class DataPlaneAuthorizerTests
Veto(gate.Authorize(Identity(), OpcUaOperation.WriteOperate, node, AddressSpaceRealm.Raw));
}
// ───────────────────────────── the cached hot path ─────────────────────────────
/// <summary>
/// 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 <see cref="NodeScope"/> the
/// walk would need, because the lookup happens before the scope is built at all.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// <b>Pins the ordering the hot path must keep:</b> the key's usability is re-checked BEFORE the
/// decision cache is consulted, so a cached allow cannot outlive the key that earned it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <summary>The flag is carried faithfully; the seams read it to decide whether to consult the gate at all.</summary>
[Theory]
[InlineData(true)]
@@ -349,6 +409,18 @@ public sealed class DataPlaneAuthorizerTests
state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
}
/// <summary>
/// The same gate <see cref="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.
/// </summary>
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);
@@ -381,6 +453,19 @@ public sealed class DataPlaneAuthorizerTests
return map;
}
/// <summary>Counts grant walks without changing any of their answers.</summary>
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);
}
}
/// <summary>Minimal controllable clock — the suite needs only "now" and "advance".</summary>
private sealed class FakeTimeProvider(DateTime utcNow) : TimeProvider
{