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:
@@ -349,3 +349,20 @@ is checked first.
|
||||
identity does not bypass.
|
||||
- Tag *values* are never logged at either seam; only the identifiers and the
|
||||
allow/deny outcome are observable.
|
||||
- **The page gate needed a re-entrancy guard.** Making `AttachEvents` async (it now
|
||||
awaits the authentication state) introduced a suspension point the synchronous
|
||||
version did not have, and with it a window: on a rapid A → B navigation the
|
||||
suspended A continuation resumes, re-reads the live `SessionId` — now B's — and
|
||||
attaches B a second time, overwriting the fields that hold B's first subscription.
|
||||
That subscription is then unreachable: never disposed, its `EventsHubViewerRegistry`
|
||||
entry never released (so the mirror keeps cloning events for it), its pump never
|
||||
cancelled. Not an ACL bypass — the newer attach had already cleared the same session
|
||||
— but a resource leak the ACL work created. `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`; a stale attach returns without attaching (it owns nothing, and tearing
|
||||
down would destroy the newer attach's subscription). `DetachEventsAsync` needs no
|
||||
guard: it captures and nulls the live fields synchronously before it awaits.
|
||||
- The admin-bypass-before-lookup ordering means an Administrator naming a session id
|
||||
the registry does not have is **allowed**, not denied. Deliberate, and pinned by a
|
||||
test so a "look the session up first" refactor cannot flip it silently.
|
||||
|
||||
Reference in New Issue
Block a user