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:
@@ -39,8 +39,6 @@ public sealed class GatewayOptionsTests
|
||||
Assert.Equal(EventBackpressurePolicy.FailFast, options.Events.BackpressurePolicy);
|
||||
|
||||
Assert.True(options.Dashboard.Enabled);
|
||||
Assert.Equal("/dashboard", options.Dashboard.PathBase);
|
||||
Assert.True(options.Dashboard.RequireAdminScope);
|
||||
Assert.True(options.Dashboard.AllowAnonymousLocalhost);
|
||||
Assert.Equal(1_000, options.Dashboard.SnapshotIntervalMilliseconds);
|
||||
Assert.Equal(100, options.Dashboard.RecentFaultLimit);
|
||||
@@ -89,7 +87,7 @@ public sealed class GatewayOptionsTests
|
||||
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
|
||||
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
[InlineData("MxGateway:Dashboard:GroupToRole:GwAdmin", "Sysadmin", "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
|
||||
{
|
||||
OptionsValidationException exception = Assert.Throws<OptionsValidationException>(() =>
|
||||
|
||||
+12
-32
@@ -1,6 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
@@ -8,19 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
public sealed class DashboardApiKeyAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue()
|
||||
public void CanManage_AuthenticatedAdmin_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("GwAdmin");
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local");
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Admin);
|
||||
|
||||
Assert.True(authorization.CanManage(user));
|
||||
}
|
||||
@@ -28,37 +17,28 @@ public sealed class DashboardApiKeyAuthorizationTests
|
||||
[Fact]
|
||||
public void CanManage_AnonymousUser_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = new(new ClaimsIdentity());
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse()
|
||||
public void CanManage_AuthenticatedViewer_ReturnsFalse()
|
||||
{
|
||||
DashboardApiKeyAuthorization authorization = CreateAuthorization();
|
||||
ClaimsPrincipal user = CreatePrincipal("ReadOnly");
|
||||
DashboardApiKeyAuthorization authorization = new();
|
||||
ClaimsPrincipal user = CreatePrincipal(DashboardRoles.Viewer);
|
||||
|
||||
Assert.False(authorization.CanManage(user));
|
||||
}
|
||||
|
||||
private static DashboardApiKeyAuthorization CreateAuthorization()
|
||||
{
|
||||
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string group)
|
||||
private static ClaimsPrincipal CreatePrincipal(string role)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
+5
-11
@@ -144,19 +144,11 @@ public sealed class DashboardApiKeyManagementServiceTests
|
||||
FakeApiKeyAuditStore? auditStore = null,
|
||||
FakeApiKeySecretHasher? hasher = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
RequiredGroup = "GwAdmin",
|
||||
},
|
||||
};
|
||||
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||
|
||||
return new DashboardApiKeyManagementService(
|
||||
new DashboardApiKeyAuthorization(Options.Create(options)),
|
||||
new DashboardApiKeyAuthorization(),
|
||||
adminStore ?? new FakeApiKeyAdminStore(),
|
||||
auditStore ?? new FakeApiKeyAuditStore(),
|
||||
hasher ?? new FakeApiKeySecretHasher(),
|
||||
@@ -178,8 +170,10 @@ public sealed class DashboardApiKeyManagementServiceTests
|
||||
private static ClaimsPrincipal CreateAuthorizedUser()
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
@@ -16,23 +16,47 @@ public sealed class DashboardAuthenticatorTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", true)]
|
||||
[InlineData("gwadmin", true)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
|
||||
[InlineData("OtherGroup", false)]
|
||||
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
|
||||
string requiredGroup,
|
||||
bool expected)
|
||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
||||
[InlineData("OtherGroup", null)]
|
||||
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
|
||||
string ldapGroup,
|
||||
string? expectedRole)
|
||||
{
|
||||
string[] groups =
|
||||
[
|
||||
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
|
||||
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
|
||||
];
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
if (expectedRole is null)
|
||||
{
|
||||
Assert.Empty(roles);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expectedRole, Assert.Single(roles));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
|
||||
{
|
||||
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
|
||||
["GwAdmin", "GwReader"],
|
||||
mapping);
|
||||
|
||||
Assert.Contains(DashboardRoles.Admin, roles);
|
||||
Assert.Contains(DashboardRoles.Viewer, roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+51
-19
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
@@ -18,7 +17,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: true);
|
||||
allowAnonymousLocalhost: true,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
@@ -45,7 +46,8 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
@@ -60,41 +62,70 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: true);
|
||||
allowAnonymousLocalhost: true,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users without admin scope fail authorization.</summary>
|
||||
/// <summary>Verifies that an authenticated user without any dashboard role fails the viewer requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||
public async Task HandleAsync_AuthenticatedWithoutDashboardRole_DoesNotSucceed()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.EventsRead),
|
||||
CreatePrincipal("SomeOtherRole"),
|
||||
IPAddress.Loopback,
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that authenticated users with admin scope succeed.</summary>
|
||||
/// <summary>Verifies that a Viewer satisfies the viewer-or-admin requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
|
||||
public async Task HandleAsync_ViewerRole_SatisfiesViewerPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(GatewayScopes.Admin),
|
||||
CreatePrincipal(DashboardRoles.Viewer),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false);
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an Admin satisfies the admin-only requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_AdminRole_SatisfiesAdminPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(DashboardRoles.Admin),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AdminOnly);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a Viewer does NOT satisfy the admin-only requirement.</summary>
|
||||
[Fact]
|
||||
public async Task HandleAsync_ViewerRole_DoesNotSatisfyAdminPolicy()
|
||||
{
|
||||
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||
CreatePrincipal(DashboardRoles.Viewer),
|
||||
IPAddress.Parse("10.0.0.5"),
|
||||
allowAnonymousLocalhost: false,
|
||||
DashboardAuthorizationRequirement.AdminOnly);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||
ClaimsPrincipal principal,
|
||||
IPAddress remoteAddress,
|
||||
bool allowAnonymousLocalhost)
|
||||
bool allowAnonymousLocalhost,
|
||||
DashboardAuthorizationRequirement requirement)
|
||||
{
|
||||
DashboardAuthorizationRequirement requirement = new();
|
||||
DefaultHttpContext httpContext = new();
|
||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||
DashboardAuthorizationHandler handler = new(
|
||||
@@ -104,7 +135,6 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||
RequireAdminScope = true
|
||||
}
|
||||
}));
|
||||
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||
@@ -114,11 +144,13 @@ public sealed class DashboardAuthorizationHandlerTests
|
||||
return context;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||
private static ClaimsPrincipal CreatePrincipal(string role)
|
||||
{
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
[new Claim(ClaimTypes.Role, role)],
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ public sealed class DashboardCookieOptionsTests
|
||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||
Assert.Equal("/", options.Cookie.Path);
|
||||
Assert.Equal("/dashboard/login", options.LoginPath);
|
||||
Assert.Equal("/dashboard/logout", options.LogoutPath);
|
||||
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
|
||||
Assert.Equal("/login", options.LoginPath);
|
||||
Assert.Equal("/logout", options.LogoutPath);
|
||||
Assert.Equal("/denied", options.AccessDeniedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,11 @@ public sealed class GatewayApplicationTests
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
||||
Assert.Contains(endpoints, endpoint =>
|
||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
|
||||
Assert.Contains(endpoints, endpoint =>
|
||||
@@ -74,19 +74,22 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] componentRoutes =
|
||||
["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"];
|
||||
// Razor-component endpoints are distinguished from minimal-API
|
||||
// endpoints registered at the same path by the presence of
|
||||
// ComponentTypeMetadata. Filter to those before checking auth.
|
||||
string[] componentRoutes = ["/", "/sessions", "/workers", "/events", "/settings"];
|
||||
foreach (string route in componentRoutes)
|
||||
{
|
||||
RouteEndpoint[] matches = endpoints
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText == route)
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText == route
|
||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(matches);
|
||||
@@ -94,51 +97,32 @@ public sealed class GatewayApplicationTests
|
||||
{
|
||||
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-020 reversal regression guard. The original Server-020 finding
|
||||
/// incorrectly concluded that the duplicate <c>@page "/dashboard/X"</c>
|
||||
/// directives were redundant because <c>MapGroup("/dashboard")</c>
|
||||
/// would prepend the prefix to all dashboard Razor pages. In practice
|
||||
/// Blazor SSR's <c>RouteTableFactory</c> matches against the raw
|
||||
/// <c>@page</c> template values (not against the endpoint-route
|
||||
/// prefix), so removing <c>@page "/dashboard/X"</c> left the dashboard
|
||||
/// unreachable at runtime (every page returned HTTP 500 with "Unable
|
||||
/// to find the provided template '/dashboard/'"). The duplicate
|
||||
/// <c>@page</c> directives are restored, and as a side effect the
|
||||
/// endpoint route table DOES carry the doubled <c>/dashboard/dashboard/X</c>
|
||||
/// shape (because <c>MapGroup("/dashboard")</c> prefixes the already-prefixed
|
||||
/// <c>@page "/dashboard/X"</c>). Those doubled endpoints are harmless —
|
||||
/// no client requests <c>/dashboard/dashboard/X</c> — and removing them
|
||||
/// requires either dropping <c>MapGroup</c> or the <c>@page</c>
|
||||
/// prefix. This test asserts only the positive contract: every
|
||||
/// dashboard page IS reachable under the canonical <c>/dashboard/X</c>
|
||||
/// route, which is what the Blazor router actually serves.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardEnabled_RegistersCanonicalDashboardRoutes()
|
||||
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] canonicalRoutes =
|
||||
[
|
||||
"/dashboard/",
|
||||
"/dashboard/sessions",
|
||||
"/dashboard/workers",
|
||||
"/dashboard/events",
|
||||
"/dashboard/settings",
|
||||
"/dashboard/galaxy",
|
||||
"/dashboard/apikeys",
|
||||
"/dashboard/sessions/{SessionId}",
|
||||
"/",
|
||||
"/sessions",
|
||||
"/workers",
|
||||
"/events",
|
||||
"/settings",
|
||||
"/galaxy",
|
||||
"/apikeys",
|
||||
"/sessions/{SessionId}",
|
||||
];
|
||||
foreach (string canonical in canonicalRoutes)
|
||||
{
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical);
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical
|
||||
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +132,6 @@ public sealed class GatewayApplicationTests
|
||||
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.DoesNotContain(endpoints, endpoint =>
|
||||
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
|
||||
Assert.DoesNotContain(endpoints, endpoint =>
|
||||
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
|
||||
"Dashboard",
|
||||
@@ -174,13 +156,9 @@ public sealed class GatewayApplicationTests
|
||||
"",
|
||||
"MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData(
|
||||
"MxGateway:Dashboard:PathBase",
|
||||
"dashboard",
|
||||
"MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:RequiredGroup",
|
||||
"",
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
|
||||
"MxGateway:Dashboard:GroupToRole:GwAdmin",
|
||||
"BogusRole",
|
||||
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:AllowInsecureLdap",
|
||||
"false",
|
||||
|
||||
Reference in New Issue
Block a user