perf(acl): authorizer hot path — cache lookup before scope build, lazy deny reasons
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user