7ec0b3594c
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.
295 lines
12 KiB
C#
295 lines
12 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
public sealed class HubTokenServiceTests
|
|
{
|
|
/// <summary>
|
|
/// A token whose data-protected payload has both
|
|
/// <c>Name</c> and <c>NameIdentifier</c> null (the principal that
|
|
/// minted the token had no identity claims) must be rejected by
|
|
/// <see cref="HubTokenService.Validate"/>. The role claims alone are
|
|
/// not enough — without a caller identity, the resulting
|
|
/// <see cref="ClaimsPrincipal"/> would satisfy
|
|
/// <c>IsAuthenticated</c> / <c>IsInRole</c> checks without an
|
|
/// associated user.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
// Issue from a principal with NO Name claim and NO NameIdentifier
|
|
// claim. The Issue method's payload will then carry
|
|
// (Name = null, NameIdentifier = null, Roles = ["Viewer"]).
|
|
ClaimsIdentity identity = new(
|
|
[new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
|
|
authenticationType: "test");
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sanity check: a token minted from a principal with a Name claim
|
|
/// validates and returns a principal carrying that identity. Pins
|
|
/// that the null-identity rejection above does not over-reject valid tokens.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "alice"),
|
|
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("alice", result.Identity?.Name);
|
|
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sanity check: a token minted with only a NameIdentifier (no Name)
|
|
/// still validates — a non-null caller identity is the contract,
|
|
/// either field is sufficient.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.NameIdentifier, "alice-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
],
|
|
authenticationType: "test");
|
|
ClaimsPrincipal principal = new(identity);
|
|
string token = service.Issue(principal);
|
|
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
|
}
|
|
|
|
/// <summary>Verifies that a null token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_NullToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate(null));
|
|
}
|
|
|
|
/// <summary>Verifies that an empty token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_EmptyToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate(string.Empty));
|
|
}
|
|
|
|
/// <summary>Verifies that an invalid token returns null.</summary>
|
|
[Fact]
|
|
public void Validate_GarbageToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
|
|
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issue/validate round-trip: a freshly minted token (default <see cref="HubTokenService.TokenLifetime"/>)
|
|
/// validates and reconstructs the caller's identity and roles.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "bob"),
|
|
new Claim(ClaimTypes.NameIdentifier, "bob-id"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Admin),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
string token = service.Issue(new ClaimsPrincipal(identity));
|
|
ClaimsPrincipal? result = service.Validate(token);
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal("bob", result.Identity?.Name);
|
|
Assert.True(result.IsInRole(DashboardRoles.Viewer));
|
|
Assert.True(result.IsInRole(DashboardRoles.Admin));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The default token lifetime is the short (5-minute) window, not the
|
|
/// former 30-minute window. Pins the value so a regression that widens the exposure window
|
|
/// of an irrevocable, query-string-carried token is caught in CI.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TokenLifetime_IsFiveMinutes()
|
|
{
|
|
Assert.Equal(TimeSpan.FromMinutes(5), HubTokenService.TokenLifetime);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A token whose lifetime has elapsed is rejected by <see cref="HubTokenService.Validate"/>.
|
|
/// Uses the internal lifetime-issuing seam with a negative lifetime so the token is already
|
|
/// expired at mint time — deterministic, no wall-clock delay.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Validate_ExpiredToken_ReturnsNull()
|
|
{
|
|
HubTokenService service = CreateService();
|
|
ClaimsIdentity identity = new(
|
|
[new Claim(ClaimTypes.Name, "carol")],
|
|
authenticationType: "test");
|
|
|
|
string expiredToken = service.Issue(
|
|
new ClaimsPrincipal(identity),
|
|
TimeSpan.FromMinutes(-1));
|
|
|
|
Assert.Null(service.Validate(expiredToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
|
|
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
|
|
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
|
|
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
|
|
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
|
|
/// <c>IDashboardSessionAcl</c> reads on the hub path.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
|
|
{
|
|
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["GwViewer"] = ["team-a"],
|
|
["TeamBViewers"] = ["team-b"],
|
|
});
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "dana"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(
|
|
["team-a", "team-b"],
|
|
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
|
|
.Select(c => c.Value)
|
|
.Order(StringComparer.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
|
|
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
|
|
/// is also the shape of every token minted before the tag field existed (the payload field
|
|
/// deserializes to null), so the fail-closed direction is covered for both.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
|
|
{
|
|
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["SomeOtherGroup"] = ["team-a"],
|
|
});
|
|
|
|
ClaimsIdentity identity = new(
|
|
[
|
|
new Claim(ClaimTypes.Name, "erin"),
|
|
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
|
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
|
],
|
|
authenticationType: "test",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: ClaimTypes.Role);
|
|
|
|
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
|
|
}
|
|
|
|
/// <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()
|
|
{
|
|
Dashboard = new DashboardOptions
|
|
{
|
|
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
|
|
},
|
|
};
|
|
|
|
return new HubTokenService(
|
|
dataProtection ?? new EphemeralDataProtectionProvider(),
|
|
Options.Create(options));
|
|
}
|
|
}
|