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>
329 lines
12 KiB
C#
329 lines
12 KiB
C#
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
|
|
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|
{
|
|
private const int MinimumMaxMessageBytes = 1024;
|
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
|
|
|
/// <summary>
|
|
/// Validates gateway configuration options.
|
|
/// </summary>
|
|
/// <param name="name">Options name.</param>
|
|
/// <param name="options">Gateway options to validate.</param>
|
|
/// <returns>Validation result.</returns>
|
|
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
|
{
|
|
List<string> failures = [];
|
|
|
|
ValidateAuthentication(options.Authentication, failures);
|
|
ValidateLdap(options.Ldap, failures);
|
|
ValidateWorker(options.Worker, failures);
|
|
ValidateSessions(options.Sessions, failures);
|
|
ValidateEvents(options.Events, failures);
|
|
ValidateDashboard(options.Dashboard, failures);
|
|
ValidateProtocol(options.Protocol, failures);
|
|
ValidateAlarms(options.Alarms, failures);
|
|
|
|
return failures.Count == 0
|
|
? ValidateOptionsResult.Success
|
|
: ValidateOptionsResult.Fail(failures);
|
|
}
|
|
|
|
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
|
{
|
|
if (!Enum.IsDefined(options.Mode))
|
|
{
|
|
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
|
return;
|
|
}
|
|
|
|
if (options.Mode == AuthenticationMode.ApiKey)
|
|
{
|
|
AddIfBlank(
|
|
options.SqlitePath,
|
|
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
|
failures);
|
|
AddIfInvalidPath(
|
|
options.SqlitePath,
|
|
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
|
failures);
|
|
AddIfBlank(
|
|
options.PepperSecretName,
|
|
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
|
{
|
|
if (!options.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
|
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
|
AddIfBlank(
|
|
options.ServiceAccountDn,
|
|
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
|
failures);
|
|
AddIfBlank(
|
|
options.ServiceAccountPassword,
|
|
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
|
failures);
|
|
AddIfBlank(
|
|
options.UserNameAttribute,
|
|
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
|
failures);
|
|
AddIfBlank(
|
|
options.DisplayNameAttribute,
|
|
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
|
failures);
|
|
AddIfBlank(
|
|
options.GroupAttribute,
|
|
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
|
failures);
|
|
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
|
|
|
if (!options.UseTls && !options.AllowInsecureLdap)
|
|
{
|
|
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
|
{
|
|
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
|
AddIfInvalidPath(
|
|
options.ExecutablePath,
|
|
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
|
failures);
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
|
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
|
{
|
|
AddIfInvalidPath(
|
|
options.WorkingDirectory,
|
|
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
|
failures);
|
|
}
|
|
|
|
if (!Enum.IsDefined(options.RequiredArchitecture))
|
|
{
|
|
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
|
}
|
|
|
|
AddIfNotPositive(
|
|
options.StartupTimeoutSeconds,
|
|
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.StartupProbeRetryAttempts,
|
|
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.StartupProbeRetryDelayMilliseconds,
|
|
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.ShutdownTimeoutSeconds,
|
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.HeartbeatIntervalSeconds,
|
|
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.HeartbeatGraceSeconds,
|
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
|
failures);
|
|
|
|
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
|
{
|
|
failures.Add(
|
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
|
}
|
|
|
|
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
|
{
|
|
failures.Add(
|
|
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
|
{
|
|
AddIfNotPositive(
|
|
options.DefaultCommandTimeoutSeconds,
|
|
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
|
AddIfNotPositive(
|
|
options.MaxPendingCommandsPerSession,
|
|
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.DefaultLeaseSeconds,
|
|
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
|
failures);
|
|
AddIfNotPositive(
|
|
options.LeaseSweepIntervalSeconds,
|
|
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
|
failures);
|
|
|
|
if (options.AllowMultipleEventSubscribers)
|
|
{
|
|
failures.Add(
|
|
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateEvents(EventOptions options, List<string> failures)
|
|
{
|
|
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
|
|
|
if (!Enum.IsDefined(options.BackpressurePolicy))
|
|
{
|
|
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
|
{
|
|
// 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)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entry.Key))
|
|
{
|
|
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}'.");
|
|
}
|
|
}
|
|
|
|
AddIfNotPositive(
|
|
options.SnapshotIntervalMilliseconds,
|
|
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
|
failures);
|
|
AddIfNegative(
|
|
options.RecentFaultLimit,
|
|
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
|
failures);
|
|
AddIfNegative(
|
|
options.RecentSessionLimit,
|
|
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
|
failures);
|
|
}
|
|
|
|
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
|
{
|
|
if (!options.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// When the central alarm monitor is enabled, it needs either a canonical
|
|
// SubscriptionExpression or a DefaultArea to compose one from. Validating
|
|
// it at startup makes the misconfiguration fail-fast at boot, in line
|
|
// with every other section.
|
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
|
{
|
|
failures.Add(
|
|
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
|
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
|
{
|
|
failures.Add(
|
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
|
}
|
|
}
|
|
|
|
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
|
{
|
|
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
|
{
|
|
failures.Add(
|
|
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
|
}
|
|
|
|
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
|
{
|
|
failures.Add(
|
|
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
|
}
|
|
}
|
|
|
|
private static void AddIfBlank(string? value, string message, List<string> failures)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
}
|
|
|
|
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
|
{
|
|
if (value <= 0)
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
}
|
|
|
|
private static void AddIfNegative(int value, string message, List<string> failures)
|
|
{
|
|
if (value < 0)
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
}
|
|
|
|
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
_ = Path.GetFullPath(value);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
catch (PathTooLongException)
|
|
{
|
|
failures.Add(message);
|
|
}
|
|
}
|
|
}
|