dashboard: role-based LDAP auth + hub bearer scheme, drop PathBase

Restructure dashboard auth around LDAP-driven Admin/Viewer roles, add a
bearer scheme so SignalR hubs (next commit) can authenticate without
forwarding the HttpOnly browser cookie, and mount the dashboard at the
host root instead of a configurable `/dashboard` prefix.

Configuration changes (breaking):
- `MxGateway:Dashboard:PathBase` removed — the dashboard now serves at `/`.
- `MxGateway:Dashboard:RequireAdminScope` removed — role checks replace
  the single admin-scope claim.
- `MxGateway:Ldap:RequiredGroup` removed — replaced by `MxGateway:Dashboard:GroupToRole`,
  a map from LDAP group name to dashboard role. Legal role values:
  `Admin` and `Viewer`. Users whose LDAP groups don't intersect this
  map are rejected at login (the existing fail-closed contract).
- appsettings.json ships a default mapping `{ GwAdmin: Admin, GwReader: Viewer }`.

Auth model:
- DashboardRoles: new static class with `Admin` and `Viewer` constants.
- DashboardAuthenticator.AuthenticateAsync: after LDAP bind, maps the
  user's groups through `DashboardOptions.GroupToRole` and emits one
  `ClaimTypes.Role` claim per resolved role. Empty result → login fails.
- DashboardAuthorizationRequirement now carries `RequiredRoles`; static
  presets `AnyDashboardRole` (Viewer ∨ Admin) and `AdminOnly`.
- DashboardAuthorizationHandler checks `IsInRole` against the
  requirement's role list instead of the old scope claim. The
  `AuthenticationMode.Disabled` and `AllowAnonymousLocalhost` bypasses
  are preserved.
- DashboardApiKeyAuthorization.CanManage now requires the `Admin` role
  (was: required LDAP group membership). The constructor's IOptions
  parameter is gone.

Policies / schemes:
- DashboardAuthenticationDefaults gains `ViewerPolicy`, `AdminPolicy`,
  `HubClientsPolicy`, and `HubAuthenticationScheme`. The legacy
  `AuthorizationPolicy` and `ScopeClaimType` constants are removed.
- DashboardServiceCollectionExtensions registers all three policies,
  adds the cookie scheme and the HubToken bearer scheme side by side,
  calls `AddSignalR()`, and hard-codes the cookie's login/logout/denied
  paths to root-relative `/login` etc.

Hub bearer infrastructure (no hubs wired yet — next commit):
- HubTokenService: mints time-limited data-protected JSON tokens
  carrying the user's name, NameIdentifier, and roles. 30-minute
  lifetime, purpose `ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`.
- HubTokenAuthenticationHandler: validates the token from
  `Authorization: Bearer …` or `?access_token=…` (WebSocket upgrade
  query string) and rebuilds the principal.

Endpoint mapping:
- DashboardEndpointRouteBuilderExtensions drops the `MapGroup(pathBase)`
  wrapper. Login/logout/denied and Razor component routes are now
  mounted at `/`. The login form posts to `/login`. Razor components
  require the new `ViewerPolicy`.
- All page `@page "/dashboard/X"` dual-route directives are removed —
  pages live at their canonical roots (`@page "/"`, `@page "/sessions"`, …).
- App.razor and DashboardLayout.razor drop their PathBase computations.

EffectiveLdapConfiguration drops `RequiredGroup`; EffectiveDashboardConfiguration
drops `PathBase`/`RequireAdminScope` and gains `GroupToRole`. SettingsPage
renders the role mapping in place of the retired fields.

Tests updated:
- DashboardAuthenticatorTests: covers the new GroupToRole mapping
  (short name + DN + multi-role).
- DashboardAuthorizationHandlerTests: split into Viewer-policy and
  Admin-policy cases.
- DashboardApiKeyAuthorizationTests, DashboardApiKeyManagementServiceTests:
  authorized principal now carries the `Admin` role claim.
- DashboardCookieOptionsTests: expects root-relative login/logout paths.
- GatewayApplicationTests: dashboard component routes registered at `/`,
  `/sessions`, … and gated by `ViewerPolicy`. Filter on
  `ComponentTypeMetadata` to ignore minimal-API endpoints sharing `/`.
- GatewayOptionsTests + Validator: drop PathBase / RequireAdminScope /
  RequiredGroup assertions; add a `GroupToRole` value-validation case.
- DashboardLdapLiveTests: provides the default `GwAdmin` → `Admin`
  mapping so the live LDAP bind resolves to a role.

Verification: 473 server tests, 275 worker tests (+9 dev-rig skips), 18
integration tests (live MxAccess + LDAP + Galaxy) all pass.

This commit is intentionally UI-neutral. The sidebar layout and the
SignalR hubs that consume the new HubToken scheme land in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 01:38:33 -04:00
parent 397d3c5c4f
commit 27ed65114e
37 changed files with 509 additions and 340 deletions
@@ -106,7 +106,16 @@ public sealed class DashboardLdapLiveTests
private static DashboardAuthenticator CreateAuthenticator()
{
return new DashboardAuthenticator(
Options.Create(new GatewayOptions()),
Options.Create(new GatewayOptions
{
Dashboard = new DashboardOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
},
},
}),
NullLogger<DashboardAuthenticator>.Instance);
}
}
@@ -5,12 +5,6 @@ public sealed class DashboardOptions
/// <summary>Gets whether the dashboard is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the dashboard URL path base.</summary>
public string PathBase { get; init; } = "/dashboard";
/// <summary>Gets whether dashboard access requires admin scope.</summary>
public bool RequireAdminScope { get; init; } = true;
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true;
@@ -25,4 +19,11 @@ public sealed class DashboardOptions
/// <summary>Gets whether to show full tag values in the dashboard.</summary>
public bool ShowTagValues { get; init; }
/// <summary>
/// LDAP group → dashboard role mapping. Values must be one of
/// <see cref="DashboardRoles.Admin"/> or <see cref="DashboardRoles.Viewer"/>.
/// Users with no matching group are rejected at login.
/// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
@@ -2,10 +2,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed record EffectiveDashboardConfiguration(
bool Enabled,
string PathBase,
bool RequireAdminScope,
bool AllowAnonymousLocalhost,
int SnapshotIntervalMilliseconds,
int RecentFaultLimit,
int RecentSessionLimit,
bool ShowTagValues);
bool ShowTagValues,
IReadOnlyDictionary<string, string> GroupToRole);
@@ -11,5 +11,4 @@ public sealed record EffectiveLdapConfiguration(
string ServiceAccountPassword,
string UserNameAttribute,
string DisplayNameAttribute,
string GroupAttribute,
string RequiredGroup);
string GroupAttribute);
@@ -30,8 +30,7 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
ServiceAccountPassword: RedactedValue,
UserNameAttribute: value.Ldap.UserNameAttribute,
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
GroupAttribute: value.Ldap.GroupAttribute,
RequiredGroup: value.Ldap.RequiredGroup),
GroupAttribute: value.Ldap.GroupAttribute),
Worker: new EffectiveWorkerConfiguration(
ExecutablePath: value.Worker.ExecutablePath,
WorkingDirectory: value.Worker.WorkingDirectory,
@@ -53,13 +52,12 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
Dashboard: new EffectiveDashboardConfiguration(
Enabled: value.Dashboard.Enabled,
PathBase: value.Dashboard.PathBase,
RequireAdminScope: value.Dashboard.RequireAdminScope,
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
ShowTagValues: value.Dashboard.ShowTagValues),
ShowTagValues: value.Dashboard.ShowTagValues,
GroupToRole: value.Dashboard.GroupToRole),
Protocol: new EffectiveProtocolConfiguration(
value.Protocol.WorkerProtocolVersion,
value.Protocol.MaxGrpcMessageBytes));
@@ -86,10 +86,6 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
options.GroupAttribute,
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.RequiredGroup,
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
failures);
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
if (!options.UseTls && !options.AllowInsecureLdap)
@@ -206,12 +202,23 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
{
if (options.Enabled)
// GroupToRole shape is validated even when the dashboard is disabled so
// misconfiguration surfaces at startup; emptiness is allowed, with the
// consequence that no LDAP user can sign in (login returns "no roles
// mapped"). Operators who disable the dashboard or want a closed
// deployment can ship without a mapping.
foreach (KeyValuePair<string, string> entry in options.GroupToRole)
{
AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures);
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
if (string.IsNullOrWhiteSpace(entry.Key))
{
failures.Add("MxGateway:Dashboard:PathBase must start with '/'.");
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
}
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
{
failures.Add(
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
}
}
@@ -23,6 +23,4 @@ public sealed class LdapOptions
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
public string RequiredGroup { get; init; } = "GwAdmin";
}
@@ -1,11 +1,9 @@
@inject IOptions<GatewayOptions> GatewayOptions
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="@DashboardBaseHref" />
<base href="/" />
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" />
@@ -17,19 +15,3 @@
<script src="/_framework/blazor.web.js"></script>
</body>
</html>
@code {
private string DashboardBaseHref
{
get
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}/";
}
}
}
@@ -1,33 +1,32 @@
@inherits LayoutComponentBase
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell">
<header class="app-bar">
<a class="brand" href=""><span class="mark">&#9646;</span> MXAccess Gateway</a>
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<nav class="app-nav">
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="sessions">Sessions</NavLink>
<NavLink href="workers">Workers</NavLink>
<NavLink href="events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink>
<NavLink href="browse">Browse</NavLink>
<NavLink href="alarms">Alarms</NavLink>
<NavLink href="apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink>
<NavLink href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="/sessions">Sessions</NavLink>
<NavLink href="/workers">Workers</NavLink>
<NavLink href="/events">Events</NavLink>
<NavLink href="/galaxy">Galaxy</NavLink>
<NavLink href="/browse">Browse</NavLink>
<NavLink href="/alarms">Alarms</NavLink>
<NavLink href="/apikeys">API Keys</NavLink>
<NavLink href="/settings">Settings</NavLink>
</nav>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<form method="post" action="/logout">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
<a class="btn btn-outline-secondary btn-sm" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</header>
@@ -35,16 +34,3 @@
@Body
</main>
</div>
@code {
private string DashboardPath(string relativePath)
{
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
return $"{pathBase}{relativePath}";
}
}
@@ -1,5 +1,4 @@
@page "/alarms"
@page "/dashboard/alarms"
@implements IAsyncDisposable
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
@@ -1,5 +1,4 @@
@page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardApiKeyManagementService ApiKeyManagementService
@@ -1,5 +1,4 @@
@page "/browse"
@page "/dashboard/browse"
@implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData
@@ -1,5 +1,4 @@
@page "/"
@page "/dashboard/"
@inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
@@ -1,5 +1,4 @@
@page "/events"
@page "/dashboard/events"
@inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle>
@@ -1,5 +1,4 @@
@page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions/{SessionId}"
@page "/dashboard/sessions/{SessionId}"
@inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions"
@page "/dashboard/sessions"
@inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle>
@@ -1,5 +1,4 @@
@page "/settings"
@page "/dashboard/settings"
@inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle>
@@ -33,7 +32,24 @@ else
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
<tr>
<th scope="row">Dashboard role mapping</th>
<td>
@if (Snapshot.Configuration.Dashboard.GroupToRole.Count == 0)
{
<span class="text-muted">(none configured)</span>
}
else
{
<ul class="mb-0">
@foreach (KeyValuePair<string, string> pair in Snapshot.Configuration.Dashboard.GroupToRole)
{
<li><code>@pair.Key</code> → @pair.Value</li>
}
</ul>
}
</td>
</tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
@@ -44,8 +60,6 @@ else
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
@@ -1,5 +1,4 @@
@page "/workers"
@page "/dashboard/workers"
@inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle>
@@ -1,10 +1,8 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
public sealed class DashboardApiKeyAuthorization
{
public bool CanManage(ClaimsPrincipal user)
{
@@ -13,10 +11,6 @@ public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> option
return false;
}
string requiredGroup = options.Value.Ldap.RequiredGroup;
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value);
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
return user.IsInRole(DashboardRoles.Admin);
}
}
@@ -2,9 +2,37 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardAuthenticationDefaults
{
/// <summary>Cookie scheme used to authenticate interactive dashboard requests.</summary>
public const string AuthenticationScheme = "MxGateway.Dashboard";
public const string AuthorizationPolicy = "MxGateway.Dashboard";
public const string ScopeClaimType = "scope";
/// <summary>
/// Bearer scheme used to authenticate SignalR hub connections. Hubs cannot
/// reuse the browser's HttpOnly cookie when the client SignalR JS resolves
/// the cookie scope to loopback, so the dashboard mints a short-lived
/// data-protected bearer token on demand.
/// </summary>
public const string HubAuthenticationScheme = "MxGateway.Dashboard.HubToken";
/// <summary>
/// Policy guarding read-only dashboard pages. Allowed roles:
/// <see cref="DashboardRoles.Admin"/> or <see cref="DashboardRoles.Viewer"/>.
/// </summary>
public const string ViewerPolicy = "MxGateway.Dashboard.Viewer";
/// <summary>
/// Policy guarding write-capable dashboard pages and admin endpoints
/// (API-key CRUD, settings edits, hub publishes). Allowed role:
/// <see cref="DashboardRoles.Admin"/>.
/// </summary>
public const string AdminPolicy = "MxGateway.Dashboard.Admin";
/// <summary>
/// Policy attached to SignalR hubs — accepts either the cookie scheme
/// (in-page connect) or the hub-token bearer scheme (cross-origin or
/// WebSocket connect where the cookie can't be forwarded).
/// </summary>
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
@@ -20,6 +20,7 @@ public sealed class DashboardAuthenticator(
CancellationToken cancellationToken)
{
LdapOptions ldapOptions = options.Value.Ldap;
DashboardOptions dashboardOptions = options.Value.Dashboard;
if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password))
@@ -79,12 +80,12 @@ public sealed class DashboardAuthenticator(
?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
if (roles.Count == 0)
{
logger.LogInformation(
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
normalizedUsername,
ldapOptions.RequiredGroup);
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
@@ -92,7 +93,8 @@ public sealed class DashboardAuthenticator(
return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername,
displayName,
groups));
groups,
roles));
}
catch (OperationCanceledException)
{
@@ -134,28 +136,32 @@ public sealed class DashboardAuthenticator(
return builder.ToString();
}
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
/// <summary>
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
/// multiple roles; Admin and Viewer are the only legal values. Returns
/// an empty list when no group matches (caller rejects the login).
/// </summary>
internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
{
string normalizedRequiredGroup = requiredGroup.Trim();
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
if (groupToRole.Count == 0)
{
return false;
return [];
}
HashSet<string> roles = new(StringComparer.Ordinal);
foreach (string group in groups)
{
string normalizedGroup = group.Trim();
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|| string.Equals(
ExtractFirstRdnValue(normalizedGroup),
normalizedRequiredGroup,
StringComparison.OrdinalIgnoreCase))
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
return true;
roles.Add(mapped);
}
}
return false;
return [.. roles];
}
internal static string ExtractFirstRdnValue(string distinguishedName)
@@ -237,20 +243,16 @@ public sealed class DashboardAuthenticator(
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable<string> groups)
IEnumerable<string> groups,
IEnumerable<string> roles)
{
// CreatePrincipal is reached only after IsMemberOfRequiredGroup passed,
// so the authenticated user is authorized for the dashboard. Emit the
// admin scope claim that DashboardAuthorizationHandler checks when
// Dashboard:RequireAdminScope is enabled — without it, every LDAP login
// would be denied once route-level authorization is enforced.
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, displayName),
new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin)
];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
@@ -259,7 +261,7 @@ public sealed class DashboardAuthenticator(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
DashboardAuthenticationDefaults.LdapGroupClaimType);
ClaimTypes.Role);
return new ClaimsPrincipal(claimsIdentity);
}
@@ -2,10 +2,15 @@ using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authorizes a dashboard request by checking either: (a) the LDAP-issued
/// role claim satisfies <see cref="DashboardAuthorizationRequirement.RequiredRoles"/>,
/// (b) authentication is fully disabled, or (c) the request is from loopback
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
/// </summary>
public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
@@ -36,9 +41,13 @@ public sealed class DashboardAuthorizationHandler(
return Task.CompletedTask;
}
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
foreach (string role in requirement.RequiredRoles)
{
context.Succeed(requirement);
if (context.User.IsInRole(role))
{
context.Succeed(requirement);
return Task.CompletedTask;
}
}
return Task.CompletedTask;
@@ -50,11 +59,4 @@ public sealed class DashboardAuthorizationHandler(
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
}
private static bool HasAdminScope(AuthorizationHandlerContext context)
{
return context.User.HasClaim(
DashboardAuthenticationDefaults.ScopeClaimType,
GatewayScopes.Admin);
}
}
@@ -2,4 +2,17 @@ using Microsoft.AspNetCore.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
/// <summary>
/// Requirement satisfied when the user has any one of the listed roles, or
/// when an environment-level bypass (auth disabled / loopback) is in effect.
/// </summary>
public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement
{
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
public static DashboardAuthorizationRequirement AdminOnly { get; } =
new([DashboardRoles.Admin]);
}
@@ -24,73 +24,64 @@ public static class DashboardEndpointRouteBuilderExtensions
return endpoints;
}
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
endpoints.MapGet(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
.AllowAnonymous()
.WithName("DashboardLogin");
dashboard.MapPost(
endpoints.MapPost(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous()
.WithName("DashboardLoginPost");
dashboard.MapPost(
endpoints.MapPost(
"/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery))
.AllowAnonymous()
.WithName("DashboardLogout");
dashboard.MapGet("/denied", () => Results.Content(
endpoints.MapGet("/denied", () => Results.Content(
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
"text/html"))
.AllowAnonymous()
.WithName("DashboardAccessDenied");
// Every dashboard Razor component requires an authorized session. The
// Every dashboard Razor component requires a viewer-or-admin role. The
// login/logout/denied endpoints above opt out via AllowAnonymous(); an
// unauthenticated request to a component route is challenged by the
// cookie scheme and redirected to the login page.
dashboard.MapRazorComponents<App>()
// cookie scheme and redirected to /login.
endpoints.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
.RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);
return endpoints;
}
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
IAntiforgery antiforgery)
{
string returnUrl = SanitizeReturnUrl(
httpContext.Request.Query["returnUrl"].ToString(),
pathBase);
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
"text/html"));
}
private static async Task<IResult> PostLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardAuthenticator authenticator,
string pathBase)
IDashboardAuthenticator authenticator)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
IFormCollection form = await httpContext.Request
.ReadFormAsync(httpContext.RequestAborted)
.ConfigureAwait(false);
string returnUrl = SanitizeReturnUrl(
form["returnUrl"].ToString(),
pathBase);
string returnUrl = SanitizeReturnUrl(form["returnUrl"].ToString());
DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync(
@@ -102,7 +93,7 @@ public static class DashboardEndpointRouteBuilderExtensions
if (!result.Succeeded || result.Principal is null)
{
return TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
"text/html",
statusCode: StatusCodes.Status401Unauthorized);
}
@@ -116,22 +107,20 @@ public static class DashboardEndpointRouteBuilderExtensions
private static async Task<IResult> PostLogoutAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
IAntiforgery antiforgery)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
await httpContext
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return Results.LocalRedirect($"{pathBase}/login");
return Results.LocalRedirect("/login");
}
private static string RenderLoginPage(
HttpContext httpContext,
IAntiforgery antiforgery,
string returnUrl,
string pathBase,
string? failureMessage)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
@@ -143,7 +132,7 @@ public static class DashboardEndpointRouteBuilderExtensions
string body = $"""
<section class="dashboard-login">
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
<form method="post" action="/login" class="card login-card">
<div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
@@ -193,24 +182,14 @@ public static class DashboardEndpointRouteBuilderExtensions
""";
}
private static string NormalizePathBase(string pathBase)
{
string normalized = pathBase.TrimEnd('/');
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
? "/dashboard"
: normalized;
}
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
private static string SanitizeReturnUrl(string? returnUrl)
{
if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
{
return pathBase;
return "/";
}
return returnUrl;
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Role names assigned to authenticated dashboard users. The role is derived
/// from the user's LDAP groups via <c>MxGateway:Dashboard:GroupToRole</c>.
/// </summary>
public static class DashboardRoles
{
/// <summary>
/// Read-write access: API-key CRUD, settings, any state-changing UI.
/// </summary>
public const string Admin = "Admin";
/// <summary>
/// Read-only access: all pages render but write affordances are hidden.
/// </summary>
public const string Viewer = "Viewer";
}
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -21,46 +20,51 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<HubTokenService>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
services.AddRazorComponents()
.AddInteractiveServerComponents();
services.AddSignalR();
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
services.AddAuthorization(options =>
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
{
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = "/login";
cookieOptions.LogoutPath = "/logout";
cookieOptions.AccessDeniedPath = "/denied";
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
cookieOptions.SlidingExpiration = true;
})
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
services.AddAuthorization(authorization =>
{
options.AddPolicy(
DashboardAuthenticationDefaults.AuthorizationPolicy,
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
authorization.AddPolicy(
DashboardAuthenticationDefaults.ViewerPolicy,
policy => policy.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
authorization.AddPolicy(
DashboardAuthenticationDefaults.AdminPolicy,
policy => policy.AddRequirements(DashboardAuthorizationRequirement.AdminOnly));
authorization.AddPolicy(
DashboardAuthenticationDefaults.HubClientsPolicy,
policy => policy
.AddAuthenticationSchemes(
DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme)
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
});
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
return services;
}
private static void ConfigureCookieOptions(
CookieAuthenticationOptions cookieOptions,
IOptions<GatewayOptions> gatewayOptions)
{
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = $"{pathBase}/login";
cookieOptions.LogoutPath = $"{pathBase}/logout";
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
cookieOptions.SlidingExpiration = true;
}
}
@@ -0,0 +1,57 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates SignalR hub connections by extracting a bearer token from
/// either the <c>Authorization</c> header or the <c>access_token</c> query
/// string (used by WebSocket upgrade requests that can't carry custom headers)
/// and validating it via <see cref="HubTokenService"/>.
/// </summary>
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly HubTokenService _tokens;
public HubTokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
HubTokenService tokens)
: base(options, logger, encoder)
{
_tokens = tokens;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string? token = ExtractToken();
if (string.IsNullOrEmpty(token))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
System.Security.Claims.ClaimsPrincipal? principal = _tokens.Validate(token);
if (principal is null)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid or expired hub token."));
}
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(principal, Scheme.Name)));
}
private string? ExtractToken()
{
string header = Request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return header["Bearer ".Length..].Trim();
}
return Request.Query.TryGetValue("access_token", out Microsoft.Extensions.Primitives.StringValues queryToken)
? queryToken.ToString()
: null;
}
}
@@ -0,0 +1,81 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Mints and validates short-lived bearer tokens for SignalR hub connections.
/// The token is a data-protected JSON payload containing the user's name and
/// role claims. Validity is enforced by the data-protection time-limited
/// protector; no separate signing keys are configured.
/// </summary>
public sealed class HubTokenService
{
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
private readonly ITimeLimitedDataProtector _protector;
public HubTokenService(IDataProtectionProvider dataProtection)
{
ArgumentNullException.ThrowIfNull(dataProtection);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
}
/// <summary>Issue a bearer token carrying the user's identity and roles.</summary>
public string Issue(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
HubTokenPayload payload = new(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
}
/// <summary>Validate a token and return the equivalent <see cref="ClaimsPrincipal"/>; null when invalid or expired.</summary>
public ClaimsPrincipal? Validate(string? token)
{
if (string.IsNullOrEmpty(token))
{
return null;
}
try
{
HubTokenPayload? payload = JsonSerializer.Deserialize<HubTokenPayload>(_protector.Unprotect(token));
if (payload is null)
{
return null;
}
List<Claim> claims = [];
if (!string.IsNullOrEmpty(payload.Name))
{
claims.Add(new Claim(ClaimTypes.Name, payload.Name));
}
if (!string.IsNullOrEmpty(payload.NameIdentifier))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, payload.NameIdentifier));
}
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
ClaimsIdentity identity = new(
claims,
DashboardAuthenticationDefaults.HubAuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
catch (Exception ex) when (ex is CryptographicException or JsonException)
{
return null;
}
}
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
}
@@ -24,8 +24,7 @@
"ServiceAccountPassword": "serviceaccount123",
"UserNameAttribute": "cn",
"DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf",
"RequiredGroup": "GwAdmin"
"GroupAttribute": "memberOf"
},
"Worker": {
"ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe",
@@ -50,13 +49,15 @@
},
"Dashboard": {
"Enabled": true,
"PathBase": "/dashboard",
"RequireAdminScope": true,
"AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100,
"RecentSessionLimit": 200,
"ShowTagValues": false
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwReader": "Viewer"
}
},
"Protocol": {
"WorkerProtocolVersion": 1,
@@ -39,8 +39,6 @@ public sealed class GatewayOptionsTests
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
Assert.True(options.Dashboard.Enabled);
Assert.Equal("/dashboard", options.Dashboard.PathBase);
Assert.True(options.Dashboard.RequireAdminScope);
Assert.True(options.Dashboard.AllowAnonymousLocalhost);
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
Assert.Equal(100, options.Dashboard.RecentFaultLimit);
@@ -89,7 +87,7 @@ public sealed class GatewayOptionsTests
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
{
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
@@ -1,6 +1,4 @@
using System.Security.Claims;
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;
@@ -8,19 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests
{
[Fact]
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue()
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("GwAdmin");
Assert.True(authorization.CanManage(user));
}
[Fact]
public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local");
DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Admin);
Assert.True(authorization.CanManage(user));
}
@@ -28,37 +17,28 @@ public sealed class DashboardApiKeyAuthorizationTests
[Fact]
public void CanManage_AnonymousUser_ReturnsFalse()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = new(new ClaimsIdentity());
Assert.False(authorization.CanManage(user));
}
[Fact]
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse()
public void CanManage_AuthenticatedViewer_ReturnsFalse()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("ReadOnly");
DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Viewer);
Assert.False(authorization.CanManage(user));
}
private static DashboardApiKeyAuthorization CreateAuthorization()
{
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
}));
}
private static ClaimsPrincipal CreatePrincipal(string group)
private static ClaimsPrincipal CreatePrincipal(string role)
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)],
DashboardAuthenticationDefaults.AuthenticationScheme);
[new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
@@ -144,19 +144,11 @@ public sealed class DashboardApiKeyManagementServiceTests
FakeApiKeyAuditStore? auditStore = null,
FakeApiKeySecretHasher? hasher = null)
{
GatewayOptions options = new()
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
};
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(Options.Create(options)),
new DashboardApiKeyAuthorization(),
adminStore ?? new FakeApiKeyAdminStore(),
auditStore ?? new FakeApiKeyAuditStore(),
hasher ?? new FakeApiKeySecretHasher(),
@@ -178,8 +170,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private static ClaimsPrincipal CreateAuthorizedUser()
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")],
DashboardAuthenticationDefaults.AuthenticationScheme);
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
@@ -16,23 +16,47 @@ public sealed class DashboardAuthenticatorTests
}
[Theory]
[InlineData("GwAdmin", true)]
[InlineData("gwadmin", true)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
[InlineData("OtherGroup", false)]
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
string requiredGroup,
bool expected)
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
[InlineData("OtherGroup", null)]
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
string ldapGroup,
string? expectedRole)
{
string[] groups =
[
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
];
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
Assert.Equal(expected, result);
if (expectedRole is null)
{
Assert.Empty(roles);
}
else
{
Assert.Equal(expectedRole, Assert.Single(roles));
}
}
[Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
{
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
["GwAdmin", "GwReader"],
mapping);
Assert.Contains(DashboardRoles.Admin, roles);
Assert.Contains(DashboardRoles.Viewer, roles);
}
[Fact]
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
@@ -18,7 +17,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false);
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
@@ -30,7 +30,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Loopback,
allowAnonymousLocalhost: true);
allowAnonymousLocalhost: true,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded);
}
@@ -45,7 +46,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Loopback,
allowAnonymousLocalhost: false);
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
@@ -60,41 +62,70 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: true);
allowAnonymousLocalhost: true,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
/// <summary>Verifies that an authenticated user without any dashboard role fails the viewer requirement.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.EventsRead),
CreatePrincipal("SomeOtherRole"),
IPAddress.Loopback,
allowAnonymousLocalhost: false);
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded);
}
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
/// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
[Fact]
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.Admin),
CreatePrincipal(DashboardRoles.Viewer),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false);
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded);
}
/// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
[Fact]
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(DashboardRoles.Admin),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AdminOnly);
Assert.True(context.HasSucceeded);
}
/// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
[Fact]
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(DashboardRoles.Viewer),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AdminOnly);
Assert.False(context.HasSucceeded);
}
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
ClaimsPrincipal principal,
IPAddress remoteAddress,
bool allowAnonymousLocalhost)
bool allowAnonymousLocalhost,
DashboardAuthorizationRequirement requirement)
{
DashboardAuthorizationRequirement requirement = new();
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = remoteAddress;
DashboardAuthorizationHandler handler = new(
@@ -104,7 +135,6 @@ public sealed class DashboardAuthorizationHandlerTests
Dashboard = new DashboardOptions
{
AllowAnonymousLocalhost = allowAnonymousLocalhost,
RequireAdminScope = true
}
}));
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
@@ -114,11 +144,13 @@ public sealed class DashboardAuthorizationHandlerTests
return context;
}
private static ClaimsPrincipal CreatePrincipal(string scope)
private static ClaimsPrincipal CreatePrincipal(string role)
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
DashboardAuthenticationDefaults.AuthenticationScheme);
[new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
@@ -26,8 +26,8 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
Assert.Equal("/", options.Cookie.Path);
Assert.Equal("/dashboard/login", options.LoginPath);
Assert.Equal("/dashboard/logout", options.LogoutPath);
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
Assert.Equal("/login", options.LoginPath);
Assert.Equal("/logout", options.LogoutPath);
Assert.Equal("/denied", options.AccessDeniedPath);
}
}
@@ -44,11 +44,11 @@ public sealed class GatewayApplicationTests
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
Assert.Contains(endpoints, endpoint =>
@@ -74,19 +74,22 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
[Fact]
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
{
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] componentRoutes =
["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"];
// Razor-component endpoints are distinguished from minimal-API
// endpoints registered at the same path by the presence of
// ComponentTypeMetadata. Filter to those before checking auth.
string[] componentRoutes = ["/", "/sessions", "/workers", "/events", "/settings"];
foreach (string route in componentRoutes)
{
RouteEndpoint[] matches = endpoints
.Where(endpoint => endpoint.RoutePattern.RawText == route)
.Where(endpoint => endpoint.RoutePattern.RawText == route
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null)
.ToArray();
Assert.NotEmpty(matches);
@@ -94,51 +97,32 @@ public sealed class GatewayApplicationTests
{
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
Assert.NotNull(authorize);
Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy);
Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy);
});
}
}
/// <summary>
/// Server-020 reversal regression guard. The original Server-020 finding
/// incorrectly concluded that the duplicate <c>@page "/dashboard/X"</c>
/// directives were redundant because <c>MapGroup("/dashboard")</c>
/// would prepend the prefix to all dashboard Razor pages. In practice
/// Blazor SSR's <c>RouteTableFactory</c> matches against the raw
/// <c>@page</c> template values (not against the endpoint-route
/// prefix), so removing <c>@page "/dashboard/X"</c> left the dashboard
/// unreachable at runtime (every page returned HTTP 500 with "Unable
/// to find the provided template '/dashboard/'"). The duplicate
/// <c>@page</c> directives are restored, and as a side effect the
/// endpoint route table DOES carry the doubled <c>/dashboard/dashboard/X</c>
/// shape (because <c>MapGroup("/dashboard")</c> prefixes the already-prefixed
/// <c>@page "/dashboard/X"</c>). Those doubled endpoints are harmless —
/// no client requests <c>/dashboard/dashboard/X</c> — and removing them
/// requires either dropping <c>MapGroup</c> or the <c>@page</c>
/// prefix. This test asserts only the positive contract: every
/// dashboard page IS reachable under the canonical <c>/dashboard/X</c>
/// route, which is what the Blazor router actually serves.
/// </summary>
[Fact]
public async Task Build_WhenDashboardEnabled_RegistersCanonicalDashboardRoutes()
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
{
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] canonicalRoutes =
[
"/dashboard/",
"/dashboard/sessions",
"/dashboard/workers",
"/dashboard/events",
"/dashboard/settings",
"/dashboard/galaxy",
"/dashboard/apikeys",
"/dashboard/sessions/{SessionId}",
"/",
"/sessions",
"/workers",
"/events",
"/settings",
"/galaxy",
"/apikeys",
"/sessions/{SessionId}",
];
foreach (string canonical in canonicalRoutes)
{
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
}
}
@@ -148,8 +132,6 @@ public sealed class GatewayApplicationTests
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.DoesNotContain(endpoints, endpoint =>
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
Assert.DoesNotContain(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
"Dashboard",
@@ -174,13 +156,9 @@ public sealed class GatewayApplicationTests
"",
"MxGateway:Authentication:PepperSecretName is required")]
[InlineData(
"MxGateway:Dashboard:PathBase",
"dashboard",
"MxGateway:Dashboard:PathBase must start with '/'.")]
[InlineData(
"MxGateway:Ldap:RequiredGroup",
"",
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
"MxGateway:Dashboard:GroupToRole:GwAdmin",
"BogusRole",
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
[InlineData(
"MxGateway:Ldap:AllowInsecureLdap",
"false",