Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs
T
Joseph Doherty 2ef7b93761 fix(SEC-41, SEC-40): re-resolve dashboard tag grants; gate the alarm feed on a real role
Committed together because the two changes share the same doc paragraphs (the ACL
section of GatewayDashboardDesign.md, the GroupToTag / AllowAnonymousLocalhost rows
of GatewayConfiguration.md, and CLAUDE.md's authentication paragraph), and splitting
them would leave one of the two describing behaviour that does not yet exist.

SEC-41 — tag grants no longer trusted from login-stamped claims
---------------------------------------------------------------
DashboardSessionAcl read the zb:dashboardtag claims stamped into the cookie at
login. GatewayOptions binds once at startup, so applying a GroupToTag edit needs a
restart — but the restart does not clear the auth cookie (data-protection keys
persist), so a browser returning afterwards re-authenticated with tag claims
resolved under the OLD map. A revoked grant therefore survived on the cookie seam
for the sliding 8-hour idle life, renewable indefinitely by activity, while the
hub-token seam had no such gap because its mint re-resolves.

CanViewSession now re-resolves the grant on every subscribe from the caller's
mxgateway:ldap_group claims through DashboardGroupTagMapping.MapGroupsToTags
against the currently bound map — the same one call HubTokenService.Issue makes.

Fallback (documented on the class and in the design doc): a principal carrying NO
group claims falls back to its stamped tag claims. That arm exists for the
hub-token principal — HubTokenService.Validate rehydrates roles and tags but not
groups, so re-resolution already happened at mint, one lifetime ago at worst. It
cannot re-open the cookie hole: DashboardAuthenticator stamps group and tag claims
from one group list, so a cookie principal holding a tag claim always holds the
groups it came from, and a login yielding no groups yields no tags. A principal
with neither kind of claim stays an empty-grant Viewer.

SEC-40 — the alarm feed requires a real authenticated dashboard role
--------------------------------------------------------------------
AlarmsHub sat behind HubClientsPolicy, which the read-only environment bypasses
satisfy, so an anonymous-localhost caller or a Mode=Disabled deployment received
galaxy-wide alarm metadata — alarm reference (a full tag path), state, severity,
category, area, acknowledging operator — for the whole subscription expression.

Decided: tighten the gate, keep the scope. New AuthenticatedDashboardRoleRequirement
+ its handler, and a MxGateway.Dashboard.Alarms policy built from it. Because
DashboardAuthorizationHandler is typed to DashboardAuthorizationRequirement, it is
never invoked for the new type, so neither Authentication:Mode=Disabled nor
anonymous localhost can satisfy the policy — the same structural device the mounted
Secrets UI already relies on. The policy keeps both schemes so external hub clients
can still authenticate with a bearer token, and DisableLogin auto-login is
unaffected (it mints a genuinely authenticated both-roles principal).

The /alarms page carries the same policy, not just the hub: it reads the same
central monitor in process, so gating only the hub would have left the disclosure
one navigation away. The side rail hides the Alarms link for callers the policy
denies, matching the Secrets link precedent. AlarmsHubPublisher is unchanged and
stays unfiltered by design — audience is decided at hub admission, and the monitor
carries no per-session provenance to narrow by.

Alarm metadata deliberately remains galaxy-wide for authenticated Viewers: an
AlarmFeedMessage has no session id or owning key for the SEC-25 tag intersection to
work against, and equating a visibility tag with an alarm-reference prefix would
turn GroupToTag into the data-access constraint it explicitly is not.

Tests
-----
DashboardSessionAclTests: the SEC-41 regression — a cookie-style principal whose
stale tag claim is no longer granted by any of its groups under the current map is
denied; plus the allowing direction (including with no stamped tag at all, which
proves the decision comes from the groups), the ignore-case corner, group claims
that map to nothing, and the hub-token fallback asserted against an EMPTY map so
dropping the fallback would fail loudly.

AlarmFeedAuthorizationTests (new): both handlers run together in the most permissive
environment (Mode=Disabled + loopback + AllowAnonymousLocalhost) do not satisfy the
alarm requirement, with the bypassable requirement as the control so the negative
cannot pass because the bypass was misconfigured; authenticated Viewer/Admin do
satisfy it; an unauthenticated role claim does not; the registered policy carries
the non-bypassable requirement and both schemes; and both readers — the hub type's
[Authorize] and the /alarms endpoint metadata — carry it.

SecretsNavRenderTests: registers the new policy (an unregistered policy name throws
rather than denying in an AuthorizeView) and gains the Alarms-link negative/control
pair.

Verified: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx (zero warnings);
filtered run of the touched classes — 92 passed, 0 failed.
2026-08-20 09:29:44 -04:00

394 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 (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));
}
/// <summary>
/// SEC-41, 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 pre-SEC-41 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 SEC-41 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;
}
}