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
@@ -185,6 +185,16 @@ else
// AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
private bool _eventsAuthorized = true;
private string? _subscribedSessionId;
// Identifies the attach currently entitled to publish subscription state. Bumped
// synchronously by OnParametersSetAsync before it awaits anything, so a suspended
// AttachEventsAsync continuation can tell that a newer parameter set overtook it — the
// same dispatcher-owned identity idea as the ReferenceEquals guards in PumpEventsAsync
// and MarkDisconnectedAsync, one level up. Without it, the await on the authentication
// state opens a window in which a rapid A -> B navigation lets the stale continuation
// re-read the live SessionId and attach B a second time, orphaning B's first
// subscription (never disposed, its viewer registration never released, its pump never
// cancelled) behind the fields it overwrites.
private int _attachGeneration;
private readonly LinkedList<MxEvent> _recentEvents = new();
private bool CanManage { get; set; }
@@ -208,11 +218,17 @@ else
{
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
{
// Claimed before the first await, so every attach that follows carries a token
// that a later parameter set can invalidate. 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.
int generation = ++_attachGeneration;
// Deliberately no ConfigureAwait(false): the resumption must stay on the
// renderer's dispatcher so the new subscription is published to
// _eventSubscription from the same thread the pump's guard reads it on.
await DetachEventsAsync();
await AttachEventsAsync();
await AttachEventsAsync(generation);
}
}
@@ -302,7 +318,7 @@ else
// whether a subscription is created at all — the generation guards, the pump, and the detach
// coupling below it are untouched, so a denied page holds no subscription to leak and never
// registers a viewer, which keeps the broadcaster's mirror off for that session.
private async Task AttachEventsAsync()
private async Task AttachEventsAsync(int generation)
{
if (string.IsNullOrWhiteSpace(SessionId))
{
@@ -313,6 +329,19 @@ else
// land back on the renderer's dispatcher, which is where the fields below are owned.
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
// Checked before ANY field write and before Subscribe, because both are the damage: a
// newer parameter set may have run start-to-finish while this continuation was parked,
// and SessionId now reads as ITS session. Attaching here would not bypass the ACL (the
// newer attach already cleared the same session), but it would strand the live
// subscription — overwritten in place, so nothing ever disposes it or releases its
// viewer registration, and the mirror stays on for a session nobody is watching. A
// stale attach owns nothing, so it returns rather than detaching: tearing down here
// would destroy the newer attach's subscription.
if (generation != _attachGeneration)
{
return;
}
_subscribedSessionId = SessionId;
_eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);
@@ -29,7 +29,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </remarks>
public sealed class HubTokenService
{
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
// Internal rather than private so a test can protect a hand-built payload through the same
// purpose and assert how Validate reads a payload shape this class no longer mints (a token
// predating the Tags field). Copying the literal into the test instead would let the two
// drift and silently turn that test into an assertion about an unrelated protector.
internal const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
@@ -40,9 +40,14 @@ public sealed class EventsHub(
/// only checks that the caller carries one of the dashboard roles, which by
/// itself would let any Viewer subscribe to any session id they name. The
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
/// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a
/// session only when its tags intersect their granted tags, and an unknown
/// session id is denied. A denied caller is not joined to the group and is
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
/// Administrator joins any session id they name; every check below it
/// applies to non-Admin callers only. For those: a Viewer sees a session
/// only when its tags intersect their granted tags, an untagged session
/// follows <c>Dashboard:UntaggedSessionVisibility</c>, and a session id the
/// registry does not have is denied outright — the phantom-id denial is
/// therefore a non-Admin rule, not a universal one.
/// A denied caller is not joined to the group and is
/// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
/// stays off for a session nobody is legitimately watching. The same ACL
/// gates the in-process seam used by the session-details page, so neither