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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user