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:
@@ -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">▮</span> MXAccess Gateway</a>
|
<a class="brand" href="/"><span class="mark">▮</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]);
|
||||||
|
}
|
||||||
|
|||||||
+22
-43
@@ -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>(() =>
|
||||||
|
|||||||
+12
-32
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-11
@@ -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]
|
||||||
|
|||||||
+51
-19
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user