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() private static DashboardAuthenticator CreateAuthenticator()
{ {
return new DashboardAuthenticator( 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); NullLogger<DashboardAuthenticator>.Instance);
} }
} }
@@ -5,12 +5,6 @@ public sealed class DashboardOptions
/// <summary>Gets whether the dashboard is enabled.</summary> /// <summary>Gets whether the dashboard is enabled.</summary>
public bool Enabled { get; init; } = true; 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> /// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true; 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> /// <summary>Gets whether to show full tag values in the dashboard.</summary>
public bool ShowTagValues { get; init; } 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( public sealed record EffectiveDashboardConfiguration(
bool Enabled, bool Enabled,
string PathBase,
bool RequireAdminScope,
bool AllowAnonymousLocalhost, bool AllowAnonymousLocalhost,
int SnapshotIntervalMilliseconds, int SnapshotIntervalMilliseconds,
int RecentFaultLimit, int RecentFaultLimit,
int RecentSessionLimit, int RecentSessionLimit,
bool ShowTagValues); bool ShowTagValues,
IReadOnlyDictionary<string, string> GroupToRole);
@@ -11,5 +11,4 @@ public sealed record EffectiveLdapConfiguration(
string ServiceAccountPassword, string ServiceAccountPassword,
string UserNameAttribute, string UserNameAttribute,
string DisplayNameAttribute, string DisplayNameAttribute,
string GroupAttribute, string GroupAttribute);
string RequiredGroup);
@@ -30,8 +30,7 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
ServiceAccountPassword: RedactedValue, ServiceAccountPassword: RedactedValue,
UserNameAttribute: value.Ldap.UserNameAttribute, UserNameAttribute: value.Ldap.UserNameAttribute,
DisplayNameAttribute: value.Ldap.DisplayNameAttribute, DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
GroupAttribute: value.Ldap.GroupAttribute, GroupAttribute: value.Ldap.GroupAttribute),
RequiredGroup: value.Ldap.RequiredGroup),
Worker: new EffectiveWorkerConfiguration( Worker: new EffectiveWorkerConfiguration(
ExecutablePath: value.Worker.ExecutablePath, ExecutablePath: value.Worker.ExecutablePath,
WorkingDirectory: value.Worker.WorkingDirectory, WorkingDirectory: value.Worker.WorkingDirectory,
@@ -53,13 +52,12 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()), BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
Dashboard: new EffectiveDashboardConfiguration( Dashboard: new EffectiveDashboardConfiguration(
Enabled: value.Dashboard.Enabled, Enabled: value.Dashboard.Enabled,
PathBase: value.Dashboard.PathBase,
RequireAdminScope: value.Dashboard.RequireAdminScope,
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost, AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds, SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
RecentFaultLimit: value.Dashboard.RecentFaultLimit, RecentFaultLimit: value.Dashboard.RecentFaultLimit,
RecentSessionLimit: value.Dashboard.RecentSessionLimit, RecentSessionLimit: value.Dashboard.RecentSessionLimit,
ShowTagValues: value.Dashboard.ShowTagValues), ShowTagValues: value.Dashboard.ShowTagValues,
GroupToRole: value.Dashboard.GroupToRole),
Protocol: new EffectiveProtocolConfiguration( Protocol: new EffectiveProtocolConfiguration(
value.Protocol.WorkerProtocolVersion, value.Protocol.WorkerProtocolVersion,
value.Protocol.MaxGrpcMessageBytes)); value.Protocol.MaxGrpcMessageBytes));
@@ -86,10 +86,6 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
options.GroupAttribute, options.GroupAttribute,
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.", "MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
failures); 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); AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
if (!options.UseTls && !options.AllowInsecureLdap) if (!options.UseTls && !options.AllowInsecureLdap)
@@ -206,12 +202,23 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
private static void ValidateDashboard(DashboardOptions options, List<string> failures) 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(entry.Key))
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
{ {
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 DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf"; public string GroupAttribute { get; init; } = "memberOf";
public string RequiredGroup { get; init; } = "GwAdmin";
} }
@@ -1,11 +1,9 @@
@inject IOptions<GatewayOptions> GatewayOptions
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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="/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/css/theme.css" /> <link rel="stylesheet" href="/css/theme.css" />
<link rel="stylesheet" href="/css/dashboard.css" /> <link rel="stylesheet" href="/css/dashboard.css" />
@@ -17,19 +15,3 @@
<script src="/_framework/blazor.web.js"></script> <script src="/_framework/blazor.web.js"></script>
</body> </body>
</html> </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 @inherits LayoutComponentBase
@inject IOptions<GatewayOptions> GatewayOptions
<div class="dashboard-shell"> <div class="dashboard-shell">
<header class="app-bar"> <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"> <nav class="app-nav">
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink> <NavLink href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink href="sessions">Sessions</NavLink> <NavLink href="/sessions">Sessions</NavLink>
<NavLink href="workers">Workers</NavLink> <NavLink href="/workers">Workers</NavLink>
<NavLink href="events">Events</NavLink> <NavLink href="/events">Events</NavLink>
<NavLink href="galaxy">Galaxy</NavLink> <NavLink href="/galaxy">Galaxy</NavLink>
<NavLink href="browse">Browse</NavLink> <NavLink href="/browse">Browse</NavLink>
<NavLink href="alarms">Alarms</NavLink> <NavLink href="/alarms">Alarms</NavLink>
<NavLink href="apikeys">API Keys</NavLink> <NavLink href="/apikeys">API Keys</NavLink>
<NavLink href="settings">Settings</NavLink> <NavLink href="/settings">Settings</NavLink>
</nav> </nav>
<span class="spacer"></span> <span class="spacer"></span>
<AuthorizeView> <AuthorizeView>
<Authorized Context="authState"> <Authorized Context="authState">
<div class="app-user"> <div class="app-user">
<span class="meta">@authState.User.Identity?.Name</span> <span class="meta">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")"> <form method="post" action="/logout">
<AntiforgeryToken /> <AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button> <button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form> </form>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <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> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
</header> </header>
@@ -35,16 +34,3 @@
@Body @Body
</main> </main>
</div> </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 "/alarms"
@page "/dashboard/alarms"
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IDashboardLiveDataService LiveData @inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions @inject IOptions<GatewayOptions> GatewayOptions
@@ -1,5 +1,4 @@
@page "/apikeys" @page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase @inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardApiKeyManagementService ApiKeyManagementService @inject IDashboardApiKeyManagementService ApiKeyManagementService
@@ -1,5 +1,4 @@
@page "/browse" @page "/browse"
@page "/dashboard/browse"
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache @inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData @inject IDashboardLiveDataService LiveData
@@ -1,5 +1,4 @@
@page "/" @page "/"
@page "/dashboard/"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle> <PageTitle>MXAccess Gateway Dashboard</PageTitle>
@@ -1,5 +1,4 @@
@page "/events" @page "/events"
@page "/dashboard/events"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle> <PageTitle>Dashboard Events</PageTitle>
@@ -1,5 +1,4 @@
@page "/galaxy" @page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle> <PageTitle>Dashboard Galaxy</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions/{SessionId}" @page "/sessions/{SessionId}"
@page "/dashboard/sessions/{SessionId}"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle> <PageTitle>Dashboard Session</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions" @page "/sessions"
@page "/dashboard/sessions"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle> <PageTitle>Dashboard Sessions</PageTitle>
@@ -1,5 +1,4 @@
@page "/settings" @page "/settings"
@page "/dashboard/settings"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle> <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 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 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 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 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">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> <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">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">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 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">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">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> <tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
@@ -1,5 +1,4 @@
@page "/workers" @page "/workers"
@page "/dashboard/workers"
@inherits DashboardPageBase @inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle> <PageTitle>Dashboard Workers</PageTitle>
@@ -1,10 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options) public sealed class DashboardApiKeyAuthorization
{ {
public bool CanManage(ClaimsPrincipal user) public bool CanManage(ClaimsPrincipal user)
{ {
@@ -13,10 +11,6 @@ public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> option
return false; return false;
} }
string requiredGroup = options.Value.Ldap.RequiredGroup; return user.IsInRole(DashboardRoles.Admin);
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value);
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
} }
} }
@@ -2,9 +2,37 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardAuthenticationDefaults public static class DashboardAuthenticationDefaults
{ {
/// <summary>Cookie scheme used to authenticate interactive dashboard requests.</summary>
public const string AuthenticationScheme = "MxGateway.Dashboard"; 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 LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix"; public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard"; public const string CookieName = "__Host-MxGatewayDashboard";
@@ -20,6 +20,7 @@ public sealed class DashboardAuthenticator(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
LdapOptions ldapOptions = options.Value.Ldap; LdapOptions ldapOptions = options.Value.Ldap;
DashboardOptions dashboardOptions = options.Value.Dashboard;
if (!ldapOptions.Enabled if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password)) || string.IsNullOrWhiteSpace(password))
@@ -79,12 +80,12 @@ public sealed class DashboardAuthenticator(
?? normalizedUsername; ?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute); 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( logger.LogInformation(
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.", "LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
normalizedUsername, normalizedUsername);
ldapOptions.RequiredGroup);
return DashboardAuthenticationResult.Fail(GenericFailureMessage); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
} }
@@ -92,7 +93,8 @@ public sealed class DashboardAuthenticator(
return DashboardAuthenticationResult.Success(CreatePrincipal( return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername, normalizedUsername,
displayName, displayName,
groups)); groups,
roles));
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -134,28 +136,32 @@ public sealed class DashboardAuthenticator(
return builder.ToString(); 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 (groupToRole.Count == 0)
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
{ {
return false; return [];
} }
HashSet<string> roles = new(StringComparer.Ordinal);
foreach (string group in groups) foreach (string group in groups)
{ {
string normalizedGroup = group.Trim(); string normalizedGroup = group.Trim();
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase) if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| string.Equals( || groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
ExtractFirstRdnValue(normalizedGroup),
normalizedRequiredGroup,
StringComparison.OrdinalIgnoreCase))
{ {
return true; roles.Add(mapped);
} }
} }
return false; return [.. roles];
} }
internal static string ExtractFirstRdnValue(string distinguishedName) internal static string ExtractFirstRdnValue(string distinguishedName)
@@ -237,20 +243,16 @@ public sealed class DashboardAuthenticator(
private static ClaimsPrincipal CreatePrincipal( private static ClaimsPrincipal CreatePrincipal(
string username, string username,
string displayName, 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 = List<Claim> claims =
[ [
new Claim(ClaimTypes.NameIdentifier, username), new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, displayName), 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( claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType, DashboardAuthenticationDefaults.LdapGroupClaimType,
group))); group)));
@@ -259,7 +261,7 @@ public sealed class DashboardAuthenticator(
claims, claims,
DashboardAuthenticationDefaults.AuthenticationScheme, DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name, ClaimTypes.Name,
DashboardAuthenticationDefaults.LdapGroupClaimType); ClaimTypes.Role);
return new ClaimsPrincipal(claimsIdentity); return new ClaimsPrincipal(claimsIdentity);
} }
@@ -2,10 +2,15 @@ using System.Net;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; 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( public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement> IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
@@ -36,9 +41,13 @@ public sealed class DashboardAuthorizationHandler(
return Task.CompletedTask; 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; return Task.CompletedTask;
@@ -50,11 +59,4 @@ public sealed class DashboardAuthorizationHandler(
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress); 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; 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; return endpoints;
} }
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase); endpoints.MapGet(
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
"/login", "/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase)) (HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
.AllowAnonymous() .AllowAnonymous()
.WithName("DashboardLogin"); .WithName("DashboardLogin");
dashboard.MapPost( endpoints.MapPost(
"/login", "/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
.WithName("DashboardLoginPost"); .WithName("DashboardLoginPost");
dashboard.MapPost( endpoints.MapPost(
"/logout", "/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase)) (HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery))
.AllowAnonymous() .AllowAnonymous()
.WithName("DashboardLogout"); .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>"), RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
"text/html")) "text/html"))
.AllowAnonymous() .AllowAnonymous()
.WithName("DashboardAccessDenied"); .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 // login/logout/denied endpoints above opt out via AllowAnonymous(); an
// unauthenticated request to a component route is challenged by the // unauthenticated request to a component route is challenged by the
// cookie scheme and redirected to the login page. // cookie scheme and redirected to /login.
dashboard.MapRazorComponents<App>() endpoints.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy); .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);
return endpoints; return endpoints;
} }
private static Task<ContentHttpResult> GetLoginAsync( private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext, HttpContext httpContext,
IAntiforgery antiforgery, IAntiforgery antiforgery)
string pathBase)
{ {
string returnUrl = SanitizeReturnUrl( string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
httpContext.Request.Query["returnUrl"].ToString(),
pathBase);
return Task.FromResult(TypedResults.Content( return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null), RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
"text/html")); "text/html"));
} }
private static async Task<IResult> PostLoginAsync( private static async Task<IResult> PostLoginAsync(
HttpContext httpContext, HttpContext httpContext,
IAntiforgery antiforgery, IAntiforgery antiforgery,
IDashboardAuthenticator authenticator, IDashboardAuthenticator authenticator)
string pathBase)
{ {
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
IFormCollection form = await httpContext.Request IFormCollection form = await httpContext.Request
.ReadFormAsync(httpContext.RequestAborted) .ReadFormAsync(httpContext.RequestAborted)
.ConfigureAwait(false); .ConfigureAwait(false);
string returnUrl = SanitizeReturnUrl( string returnUrl = SanitizeReturnUrl(form["returnUrl"].ToString());
form["returnUrl"].ToString(),
pathBase);
DashboardAuthenticationResult result = await authenticator DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync( .AuthenticateAsync(
@@ -102,7 +93,7 @@ public static class DashboardEndpointRouteBuilderExtensions
if (!result.Succeeded || result.Principal is null) if (!result.Succeeded || result.Principal is null)
{ {
return TypedResults.Content( return TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage), RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
"text/html", "text/html",
statusCode: StatusCodes.Status401Unauthorized); statusCode: StatusCodes.Status401Unauthorized);
} }
@@ -116,22 +107,20 @@ public static class DashboardEndpointRouteBuilderExtensions
private static async Task<IResult> PostLogoutAsync( private static async Task<IResult> PostLogoutAsync(
HttpContext httpContext, HttpContext httpContext,
IAntiforgery antiforgery, IAntiforgery antiforgery)
string pathBase)
{ {
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false); await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
await httpContext await httpContext
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme) .SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false); .ConfigureAwait(false);
return Results.LocalRedirect($"{pathBase}/login"); return Results.LocalRedirect("/login");
} }
private static string RenderLoginPage( private static string RenderLoginPage(
HttpContext httpContext, HttpContext httpContext,
IAntiforgery antiforgery, IAntiforgery antiforgery,
string returnUrl, string returnUrl,
string pathBase,
string? failureMessage) string? failureMessage)
{ {
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext); AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
@@ -143,7 +132,7 @@ public static class DashboardEndpointRouteBuilderExtensions
string body = $""" string body = $"""
<section class="dashboard-login"> <section class="dashboard-login">
{alert} {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"> <div class="card-body">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" /> <input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" /> <input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
@@ -193,24 +182,14 @@ public static class DashboardEndpointRouteBuilderExtensions
"""; """;
} }
private static string NormalizePathBase(string pathBase) private static string SanitizeReturnUrl(string? returnUrl)
{
string normalized = pathBase.TrimEnd('/');
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
? "/dashboard"
: normalized;
}
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
{ {
if (string.IsNullOrWhiteSpace(returnUrl) if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal) || !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal) || returnUrl.StartsWith("//", StringComparison.Ordinal)
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _)) || Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
{ {
return pathBase; return "/";
} }
return returnUrl; 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.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -21,46 +20,51 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>(); services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddSingleton<DashboardApiKeyAuthorization>(); services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>(); services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<HubTokenService>();
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddAntiforgery(); services.AddAntiforgery();
services.AddCascadingAuthenticationState(); services.AddCascadingAuthenticationState();
services.AddRazorComponents() services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
services.AddSignalR();
services services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme) .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme); .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme) {
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions); cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
services.AddAuthorization(options => 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( authorization.AddPolicy(
DashboardAuthenticationDefaults.AuthorizationPolicy, DashboardAuthenticationDefaults.ViewerPolicy,
policy => policy.AddRequirements(new DashboardAuthorizationRequirement())); 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>(); services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
return services; 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", "ServiceAccountPassword": "serviceaccount123",
"UserNameAttribute": "cn", "UserNameAttribute": "cn",
"DisplayNameAttribute": "cn", "DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf", "GroupAttribute": "memberOf"
"RequiredGroup": "GwAdmin"
}, },
"Worker": { "Worker": {
"ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe", "ExecutablePath": "src\\ZB.MOM.WW.MxGateway.Worker\\bin\\x86\\Release\\ZB.MOM.WW.MxGateway.Worker.exe",
@@ -50,13 +49,15 @@
}, },
"Dashboard": { "Dashboard": {
"Enabled": true, "Enabled": true,
"PathBase": "/dashboard",
"RequireAdminScope": true,
"AllowAnonymousLocalhost": true, "AllowAnonymousLocalhost": true,
"SnapshotIntervalMilliseconds": 1000, "SnapshotIntervalMilliseconds": 1000,
"RecentFaultLimit": 100, "RecentFaultLimit": 100,
"RecentSessionLimit": 200, "RecentSessionLimit": 200,
"ShowTagValues": false "ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwReader": "Viewer"
}
}, },
"Protocol": { "Protocol": {
"WorkerProtocolVersion": 1, "WorkerProtocolVersion": 1,
@@ -39,8 +39,6 @@ public sealed class GatewayOptionsTests
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy); Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
Assert.True(options.Dashboard.Enabled); Assert.True(options.Dashboard.Enabled);
Assert.Equal("/dashboard", options.Dashboard.PathBase);
Assert.True(options.Dashboard.RequireAdminScope);
Assert.True(options.Dashboard.AllowAnonymousLocalhost); Assert.True(options.Dashboard.AllowAnonymousLocalhost);
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds); Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
Assert.Equal(100, options.Dashboard.RecentFaultLimit); 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:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")] [InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")] [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) public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
{ {
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() => OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
@@ -1,6 +1,4 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.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 public sealed class DashboardApiKeyAuthorizationTests
{ {
[Fact] [Fact]
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue() public void CanManage_AuthenticatedAdmin_ReturnsTrue()
{ {
DashboardApiKeyAuthorization authorization = CreateAuthorization(); DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = CreatePrincipal("GwAdmin"); ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Admin);
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");
Assert.True(authorization.CanManage(user)); Assert.True(authorization.CanManage(user));
} }
@@ -28,37 +17,28 @@ public sealed class DashboardApiKeyAuthorizationTests
[Fact] [Fact]
public void CanManage_AnonymousUser_ReturnsFalse() public void CanManage_AnonymousUser_ReturnsFalse()
{ {
DashboardApiKeyAuthorization authorization = CreateAuthorization(); DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = new(new ClaimsIdentity()); ClaimsPrincipal user = new(new ClaimsIdentity());
Assert.False(authorization.CanManage(user)); Assert.False(authorization.CanManage(user));
} }
[Fact] [Fact]
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse() public void CanManage_AuthenticatedViewer_ReturnsFalse()
{ {
DashboardApiKeyAuthorization authorization = CreateAuthorization(); DashboardApiKeyAuthorization authorization = new();
ClaimsPrincipal user = CreatePrincipal("ReadOnly"); ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Viewer);
Assert.False(authorization.CanManage(user)); Assert.False(authorization.CanManage(user));
} }
private static DashboardApiKeyAuthorization CreateAuthorization() private static ClaimsPrincipal CreatePrincipal(string role)
{
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
}));
}
private static ClaimsPrincipal CreatePrincipal(string group)
{ {
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)], [new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme); DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity); return new ClaimsPrincipal(identity);
} }
@@ -144,19 +144,11 @@ public sealed class DashboardApiKeyManagementServiceTests
FakeApiKeyAuditStore? auditStore = null, FakeApiKeyAuditStore? auditStore = null,
FakeApiKeySecretHasher? hasher = null) FakeApiKeySecretHasher? hasher = null)
{ {
GatewayOptions options = new()
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
};
DefaultHttpContext httpContext = new(); DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService( return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(Options.Create(options)), new DashboardApiKeyAuthorization(),
adminStore ?? new FakeApiKeyAdminStore(), adminStore ?? new FakeApiKeyAdminStore(),
auditStore ?? new FakeApiKeyAuditStore(), auditStore ?? new FakeApiKeyAuditStore(),
hasher ?? new FakeApiKeySecretHasher(), hasher ?? new FakeApiKeySecretHasher(),
@@ -178,8 +170,10 @@ public sealed class DashboardApiKeyManagementServiceTests
private static ClaimsPrincipal CreateAuthorizedUser() private static ClaimsPrincipal CreateAuthorizedUser()
{ {
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")], [new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
DashboardAuthenticationDefaults.AuthenticationScheme); DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity); return new ClaimsPrincipal(identity);
} }
@@ -16,23 +16,47 @@ public sealed class DashboardAuthenticatorTests
} }
[Theory] [Theory]
[InlineData("GwAdmin", true)] [InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", true)] [InlineData("gwadmin", DashboardRoles.Admin)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)] [InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
[InlineData("OtherGroup", false)] [InlineData("OtherGroup", null)]
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName( public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
string requiredGroup, string ldapGroup,
bool expected) string? expectedRole)
{ {
string[] groups = Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
[ {
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local", ["GwAdmin"] = DashboardRoles.Admin,
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local" ["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] [Fact]
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
@@ -18,7 +17,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()), new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Parse("10.0.0.5"), IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false); allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded); Assert.False(context.HasSucceeded);
} }
@@ -30,7 +30,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()), new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Loopback, IPAddress.Loopback,
allowAnonymousLocalhost: true); allowAnonymousLocalhost: true,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded); Assert.True(context.HasSucceeded);
} }
@@ -45,7 +46,8 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()), new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Loopback, IPAddress.Loopback,
allowAnonymousLocalhost: false); allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded); Assert.False(context.HasSucceeded);
} }
@@ -60,41 +62,70 @@ public sealed class DashboardAuthorizationHandlerTests
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()), new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Parse("10.0.0.5"), IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: true); allowAnonymousLocalhost: true,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded); 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] [Fact]
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed() public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
{ {
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.EventsRead), CreatePrincipal("SomeOtherRole"),
IPAddress.Loopback, IPAddress.Loopback,
allowAnonymousLocalhost: false); allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.False(context.HasSucceeded); 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] [Fact]
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds() public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
{ {
AuthorizationHandlerContext context = await AuthorizeAsync( AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.Admin), CreatePrincipal(DashboardRoles.Viewer),
IPAddress.Parse("10.0.0.5"), IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false); allowAnonymousLocalhost: false,
DashboardAuthorizationRequirement.AnyDashboardRole);
Assert.True(context.HasSucceeded); 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( private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
ClaimsPrincipal principal, ClaimsPrincipal principal,
IPAddress remoteAddress, IPAddress remoteAddress,
bool allowAnonymousLocalhost) bool allowAnonymousLocalhost,
DashboardAuthorizationRequirement requirement)
{ {
DashboardAuthorizationRequirement requirement = new();
DefaultHttpContext httpContext = new(); DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = remoteAddress; httpContext.Connection.RemoteIpAddress = remoteAddress;
DashboardAuthorizationHandler handler = new( DashboardAuthorizationHandler handler = new(
@@ -104,7 +135,6 @@ public sealed class DashboardAuthorizationHandlerTests
Dashboard = new DashboardOptions Dashboard = new DashboardOptions
{ {
AllowAnonymousLocalhost = allowAnonymousLocalhost, AllowAnonymousLocalhost = allowAnonymousLocalhost,
RequireAdminScope = true
} }
})); }));
AuthorizationHandlerContext context = new([requirement], principal, httpContext); AuthorizationHandlerContext context = new([requirement], principal, httpContext);
@@ -114,11 +144,13 @@ public sealed class DashboardAuthorizationHandlerTests
return context; return context;
} }
private static ClaimsPrincipal CreatePrincipal(string scope) private static ClaimsPrincipal CreatePrincipal(string role)
{ {
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)], [new Claim(ClaimTypes.Role, role)],
DashboardAuthenticationDefaults.AuthenticationScheme); DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
ClaimTypes.Role);
return new ClaimsPrincipal(identity); return new ClaimsPrincipal(identity);
} }
@@ -26,8 +26,8 @@ public sealed class DashboardCookieOptionsTests
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy); Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite); Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
Assert.Equal("/", options.Cookie.Path); Assert.Equal("/", options.Cookie.Path);
Assert.Equal("/dashboard/login", options.LoginPath); Assert.Equal("/login", options.LoginPath);
Assert.Equal("/dashboard/logout", options.LogoutPath); Assert.Equal("/logout", options.LogoutPath);
Assert.Equal("/dashboard/denied", options.AccessDeniedPath); Assert.Equal("/denied", options.AccessDeniedPath);
} }
} }
@@ -44,11 +44,11 @@ public sealed class GatewayApplicationTests
await using WebApplication app = GatewayApplication.Build([]); await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app); IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings"); Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
Assert.Contains(endpoints, endpoint => Assert.Contains(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin"); endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
Assert.Contains(endpoints, endpoint => 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] [Fact]
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization() public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
{ {
await using WebApplication app = GatewayApplication.Build([]); await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app); IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] componentRoutes = // Razor-component endpoints are distinguished from minimal-API
["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"]; // 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) foreach (string route in componentRoutes)
{ {
RouteEndpoint[] matches = endpoints 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(); .ToArray();
Assert.NotEmpty(matches); Assert.NotEmpty(matches);
@@ -94,51 +97,32 @@ public sealed class GatewayApplicationTests
{ {
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>(); IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
Assert.NotNull(authorize); 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] [Fact]
public async Task Build_WhenDashboardEnabled_RegistersCanonicalDashboardRoutes() public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
{ {
await using WebApplication app = GatewayApplication.Build([]); await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app); IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] canonicalRoutes = string[] canonicalRoutes =
[ [
"/dashboard/", "/",
"/dashboard/sessions", "/sessions",
"/dashboard/workers", "/workers",
"/dashboard/events", "/events",
"/dashboard/settings", "/settings",
"/dashboard/galaxy", "/galaxy",
"/dashboard/apikeys", "/apikeys",
"/dashboard/sessions/{SessionId}", "/sessions/{SessionId}",
]; ];
foreach (string canonical in canonicalRoutes) 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"]); await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app); IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.DoesNotContain(endpoints, endpoint =>
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
Assert.DoesNotContain(endpoints, endpoint => Assert.DoesNotContain(endpoints, endpoint =>
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith( endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
"Dashboard", "Dashboard",
@@ -174,13 +156,9 @@ public sealed class GatewayApplicationTests
"", "",
"MxGateway:Authentication:PepperSecretName is required")] "MxGateway:Authentication:PepperSecretName is required")]
[InlineData( [InlineData(
"MxGateway:Dashboard:PathBase", "MxGateway:Dashboard:GroupToRole:GwAdmin",
"dashboard", "BogusRole",
"MxGateway:Dashboard:PathBase must start with '/'.")] "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
[InlineData(
"MxGateway:Ldap:RequiredGroup",
"",
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
[InlineData( [InlineData(
"MxGateway:Ldap:AllowInsecureLdap", "MxGateway:Ldap:AllowInsecureLdap",
"false", "false",