Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs
T
Joseph Doherty 7ec0b3594c 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.
2026-08-17 04:35:59 -04:00

281 lines
11 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
/// consult (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// Every branch is asserted in its denying direction as well as its allowing one, because the
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
/// cannot distinguish a working gate from no gate at all.
/// </remarks>
public sealed class DashboardSessionAclTests
{
private const string TaggedSessionId = "session-tagged";
private const string UntaggedSessionId = "session-untagged";
/// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
[Fact]
public void CanViewSession_Administrator_BypassesTagCheck()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
}
/// <summary>
/// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
/// roles and no tags) working exactly as before this change.
/// </summary>
[Fact]
public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
TaggedSessionId));
}
/// <summary>
/// The decision table's order is load-bearing at exactly one corner: an Administrator naming
/// a session id the registry does not have is ALLOWED, because the admin bypass is checked
/// before the lookup. Pinned deliberately — reordering the two checks (a plausible "look the
/// session up first, it reads better" refactor) would flip this to a denial and quietly change
/// what an Administrator's hub join does for a session that closed a moment ago.
/// </summary>
[Fact]
public void CanViewSession_AdministratorAndUnknownSession_Allowed()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), "session-does-not-exist"));
}
/// <summary>
/// An unknown session id is denied for a non-Admin even when they hold every configured tag:
/// no subscription is created for a session the registry does not have. The Administrator
/// counterpart above is the deliberate exception.
/// </summary>
[Fact]
public void CanViewSession_UnknownSession_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
"session-does-not-exist"));
}
/// <summary>A blank session id is denied without consulting anything.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void CanViewSession_BlankSessionId_Denied(string sessionId)
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
}
/// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
[Fact]
public void CanViewSession_NullPrincipal_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(null, UntaggedSessionId));
}
/// <summary>
/// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
/// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
/// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
/// <param name="visibility">The configured untagged-session visibility.</param>
/// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
[Theory]
[InlineData(UntaggedSessionVisibility.AdminOnly, false)]
[InlineData(UntaggedSessionVisibility.AllViewers, true)]
public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
UntaggedSessionVisibility visibility,
bool expected)
{
DashboardSessionAcl acl = CreateAcl(visibility);
Assert.Equal(
expected,
acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
}
/// <summary>
/// A Viewer whose grant intersects the session's tags is allowed; the comparison is
/// ordinal-ignore-case, matching the session's tag set and the config map.
/// </summary>
/// <param name="grantedTag">The single tag the Viewer holds.</param>
[Theory]
[InlineData("team-a")]
[InlineData("TEAM-A")]
public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
TaggedSessionId));
}
/// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
[Fact]
public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
TaggedSessionId));
}
/// <summary>
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
[Fact]
public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
{
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
}
/// <summary>
/// An unauthenticated principal that nonetheless carries an Administrator role claim does not
/// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
/// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
/// </summary>
[Fact]
public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
{
// No authentication type => IsAuthenticated is false.
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: null,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
}
private static DashboardSessionAcl CreateAcl(
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
};
return new DashboardSessionAcl(
new TwoSessionManager(
CreateSession(TaggedSessionId, ["team-a"]),
CreateSession(UntaggedSessionId, tags: null)),
Options.Create(options));
}
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
{
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange((tags ?? []).Select(tag => new Claim(
DashboardAuthenticationDefaults.DashboardTagClaimType,
tag)));
return new ClaimsPrincipal(new ClaimsIdentity(
claims,
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
}
private static GatewaySession CreateSession(string sessionId, string[]? tags)
{
return new GatewaySession(
sessionId: sessionId,
backendName: "backend",
pipeName: $"pipe-{sessionId}",
nonce: "nonce",
clientIdentity: "client",
ownerKeyId: "key-1",
clientSessionName: "client-session",
clientCorrelationId: "correlation",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UnixEpoch,
ownerDashboardTags: tags);
}
/// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => Task.FromResult(tagged);
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessionId switch
{
TaggedSessionId => tagged,
UntaggedSessionId => untagged,
_ => null,
};
return session is not null;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}