fix(dashboard): close AttachEventsAsync re-entrancy window; pin ACL decision-table corners (SEC-25 review)

Follow-up to the per-session event ACL. Part of that change rode into 693a78d
via a concurrent agent's pathspec-less commit; this commit carries the review
fixes and uses pathspecs on the commit itself so it cannot recur in either
direction.

Gating the page's subscribe seam made AttachEvents asynchronous — it awaits the
authentication state — and that await is a suspension point the synchronous
version did not have. On a rapid A -> B navigation the suspended A continuation
resumes after B's parameter set has run to completion, re-reads the live
SessionId (now B's), and attaches B a SECOND time. The ACL is not bypassed —
the newer attach already cleared that same session — but the fields holding B's
first subscription are overwritten in place, so nothing ever disposes it: its
EventsHubViewerRegistry entry is never released, which keeps the mirror cloning
events for a session the page is no longer watching through that handle, and
its pump is never cancelled. A resource leak the ACL work introduced.

OnParametersSetAsync now claims a monotonic _attachGeneration synchronously,
before its first await, and AttachEventsAsync re-checks it after the await and
before any field write or Subscribe call. A stale attach returns rather than
detaching: it owns nothing, and tearing down there would destroy the newer
attach's subscription. DetachEventsAsync needs no such guard — it captures and
nulls the live fields synchronously before it awaits, so a resumed detach only
unwinds what it already took ownership of. Same dispatcher-owned identity idea
as the existing ReferenceEquals guards in PumpEventsAsync and
MarkDisconnectedAsync, one level up.

The interleaving is not expressible with the static HtmlRenderer idiom the other
page tests use: it renders a root component once and exposes no parameter-update
seam. The new test therefore adds a minimal Renderer subclass whose only job is
to mount a component and drive a second SetParametersAsync into it while the
first is parked on a gated AuthenticationStateProvider. That subclass is the
lone reason for a narrowly scoped BL0006 suppression, justified in place: it is
test-only scaffolding that never ships, and the cost of the warning coming true
is a compile break in one test file on an SDK bump. Confirmed non-vacuous by
mutation — with the generation check disabled the test goes red on the doubled
subscription and the two passing ACL tests stay green.

Two decision-table corners are now pinned rather than implied. Admin x
nonexistent session id resolves to ALLOW, because the admin bypass is evaluated
before the registry lookup; a plausible "look the session up first, it reads
better" refactor would flip it, so a test documents the ordering. EventsHub's
remarks said "an unknown session id is denied" without qualification, which read
as universal; they now state that the bypass is checked first and every rule
below it is a non-Admin rule.

HubTokenServiceTests gains the truly-absent-field case: a hand-built payload
JSON with no Tags key at all, protected through the same purpose, which is the
shape every in-flight token has across the deploy that introduces the field. The
existing test covered present-but-empty, which does not exercise the null
coalesce that stands between a legacy token and a crash on the hub auth path.
ProtectorPurpose became internal so the test cannot drift from the real purpose
string.

Tag-count cardinality cap considered and recorded as a deliberate non-goal.

Build 0 warnings / 0 errors; 48 filtered (ACL/hub/token/page) and 257 dashboard
tests pass.
This commit is contained in:
Joseph Doherty
2026-08-17 04:35:59 -04:00
parent 693a78db7d
commit 7ec0b3594c
7 changed files with 299 additions and 16 deletions
@@ -245,7 +245,39 @@ public sealed class HubTokenServiceTests
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
}
private static HubTokenService CreateService(Dictionary<string, string[]>? groupToTag = null)
/// <summary>
/// A token minted before the payload carried tags at all still validates, and yields an empty
/// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which
/// exercises a <c>Tags</c> key that is present and empty: this one protects a hand-built
/// payload with the key genuinely ABSENT, which is the shape every in-flight token has across
/// the deploy that introduces the field. Deserialization leaves the field null, and the
/// null-coalesce in <c>Validate</c> is the only thing standing between that and a crash on
/// the hub's authentication path.
/// </summary>
[Fact]
public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant()
{
EphemeralDataProtectionProvider dataProtection = new();
HubTokenService service = CreateService(dataProtection: dataProtection);
// The pre-field payload shape, verbatim: no "Tags" key anywhere.
const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}""";
string legacyToken = dataProtection
.CreateProtector(HubTokenService.ProtectorPurpose)
.ToTimeLimitedDataProtector()
.Protect(LegacyPayload, HubTokenService.TokenLifetime);
ClaimsPrincipal? result = service.Validate(legacyToken);
Assert.NotNull(result);
Assert.Equal("frank", result.Identity?.Name);
Assert.True(result.IsInRole(DashboardRoles.Viewer));
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
}
private static HubTokenService CreateService(
Dictionary<string, string[]>? groupToTag = null,
IDataProtectionProvider? dataProtection = null)
{
GatewayOptions options = new()
{
@@ -255,6 +287,8 @@ public sealed class HubTokenServiceTests
},
};
return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options));
return new HubTokenService(
dataProtection ?? new EphemeralDataProtectionProvider(),
Options.Create(options));
}
}