Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.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

187 lines
8.3 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout;
using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Renders <see cref="MainLayout"/> and asserts which policy-gated links the side rail emits —
/// Secrets (<c>secrets:manage</c>) and, since SEC-40, Alarms
/// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>).
/// </summary>
/// <remarks>
/// <para>
/// The policy-level tests in <c>SecretsNavGateTests</c> are not sufficient on their own, and the
/// reason is worth stating because it is easy to miss: they would stay green if the
/// <c>&lt;AuthorizeView&gt;</c> were deleted outright. They prove the policy decides correctly, not
/// that the rail asks it. The wiring is the part this change actually introduced, so it is the part
/// that needs its own evidence.
/// </para>
/// <para>
/// The load-bearing assertion is the NEGATIVE one. "An Administrator sees the link" is identical to
/// the behaviour before the gate existed, so it cannot distinguish a working gate from an inert one.
/// Only a principal without <c>secrets:manage</c> failing to see the item proves a gate is there at
/// all — which is why <see cref="Rail_OmitsSecretsLink_ForViewer"/> is the test that matters and the
/// Administrator case is its control.
/// </para>
/// <para>
/// Uses the framework's static <see cref="HtmlRenderer"/> rather than a component-testing package:
/// no new dependency, and static rendering is enough because the assertion is about markup the
/// server emits, not about interactivity.
/// </para>
/// </remarks>
public sealed class SecretsNavRenderTests
{
private const string SecretsHref = "/admin/secrets";
private const string AlarmsHref = "/alarms";
/// <summary>
/// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered
/// the link, because the page would refuse them.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_OmitsSecretsLink_ForViewer()
{
string html = await RenderRailAsync(DashboardRoles.Viewer);
Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
}
/// <summary>Anonymous callers (the read-only localhost path) are likewise not offered it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_OmitsSecretsLink_ForAnonymous()
{
string html = await RenderRailAsync();
Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
}
/// <summary>
/// The control for the two negatives. Without this, a rail that rendered no nav at all — a
/// broken layout, a throwing component swallowed somewhere — would satisfy both absence
/// assertions and the suite would report a working gate over a blank page. The sibling
/// assertions on the always-present items are what make the absence above mean "gated" rather
/// than "nothing rendered".
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_EmitsSecretsLink_ForAdministrator()
{
string html = await RenderRailAsync(DashboardRoles.Admin);
Assert.Contains(SecretsHref, html, StringComparison.Ordinal);
Assert.Contains("/apikeys", html, StringComparison.Ordinal);
}
/// <summary>
/// Pins the deliberate asymmetry: the ungated sibling stays visible to a Viewer. If someone
/// later "fixes the inconsistency" by wrapping the API Keys item in the same AuthorizeView,
/// this fails — that page renders read-only for Viewers, so hiding its link would remove
/// legitimate access.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_StillEmitsApiKeysLink_ForViewer()
{
string html = await RenderRailAsync(DashboardRoles.Viewer);
Assert.Contains("/apikeys", html, StringComparison.Ordinal);
}
/// <summary>
/// SEC-40's nav half: an anonymous caller — the read-only localhost path — is not offered the
/// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets
/// negative above: this is the assertion that distinguishes a gate from an inert one.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_OmitsAlarmsLink_ForAnonymous()
{
string html = await RenderRailAsync();
Assert.DoesNotContain(AlarmsHref, html, StringComparison.Ordinal);
}
/// <summary>
/// The control for that negative: an authenticated Viewer still gets the Alarms link. SEC-40
/// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a
/// real dashboard role because the central monitor carries no per-session provenance.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Rail_EmitsAlarmsLink_ForViewer()
{
string html = await RenderRailAsync(DashboardRoles.Viewer);
Assert.Contains(AlarmsHref, html, StringComparison.Ordinal);
}
private static async Task<string> RenderRailAsync(params string[] roles)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAuthorization(options =>
{
options.AddSecretsAuthorization();
// The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws
// rather than denying, so it must be present for any rail render (SEC-40).
options.AddPolicy(
DashboardAuthenticationDefaults.AlarmsPolicy,
policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
});
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
services.AddCascadingAuthenticationState();
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider(roles));
services.AddSingleton<NavigationManager, StubNavigationManager>();
await using ServiceProvider provider = services.BuildServiceProvider();
await using var renderer = new HtmlRenderer(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<MainLayout>();
return output.ToHtmlString();
});
}
// Supplies the authentication state the rail's AuthorizeView reads. An empty role list yields an
// unauthenticated principal; otherwise the identity carries an authentication type, without
// which every policy would fail for the wrong reason and the negative assertions would pass
// vacuously.
private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
ClaimsIdentity identity = roles.Length == 0
? new ClaimsIdentity()
: new ClaimsIdentity(
roles.Select(role => new Claim(ZbClaimTypes.Role, role)),
authenticationType: "Test",
nameType: ZbClaimTypes.Name,
roleType: ZbClaimTypes.Role);
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity)));
}
}
// NavLink resolves hrefs against the current URI, so the rail needs a NavigationManager even
// under static rendering.
private sealed class StubNavigationManager : NavigationManager
{
public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/");
}
}