27ed65114e
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>
259 lines
9.2 KiB
C#
259 lines
9.2 KiB
C#
using System.Security.Claims;
|
|
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.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
public sealed class DashboardApiKeyManagementServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore);
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(0, adminStore.CreateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new();
|
|
FakeApiKeyAuditStore auditStore = new();
|
|
FakeApiKeySecretHasher hasher = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
CreateRequest(),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.NotNull(result.ApiKey);
|
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
|
string secret = result.ApiKey["mxgw_operator01_".Length..];
|
|
Assert.Equal(secret, hasher.LastSecret);
|
|
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
|
|
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
|
|
Assert.Equal("operator01", stored.KeyId);
|
|
Assert.Equal("Operator", stored.DisplayName);
|
|
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
|
|
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
|
|
Assert.Contains(auditStore.Entries, entry =>
|
|
entry.EventType == "dashboard-create-key"
|
|
&& entry.KeyId == "operator01");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore);
|
|
|
|
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(0, adminStore.RevokeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
|
|
FakeApiKeyAuditStore auditStore = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
|
|
|
|
DashboardApiKeyManagementResult result = await service.RevokeAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
|
|
Assert.Contains(auditStore.Entries, entry =>
|
|
entry.EventType == "dashboard-revoke-key"
|
|
&& entry.KeyId == "operator01"
|
|
&& entry.Details == "revoked");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
|
|
FakeApiKeyAuditStore auditStore = new();
|
|
FakeApiKeySecretHasher hasher = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
|
|
|
|
DashboardApiKeyManagementResult result = await service.RotateAsync(
|
|
CreateAuthorizedUser(),
|
|
"operator01",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.NotNull(result.ApiKey);
|
|
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
|
|
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
|
|
Assert.Contains(auditStore.Entries, entry =>
|
|
entry.EventType == "dashboard-rotate-key"
|
|
&& entry.KeyId == "operator01"
|
|
&& entry.Details == "rotated");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-004 regression: the dashboard create path must reject a request
|
|
/// carrying a non-canonical scope string rather than persisting a key whose
|
|
/// scope the authorization resolver never matches.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CreateAsync_UnknownScope_DoesNotCallStore()
|
|
{
|
|
FakeApiKeyAdminStore adminStore = new();
|
|
DashboardApiKeyManagementService service = CreateService(adminStore);
|
|
|
|
DashboardApiKeyManagementRequest request = CreateRequest() with
|
|
{
|
|
Scopes = new HashSet<string>(
|
|
[GatewayScopes.SessionOpen, "invoke", "metadata"],
|
|
StringComparer.Ordinal),
|
|
};
|
|
|
|
DashboardApiKeyManagementResult result = await service.CreateAsync(
|
|
CreateAuthorizedUser(),
|
|
request,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(0, adminStore.CreateCount);
|
|
}
|
|
|
|
private static DashboardApiKeyManagementService CreateService(
|
|
FakeApiKeyAdminStore? adminStore = null,
|
|
FakeApiKeyAuditStore? auditStore = null,
|
|
FakeApiKeySecretHasher? hasher = null)
|
|
{
|
|
DefaultHttpContext httpContext = new();
|
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
|
|
|
return new DashboardApiKeyManagementService(
|
|
new DashboardApiKeyAuthorization(),
|
|
adminStore ?? new FakeApiKeyAdminStore(),
|
|
auditStore ?? new FakeApiKeyAuditStore(),
|
|
hasher ?? new FakeApiKeySecretHasher(),
|
|
new HttpContextAccessor { HttpContext = httpContext });
|
|
}
|
|
|
|
private static DashboardApiKeyManagementRequest CreateRequest()
|
|
{
|
|
return new DashboardApiKeyManagementRequest(
|
|
KeyId: "operator01",
|
|
DisplayName: "Operator",
|
|
Scopes: new HashSet<string>([GatewayScopes.SessionOpen], StringComparer.Ordinal),
|
|
Constraints: ApiKeyConstraints.Empty with
|
|
{
|
|
BrowseSubtrees = ["Area1/*"],
|
|
});
|
|
}
|
|
|
|
private static ClaimsPrincipal CreateAuthorizedUser()
|
|
{
|
|
ClaimsIdentity identity = new(
|
|
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
|
ClaimTypes.Name,
|
|
ClaimTypes.Role);
|
|
|
|
return new ClaimsPrincipal(identity);
|
|
}
|
|
|
|
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
|
|
{
|
|
public int CreateCount { get; private set; }
|
|
|
|
public int RevokeCount { get; private set; }
|
|
|
|
public bool RevokeResult { get; init; }
|
|
|
|
public bool RotateResult { get; init; }
|
|
|
|
public string? LastRevokedKeyId { get; private set; }
|
|
|
|
public byte[]? LastRotatedSecretHash { get; private set; }
|
|
|
|
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
|
|
|
|
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
|
{
|
|
CreateCount++;
|
|
CreatedRequests.Add(request);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
|
}
|
|
|
|
public Task<bool> RevokeAsync(
|
|
string keyId,
|
|
DateTimeOffset revokedUtc,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
RevokeCount++;
|
|
LastRevokedKeyId = keyId;
|
|
return Task.FromResult(RevokeResult);
|
|
}
|
|
|
|
public Task<bool> RotateAsync(
|
|
string keyId,
|
|
byte[] secretHash,
|
|
DateTimeOffset rotatedUtc,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LastRotatedSecretHash = secretHash;
|
|
return Task.FromResult(RotateResult);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
|
|
{
|
|
public List<ApiKeyAuditEntry> Entries { get; } = [];
|
|
|
|
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
|
{
|
|
Entries.Add(entry);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
|
|
int count,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
|
|
}
|
|
}
|
|
|
|
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
|
|
{
|
|
public string? LastSecret { get; private set; }
|
|
|
|
public byte[] HashSecret(string secret)
|
|
{
|
|
LastSecret = secret;
|
|
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
|
|
}
|
|
}
|
|
}
|