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.
This commit is contained in:
Joseph Doherty
2026-08-20 09:29:44 -04:00
parent b177636d7a
commit 2ef7b93761
15 changed files with 681 additions and 43 deletions
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40).
/// </summary>
public sealed class AuthenticatedDashboardRoleHandler
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AuthenticatedDashboardRoleRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
{
return Task.CompletedTask;
}
foreach (string role in requirement.RequiredRoles)
{
if (context.User.IsInRole(role))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Requirement satisfied ONLY by an authenticated principal holding one of
/// <see cref="RequiredRoles"/> as a real role claim. Unlike
/// <see cref="DashboardAuthorizationRequirement"/> there is no environment bypass: it is a
/// distinct requirement type, so <see cref="DashboardAuthorizationHandler"/> —
/// an <c>AuthorizationHandler&lt;DashboardAuthorizationRequirement&gt;</c> — is never invoked for
/// it and its <c>Authentication:Mode=Disabled</c> / anonymous-localhost arms cannot reach it.
/// The type distinction is the enforcement; adding a bypass arm to the other handler cannot leak
/// into this one by accident.
/// </summary>
/// <remarks>
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
/// which is gateway-central and session-less and therefore carries no per-session provenance the
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
/// state, severity, category, area, acknowledging operator — away from the read-only environment
/// bypasses, which exist for local development and unauthenticated deployments rather than as a
/// grant of plant-wide visibility. The same pattern the shared Secrets UI relies on: a policy the
/// dashboard bypass handler cannot satisfy because the requirement type differs.
/// </remarks>
/// <param name="requiredRoles">Roles any one of which satisfies the requirement.</param>
public sealed class AuthenticatedDashboardRoleRequirement(IReadOnlyList<string> requiredRoles)
: IAuthorizationRequirement
{
/// <summary>Gets the roles, any one of which satisfies this requirement.</summary>
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
/// <summary>
/// Gets the requirement satisfied by an authenticated caller holding either dashboard role.
/// Same role set as <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>; the
/// difference is entirely that no environment bypass applies.
/// </summary>
public static AuthenticatedDashboardRoleRequirement AnyDashboardRole { get; } =
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
}
@@ -12,7 +12,14 @@
<NavRailItem Href="/sessions" Text="Sessions" />
<NavRailItem Href="/workers" Text="Workers" />
<NavRailItem Href="/events" Text="Events" />
<NavRailItem Href="/alarms" Text="Alarms" />
@* Gated on the SAME policy the /alarms page and AlarmsHub enforce (SEC-40), for the
same reason the Secrets link below is: the page denies an environment-bypass
caller outright, so an ungated link would be a dead end. *@
<AuthorizeView Policy="@DashboardAuthenticationDefaults.AlarmsPolicy">
<Authorized>
<NavRailItem Href="/alarms" Text="Alarms" />
</Authorized>
</AuthorizeView>
</NavRailSection>
<NavRailSection Title="Galaxy" Key="galaxy">
<NavRailItem Href="/galaxy" Text="Repository" />
@@ -1,6 +1,15 @@
@page "/alarms"
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.MxGateway.Server.Alarms
@* SEC-40: the alarm feed needs a real authenticated dashboard role, not the read-only
environment bypasses (Authentication:Mode=Disabled, anonymous localhost) that satisfy the
ViewerPolicy MapRazorComponents<App>() applies. This page reads the gateway-central alarm
monitor in process, exactly the surface AlarmsHub serves to external clients, so it carries the
SAME policy as the hub — gating only the hub would leave the disclosure one navigation away.
Alarm metadata stays galaxy-wide for an authenticated Viewer: the monitor has no per-session
provenance, so the SEC-25 tag ACL has nothing to intersect. *@
@attribute [Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
@inject IGatewayAlarmService AlarmService
@@ -33,6 +33,18 @@ public static class DashboardAuthenticationDefaults
/// </summary>
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
/// <summary>
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
/// (<c>Authentication:Mode=Disabled</c> and anonymous localhost) cannot satisfy it. The alarm
/// feed is galaxy-wide and session-less, so unlike a session event stream there is no tag
/// intersection to narrow it with — requiring a real role claim is the available narrowing.
/// </summary>
public const string AlarmsPolicy = "MxGateway.Dashboard.Alarms";
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
@@ -40,9 +52,13 @@ public static class DashboardAuthenticationDefaults
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
/// <c>MxGateway:Dashboard:GroupToTag</c>; read by <see cref="IDashboardSessionAcl"/>. A
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
/// default. Visibility only — it never grants data access.
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
/// this claim only as a FALLBACK, for a principal that carries no
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
/// payload carries tags but not groups); a cookie principal is re-resolved against current
/// options instead, so a stamped tag cannot outlive a config-revoked grant. A principal
/// carrying neither kind of claim is an empty-grant Viewer, which is the fail-closed default.
/// Visibility only — it never grants data access.
/// </summary>
public const string DashboardTagClaimType = "zb:dashboardtag";
@@ -177,8 +177,20 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme)
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
// and an external hub reader), but a requirement type DashboardAuthorizationHandler
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
authorization.AddPolicy(
DashboardAuthenticationDefaults.AlarmsPolicy,
policy => policy
.AddAuthenticationSchemes(
DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme)
.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
});
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
return services;
}
@@ -26,22 +26,53 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
/// is an empty-grant Viewer.
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> makes at token mint. It deliberately does
/// NOT trust the <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims
/// stamped into the cookie at login: those were resolved under whatever map was bound when the
/// user signed in, and the auth cookie survives the gateway restart that applies a
/// <c>GroupToTag</c> edit (options bind once at startup; data-protection keys persist). Trusting
/// them let a revoked grant live on for the sliding 8-hour cookie life, renewable indefinitely by
/// activity, on the one seam — cookie-authenticated — that the 5-minute hub-token bound did not
/// cover.
/// </para>
/// <para>
/// <strong>Fallback.</strong> A principal carrying no group claims at all falls back to its
/// stamped tag claims. That arm exists for the hub-token principal: <see cref="HubTokenService"/>
/// re-resolves the grant at mint and rehydrates it as tag claims only — the token payload carries
/// no LDAP groups — so re-resolution has already happened for that caller, one token lifetime ago
/// at worst. The fallback cannot re-open the cookie hole it replaces, because a cookie principal
/// with a tag claim always also carries the group claims it was derived from
/// (<see cref="DashboardAuthenticator"/> stamps both from one group list), and a login that
/// yielded no groups yields no tags either. A principal with neither kind of claim — anonymous
/// localhost included — is an empty-grant Viewer.
/// </para>
/// <para>
/// <strong>Revocation bounds this produces</strong> (mirrored in
/// <c>docs/GatewayDashboardDesign.md</c>): a <c>GroupToTag</c> entry removed from config takes
/// effect on the next subscribe after the restart that rebinds options, on both seams; an LDAP
/// group-membership revocation takes effect when the principal is rebuilt — at re-login (bounded
/// by the cookie's sliding 8-hour idle life) for the cookie seam, and within one 5-minute token
/// lifetime for the hub-token seam; an already-open subscription is never re-checked and lives
/// until it drops.
/// </para>
/// <para>
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
/// keep it there: the only per-call work is a claim scan, the group→tag map lookup, and a
/// session-registry lookup. A per-event re-check is deliberately not needed — a joined SignalR
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
/// while the subscription lives.
/// </para>
/// </remarks>
/// <param name="sessionManager">Registry the session id is resolved against.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
/// <param name="options">
/// Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c> and the
/// <c>Dashboard:GroupToTag</c> map the caller's grant is re-resolved through.
/// </param>
public sealed class DashboardSessionAcl(
ISessionManager sessionManager,
IOptions<GatewayOptions> options) : IDashboardSessionAcl
@@ -64,14 +95,53 @@ public sealed class DashboardSessionAcl(
return false;
}
DashboardOptions dashboard = options.Value.Dashboard;
if (session.Tags.Count == 0)
{
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
return dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
}
// Session.Tags is an ordinal-ignore-case set, so the containment test carries the
// comparison; scanning the claims (rather than materializing the grant) keeps this
// allocation-free beyond the claim enumerator.
return GrantIntersectsSessionTags(principal, session, dashboard);
}
private static bool GrantIntersectsSessionTags(
ClaimsPrincipal principal,
GatewaySession session,
DashboardOptions dashboard)
{
// Collected rather than streamed because MapGroupsToTags takes the whole group list;
// the list stays null (no allocation) for the hub-token principal, which carries none.
List<string>? groups = null;
foreach (Claim groupClaim in principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType))
{
(groups ??= []).Add(groupClaim.Value);
}
if (groups is not null)
{
// SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an
// OrdinalIgnoreCase set, so probing it with the session's tags carries the
// case-insensitive comparison in the same direction the claim scan below does.
IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags(
groups,
dashboard.GroupToTag);
foreach (string sessionTag in session.Tags)
{
if (granted.Contains(sessionTag))
{
return true;
}
}
return false;
}
// No group claims: the hub-token arm documented on the class. Session.Tags is an
// ordinal-ignore-case set, so the containment test carries the comparison; scanning the
// claims (rather than materializing the grant) keeps this allocation-free beyond the
// claim enumerator.
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
{
if (session.Tags.Contains(tagClaim.Value))
@@ -9,7 +9,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <see cref="AllAlarmsGroup"/> on connect and receive every
/// <c>AlarmFeedMessage</c> the monitor emits.
/// </summary>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
/// <remarks>
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate:
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
/// longer receive the feed. The <c>/alarms</c> page carries the same policy — the two readers of
/// one monitor must not disagree about who may see it.
/// </remarks>
[Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
public sealed class AlarmsHub : Hub
{
public const string AllAlarmsGroup = "__alarms__";
@@ -22,6 +22,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// The source message is never mutated: it fans out from one feed to the gRPC
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
/// those audiences is subject to this dashboard-display flag.
/// <para>
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
/// admission — <see cref="AlarmsHub"/> carries
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
/// monitor is gateway-central with no per-session provenance, so there is no per-connection
/// narrowing this loop could apply even in principle. Adding a filter here would imply a
/// per-caller scope the feed does not carry.
/// </para>
/// </remarks>
/// <param name="alarmService">The gateway's central alarm feed.</param>
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
@@ -13,7 +14,9 @@ using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Renders <see cref="MainLayout"/> and asserts whether the side rail emits the Secrets link.
/// 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>
@@ -39,6 +42,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
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
@@ -95,11 +99,48 @@ public sealed class SecretsNavRenderTests
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());
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>();
@@ -0,0 +1,198 @@
using System.Net;
using System.Reflection;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.AuthenticationOptions;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only
/// environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no longer
/// receive galaxy-wide alarm metadata.
/// </summary>
/// <remarks>
/// The enforcement is a requirement TYPE distinction — <see cref="DashboardAuthorizationHandler"/>
/// is an <c>AuthorizationHandler&lt;DashboardAuthorizationRequirement&gt;</c> and therefore never
/// runs for <see cref="AuthenticatedDashboardRoleRequirement"/> — so the tests below assert both
/// halves: that both handlers together still deny a bypassed caller, and that the policy the two
/// alarm surfaces actually carry is built from that requirement rather than the bypassable one.
/// Asserting only the handler would pass even if the hub were left on the old policy.
/// </remarks>
public sealed class AlarmFeedAuthorizationTests
{
/// <summary>
/// The bypass that satisfies <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>
/// — auth disabled AND a loopback request with <c>AllowAnonymousLocalhost</c> on, i.e. both
/// arms at once — does not satisfy the alarm requirement, with the real bypass handler present
/// in the same evaluation.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmRequirement_AnonymousLocalhostAndAuthDisabled_DoesNotSucceed()
{
AuthorizationHandlerContext context = await EvaluateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
/// <summary>
/// Control: the SAME evaluation environment satisfies the bypassable requirement. Without this
/// pairing the test above could pass because the bypass was misconfigured rather than because
/// the requirement type excludes it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ViewerRequirement_AnonymousLocalhostAndAuthDisabled_StillSucceeds()
{
AuthorizationHandlerContext context = await EvaluateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded);
}
/// <summary>
/// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the
/// alarm requirement: SEC-40 excludes the environment bypasses, not Viewers. Alarm metadata
/// stays galaxy-wide for them because the monitor is gateway-central and carries no
/// per-session provenance to tag-scope by.
/// </summary>
/// <param name="role">The dashboard role the authenticated caller holds.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(DashboardRoles.Viewer)]
[InlineData(DashboardRoles.Admin)]
public async Task AlarmRequirement_AuthenticatedDashboardRole_Succeeds(string role)
{
AuthorizationHandlerContext context = await EvaluateAsync(
CreatePrincipal(role),
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded);
}
/// <summary>
/// A principal carrying a dashboard role claim but no authentication type does not satisfy the
/// requirement: "real role claim" means an authenticated identity, matching how the session
/// ACL and <c>DashboardSessionAdminService</c> read a role.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmRequirement_UnauthenticatedRoleClaim_DoesNotSucceed()
{
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: null,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
AuthorizationHandlerContext context = await EvaluateAsync(
principal,
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
/// <summary>
/// The registered policy is built from the non-bypassable requirement and from neither
/// <see cref="DashboardAuthorizationRequirement"/> nor a role list the bypass handler could
/// reach, and it still accepts both hub-capable schemes so an external hub client can
/// authenticate with a bearer token.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPolicy_IsRegisteredWithNonBypassableRequirement()
{
await using WebApplication app = GatewayApplication.Build([]);
IAuthorizationPolicyProvider provider = app.Services
.GetRequiredService<IAuthorizationPolicyProvider>();
AuthorizationPolicy? policy = await provider
.GetPolicyAsync(DashboardAuthenticationDefaults.AlarmsPolicy);
Assert.NotNull(policy);
Assert.Contains(policy.Requirements, r => r is AuthenticatedDashboardRoleRequirement);
Assert.DoesNotContain(policy.Requirements, r => r is DashboardAuthorizationRequirement);
Assert.Equal(
[
DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme,
],
policy.AuthenticationSchemes);
}
/// <summary>
/// Both readers of the one central monitor carry the alarm policy: the hub (external clients)
/// and the <c>/alarms</c> Razor endpoint (the in-page reader). Gating only the hub would leave
/// the same metadata one navigation away for a bypassed caller.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsHubAndAlarmsPage_BothCarryTheAlarmsPolicy()
{
AuthorizeAttribute? hubAttribute = typeof(AlarmsHub)
.GetCustomAttribute<AuthorizeAttribute>(inherit: false);
Assert.Equal(DashboardAuthenticationDefaults.AlarmsPolicy, hubAttribute?.Policy);
await using WebApplication app = GatewayApplication.Build([]);
RouteEndpoint alarmsPage = ((IEndpointRouteBuilder)app).DataSources
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.Single(endpoint => endpoint.RoutePattern.RawText == "/alarms");
Assert.Contains(
alarmsPage.Metadata.OfType<IAuthorizeData>(),
data => data.Policy == DashboardAuthenticationDefaults.AlarmsPolicy);
}
/// <summary>
/// Runs BOTH dashboard authorization handlers over one requirement in the most permissive
/// environment the gateway supports (auth disabled, loopback, anonymous localhost allowed),
/// mirroring how the authorization service invokes every registered handler.
/// </summary>
/// <param name="principal">The caller.</param>
/// <param name="requirement">The single requirement under evaluation.</param>
/// <returns>The context after both handlers have run.</returns>
private static async Task<AuthorizationHandlerContext> EvaluateAsync(
ClaimsPrincipal principal,
IAuthorizationRequirement requirement)
{
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = IPAddress.Loopback;
IOptions<GatewayOptions> options = Options.Create(new GatewayOptions
{
Authentication = new AuthenticationOptions { Mode = AuthenticationMode.Disabled },
Dashboard = new DashboardOptions { AllowAnonymousLocalhost = true },
});
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
await new DashboardAuthorizationHandler(
new HttpContextAccessor { HttpContext = httpContext },
options).HandleAsync(context);
await new AuthenticatedDashboardRoleHandler().HandleAsync(context);
return context;
}
private static ClaimsPrincipal CreatePrincipal(string role) =>
new(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role));
}
@@ -178,12 +178,112 @@ public sealed class DashboardSessionAclTests
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)
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly,
Dictionary<string, string[]>? groupToTag = null)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
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(
@@ -193,13 +293,26 @@ public sealed class DashboardSessionAclTests
Options.Create(options));
}
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
/// <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,