f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
395 lines
17 KiB
C#
395 lines
17 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.
|
|
/// </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>
|
|
/// <param name="sessionId">A blank or whitespace-only session id.</param>
|
|
[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 —
|
|
/// 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The load-bearing regression: a cookie-style principal whose tag claim was stamped
|
|
/// at login under an older <c>GroupToTag</c> map is DENIED once that map no longer grants the
|
|
/// tag to any group the principal belongs to. The auth cookie survives the restart that
|
|
/// rebinds options, so trusting the stamped claim let a config-revoked grant live on for the
|
|
/// sliding 8-hour cookie life; the ACL now re-resolves from the group claims the same
|
|
/// principal carries, against the currently bound map.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CanViewSession_StampedTagClaimRevokedFromCurrentGroupToTag_Denied()
|
|
{
|
|
// Current config grants team-a to nobody the caller belongs to: "plant-ops" was the group
|
|
// whose entry was removed, and the surviving entry is for an unrelated group.
|
|
DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["other-team"] = ["team-b"] });
|
|
|
|
ClaimsPrincipal stale = Principal(
|
|
roles: [DashboardRoles.Viewer],
|
|
tags: ["team-a"],
|
|
groups: ["plant-ops"]);
|
|
|
|
Assert.False(acl.CanViewSession(stale, TaggedSessionId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The re-resolution's allowing direction: the same principal is allowed when the current map
|
|
/// still grants the session's tag to one of its groups — and is allowed even when it carries
|
|
/// NO stamped tag claim at all, which is what proves the decision comes from the group claims
|
|
/// rather than from the stamped ones.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CanViewSession_GroupClaimGrantsTagUnderCurrentMap_Allowed()
|
|
{
|
|
DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["plant-ops"] = ["team-a"] });
|
|
|
|
Assert.True(acl.CanViewSession(
|
|
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]),
|
|
TaggedSessionId));
|
|
|
|
Assert.True(acl.CanViewSession(
|
|
Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]),
|
|
TaggedSessionId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which
|
|
/// <see cref="DashboardGroupTagMapping"/> owns) and the resolved tag against the session's tag
|
|
/// set. Pinned because the earlier claim scan got the second comparison from
|
|
/// <c>Session.Tags</c>'s own comparer, and the re-resolution arm probes the grant set in the
|
|
/// opposite direction.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CanViewSession_GroupAndTagCasingDiffer_Allowed()
|
|
{
|
|
DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["PLANT-OPS"] = ["TEAM-A"] });
|
|
|
|
Assert.True(acl.CanViewSession(
|
|
Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]),
|
|
TaggedSessionId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A principal carrying group claims that map to nothing is an empty-grant Viewer, even though
|
|
/// it holds a stamped tag claim: once there is a group list to re-resolve from, the stamped
|
|
/// claims are not consulted at all. This is the arm that closes the revocation gap for a user
|
|
/// whose LDAP groups changed shape rather than whose config entry was removed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CanViewSession_GroupClaimsPresentButUnmapped_IgnoresStampedTagClaim()
|
|
{
|
|
DashboardSessionAcl acl = CreateAcl(groupToTag: []);
|
|
|
|
Assert.False(acl.CanViewSession(
|
|
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]),
|
|
TaggedSessionId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The documented fallback: a hub-token principal carries the tags <see cref="HubTokenService"/>
|
|
/// re-resolved at mint but no LDAP group claims to re-resolve from, so its stamped tag claims
|
|
/// are honoured. Asserted with an EMPTY current map, which is the state that would deny if the
|
|
/// fallback were ever dropped — and would silently break every token-authenticated hub join.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CanViewSession_HubTokenStylePrincipalWithNoGroupClaims_UsesStampedTags()
|
|
{
|
|
DashboardSessionAcl acl = CreateAcl(groupToTag: []);
|
|
|
|
Assert.True(acl.CanViewSession(
|
|
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: null),
|
|
TaggedSessionId));
|
|
}
|
|
|
|
private static DashboardSessionAcl CreateAcl(
|
|
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly,
|
|
Dictionary<string, string[]>? groupToTag = null)
|
|
{
|
|
GatewayOptions options = new()
|
|
{
|
|
Dashboard = new DashboardOptions
|
|
{
|
|
UntaggedSessionVisibility = visibility,
|
|
// Mirrors the binder's comparer so a test's group key behaves like a config one.
|
|
GroupToTag = groupToTag is null
|
|
? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
|
: new Dictionary<string, string[]>(groupToTag, StringComparer.OrdinalIgnoreCase),
|
|
},
|
|
};
|
|
|
|
return new DashboardSessionAcl(
|
|
new TwoSessionManager(
|
|
CreateSession(TaggedSessionId, ["team-a"]),
|
|
CreateSession(UntaggedSessionId, tags: null)),
|
|
Options.Create(options));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a dashboard principal. <paramref name="groups"/> is the discriminator: a
|
|
/// principal WITH group claims is re-resolved against the current map (the cookie seam), one
|
|
/// WITHOUT falls back to its stamped tag claims (the hub-token seam). Existing cases pass
|
|
/// tags only, so they continue to exercise the fallback arm exactly as before.
|
|
/// </summary>
|
|
/// <param name="roles">Role claims to stamp.</param>
|
|
/// <param name="tags">Login-stamped visibility tag claims, if any.</param>
|
|
/// <param name="groups">LDAP group claims, if any.</param>
|
|
/// <returns>An authenticated principal carrying the requested claims.</returns>
|
|
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null, string[]? groups = 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)));
|
|
claims.AddRange((groups ?? []).Select(group => new Claim(
|
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
|
group)));
|
|
|
|
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;
|
|
}
|
|
}
|