From 27ed65114ef8c66eba092fd6a86687f66709e680 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 01:38:33 -0400 Subject: [PATCH] dashboard: role-based LDAP auth + hub bearer scheme, drop PathBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../DashboardLdapLiveTests.cs | 11 ++- .../Configuration/DashboardOptions.cs | 13 +-- .../EffectiveDashboardConfiguration.cs | 5 +- .../EffectiveLdapConfiguration.cs | 3 +- .../GatewayConfigurationProvider.cs | 8 +- .../Configuration/GatewayOptionsValidator.cs | 23 ++++-- .../Configuration/LdapOptions.cs | 2 - .../Dashboard/Components/App.razor | 20 +---- .../Components/Layout/DashboardLayout.razor | 38 +++------ .../Components/Pages/AlarmsPage.razor | 1 - .../Components/Pages/ApiKeysPage.razor | 1 - .../Components/Pages/BrowsePage.razor | 1 - .../Components/Pages/DashboardHome.razor | 1 - .../Components/Pages/EventsPage.razor | 1 - .../Components/Pages/GalaxyPage.razor | 1 - .../Components/Pages/SessionDetailsPage.razor | 1 - .../Components/Pages/SessionsPage.razor | 1 - .../Components/Pages/SettingsPage.razor | 22 ++++- .../Components/Pages/WorkersPage.razor | 1 - .../Dashboard/DashboardApiKeyAuthorization.cs | 10 +-- .../DashboardAuthenticationDefaults.cs | 32 +++++++- .../Dashboard/DashboardAuthenticator.cs | 50 ++++++------ .../DashboardAuthorizationHandler.cs | 22 ++--- .../DashboardAuthorizationRequirement.cs | 15 +++- ...DashboardEndpointRouteBuilderExtensions.cs | 65 +++++---------- .../Dashboard/DashboardRoles.cs | 18 +++++ .../DashboardServiceCollectionExtensions.cs | 66 ++++++++------- .../HubTokenAuthenticationHandler.cs | 57 +++++++++++++ .../Dashboard/HubTokenService.cs | 81 +++++++++++++++++++ .../appsettings.json | 11 +-- .../Configuration/GatewayOptionsTests.cs | 4 +- .../DashboardApiKeyAuthorizationTests.cs | 44 +++------- .../DashboardApiKeyManagementServiceTests.cs | 16 ++-- .../Dashboard/DashboardAuthenticatorTests.cs | 52 ++++++++---- .../DashboardAuthorizationHandlerTests.cs | 70 +++++++++++----- .../Dashboard/DashboardCookieOptionsTests.cs | 6 +- .../Gateway/GatewayApplicationTests.cs | 76 +++++++---------- 37 files changed, 509 insertions(+), 340 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRoles.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenAuthenticationHandler.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs index 2baeb2d..207c1c4 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -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(StringComparer.OrdinalIgnoreCase) + { + ["GwAdmin"] = DashboardRoles.Admin, + }, + }, + }), NullLogger.Instance); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs index 7694c8f..7d8e915 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs @@ -5,12 +5,6 @@ public sealed class DashboardOptions /// Gets whether the dashboard is enabled. public bool Enabled { get; init; } = true; - /// Gets the dashboard URL path base. - public string PathBase { get; init; } = "/dashboard"; - - /// Gets whether dashboard access requires admin scope. - public bool RequireAdminScope { get; init; } = true; - /// Gets whether anonymous localhost access to dashboard is allowed. public bool AllowAnonymousLocalhost { get; init; } = true; @@ -25,4 +19,11 @@ public sealed class DashboardOptions /// Gets whether to show full tag values in the dashboard. public bool ShowTagValues { get; init; } + + /// + /// LDAP group → dashboard role mapping. Values must be one of + /// or . + /// Users with no matching group are rejected at login. + /// + public Dictionary GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs index 91e852b..9db3a8b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs @@ -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 GroupToRole); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs index 7ce9d6e..6a19c66 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveLdapConfiguration.cs @@ -11,5 +11,4 @@ public sealed record EffectiveLdapConfiguration( string ServiceAccountPassword, string UserNameAttribute, string DisplayNameAttribute, - string GroupAttribute, - string RequiredGroup); + string GroupAttribute); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index 09d64e1..fb4e850 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -30,8 +30,7 @@ public sealed class GatewayConfigurationProvider(IOptions 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 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)); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 89af828..26f05ca 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -86,10 +86,6 @@ public sealed class GatewayOptionsValidator : IValidateOptions 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 private static void ValidateDashboard(DashboardOptions options, List 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 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}'."); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs index 1d5c16e..d580569 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs @@ -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"; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor index 09ec8c1..6b932d9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor @@ -1,11 +1,9 @@ -@inject IOptions GatewayOptions - - + @@ -17,19 +15,3 @@ - -@code { - private string DashboardBaseHref - { - get - { - string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(pathBase)) - { - pathBase = "/dashboard"; - } - - return $"{pathBase}/"; - } - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor index 29ddee2..355556c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -1,33 +1,32 @@ @inherits LayoutComponentBase -@inject IOptions GatewayOptions
- MXAccess Gateway + MXAccess Gateway
@authState.User.Identity?.Name -
+
- Sign in + Sign in
@@ -35,16 +34,3 @@ @Body
- -@code { - private string DashboardPath(string relativePath) - { - string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/'); - if (string.IsNullOrWhiteSpace(pathBase)) - { - pathBase = "/dashboard"; - } - - return $"{pathBase}{relativePath}"; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor index 2fc06f6..10a9f60 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -1,5 +1,4 @@ @page "/alarms" -@page "/dashboard/alarms" @implements IAsyncDisposable @inject IDashboardLiveDataService LiveData @inject IOptions GatewayOptions diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor index 345bcd1..7c885a9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -1,5 +1,4 @@ @page "/apikeys" -@page "/dashboard/apikeys" @inherits DashboardPageBase @inject AuthenticationStateProvider AuthenticationStateProvider @inject IDashboardApiKeyManagementService ApiKeyManagementService diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor index dc7ddc0..fa16556 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/BrowsePage.razor @@ -1,5 +1,4 @@ @page "/browse" -@page "/dashboard/browse" @implements IAsyncDisposable @inject IGalaxyHierarchyCache GalaxyCache @inject IDashboardLiveDataService LiveData diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor index 0197588..919594d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor @@ -1,5 +1,4 @@ @page "/" -@page "/dashboard/" @inherits DashboardPageBase MXAccess Gateway Dashboard diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor index e5401ec..0e0b687 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/EventsPage.razor @@ -1,5 +1,4 @@ @page "/events" -@page "/dashboard/events" @inherits DashboardPageBase Dashboard Events diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor index ba3cc2a..f3e072b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/GalaxyPage.razor @@ -1,5 +1,4 @@ @page "/galaxy" -@page "/dashboard/galaxy" @inherits DashboardPageBase Dashboard Galaxy diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 569ad72..0eddbbc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -1,5 +1,4 @@ @page "/sessions/{SessionId}" -@page "/dashboard/sessions/{SessionId}" @inherits DashboardPageBase Dashboard Session diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor index 5c60e20..e8df4dc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionsPage.razor @@ -1,5 +1,4 @@ @page "/sessions" -@page "/dashboard/sessions" @inherits DashboardPageBase Dashboard Sessions diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor index ebf9930..600d022 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor @@ -1,5 +1,4 @@ @page "/settings" -@page "/dashboard/settings" @inherits DashboardPageBase Dashboard Settings @@ -33,7 +32,24 @@ else LDAP service password@Snapshot.Configuration.Ldap.ServiceAccountPassword LDAP username attribute@Snapshot.Configuration.Ldap.UserNameAttribute LDAP group attribute@Snapshot.Configuration.Ldap.GroupAttribute - LDAP required group@Snapshot.Configuration.Ldap.RequiredGroup + + Dashboard role mapping + + @if (Snapshot.Configuration.Dashboard.GroupToRole.Count == 0) + { + (none configured) + } + else + { +
    + @foreach (KeyValuePair pair in Snapshot.Configuration.Dashboard.GroupToRole) + { +
  • @pair.Key → @pair.Value
  • + } +
+ } + + Worker executable@Snapshot.Configuration.Worker.ExecutablePath Worker architecture@Snapshot.Configuration.Worker.RequiredArchitecture Startup timeout@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds @@ -44,8 +60,6 @@ else Event queue capacity@Snapshot.Configuration.Events.QueueCapacity Backpressure policy@Snapshot.Configuration.Events.BackpressurePolicy Dashboard enabled@Snapshot.Configuration.Dashboard.Enabled - Dashboard path@Snapshot.Configuration.Dashboard.PathBase - Require admin scope@Snapshot.Configuration.Dashboard.RequireAdminScope Anonymous localhost@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost Snapshot interval@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms Show tag values@Snapshot.Configuration.Dashboard.ShowTagValues diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor index 80f8182..4811852 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/WorkersPage.razor @@ -1,5 +1,4 @@ @page "/workers" -@page "/dashboard/workers" @inherits DashboardPageBase Dashboard Workers diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs index 79b7671..fe6efb9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyAuthorization.cs @@ -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 options) +public sealed class DashboardApiKeyAuthorization { public bool CanManage(ClaimsPrincipal user) { @@ -13,10 +11,6 @@ public sealed class DashboardApiKeyAuthorization(IOptions option return false; } - string requiredGroup = options.Value.Ldap.RequiredGroup; - IEnumerable groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType) - .Select(claim => claim.Value); - - return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup); + return user.IsInRole(DashboardRoles.Admin); } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs index 55b2613..e8830e7 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -2,9 +2,37 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public static class DashboardAuthenticationDefaults { + /// Cookie scheme used to authenticate interactive dashboard requests. public const string AuthenticationScheme = "MxGateway.Dashboard"; - public const string AuthorizationPolicy = "MxGateway.Dashboard"; - public const string ScopeClaimType = "scope"; + + /// + /// 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. + /// + public const string HubAuthenticationScheme = "MxGateway.Dashboard.HubToken"; + + /// + /// Policy guarding read-only dashboard pages. Allowed roles: + /// or . + /// + public const string ViewerPolicy = "MxGateway.Dashboard.Viewer"; + + /// + /// Policy guarding write-capable dashboard pages and admin endpoints + /// (API-key CRUD, settings edits, hub publishes). Allowed role: + /// . + /// + public const string AdminPolicy = "MxGateway.Dashboard.Admin"; + + /// + /// 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). + /// + 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"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs index a5fb097..09b9fc5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -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 groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute); - if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup)) + IReadOnlyList 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 groups, string requiredGroup) + /// + /// 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). + /// + internal static IReadOnlyList MapGroupsToRoles( + IEnumerable groups, + IReadOnlyDictionary groupToRole) { - string normalizedRequiredGroup = requiredGroup.Trim(); - if (string.IsNullOrWhiteSpace(normalizedRequiredGroup)) + if (groupToRole.Count == 0) { - return false; + return []; } + HashSet 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 groups) + IEnumerable groups, + IEnumerable 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 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); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs index ec10f7a..8e157bd 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs @@ -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; +/// +/// Authorizes a dashboard request by checking either: (a) the LDAP-issued +/// role claim satisfies , +/// (b) authentication is fully disabled, or (c) the request is from loopback +/// and MxGateway:Dashboard:AllowAnonymousLocalhost is on. +/// public sealed class DashboardAuthorizationHandler( IHttpContextAccessor httpContextAccessor, IOptions options) : AuthorizationHandler @@ -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); - } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs index 37685b8..3dd55a0 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationRequirement.cs @@ -2,4 +2,17 @@ using Microsoft.AspNetCore.Authorization; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; -public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement; +/// +/// Requirement satisfied when the user has any one of the listed roles, or +/// when an environment-level bypass (auth disabled / loopback) is in effect. +/// +public sealed class DashboardAuthorizationRequirement(IReadOnlyList requiredRoles) : IAuthorizationRequirement +{ + public IReadOnlyList RequiredRoles { get; } = requiredRoles; + + public static DashboardAuthorizationRequirement AnyDashboardRole { get; } = + new([DashboardRoles.Admin, DashboardRoles.Viewer]); + + public static DashboardAuthorizationRequirement AdminOnly { get; } = + new([DashboardRoles.Admin]); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index a11fc04..061f322 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -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", "

The signed-in user is not authorized for dashboard access.

"), "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() + // cookie scheme and redirected to /login. + endpoints.MapRazorComponents() .AddInteractiveServerRenderMode() - .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy); + .RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy); return endpoints; } private static Task 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 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 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 = $"""