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
@@ -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>