b177636d7a
GatewayBrowseScopeProvider resolved `identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty`, so an absent ambient identity produced an empty BrowseSubtrees list — which the shared GalaxyRepository library reads as "no scoping". The control's fail state was therefore open: any future path reaching the library's galaxy service without GatewayGrpcAuthorizationInterceptor having pushed the identity would silently hand a browse-scoped key the full hierarchy, with nothing failing and nothing logged. A constraint-narrowing control's absence is invisible in normal operation, which is what made the honest "safe only because the global interceptor runs first" comment insufficient on its own. The provider now distinguishes the two states the old expression collapsed: - authenticated key with no BrowseSubtrees constraint -> empty list, unscoped (unchanged, and now asserted in its own test rather than implied); - no ambient identity with Authentication:Mode=ApiKey -> log a warning naming the broken ordering invariant and throw RpcException(PermissionDenied); - no ambient identity with Authentication:Mode=Disabled -> unscoped, because nothing authenticates there so no key and no constraints exist to scope by. The registration-order comment at GatewayApplication.cs now says the ordering is still required but no longer silently trusted. Tests: GatewayBrowseScopeProviderTests gains the PermissionDenied case and the Disabled-mode case alongside the unconstrained-identity one. GalaxyRepositoryHostWiringTests phase 1 now pushes an identity carrying no BrowseSubtrees instead of pushing none — that, not an absent identity, is production's unscoped case. Docs: docs/Authorization.md's constraint section states the two-state distinction and both fail behaviours under browse_subtrees.
370 lines
18 KiB
C#
370 lines
18 KiB
C#
using System.Security.Cryptography.X509Certificates;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Configuration;
|
|
using ZB.MOM.WW.GalaxyRepository.DependencyInjection;
|
|
using ZB.MOM.WW.Health;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
|
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Configuration;
|
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Sqlite;
|
|
using ZB.MOM.WW.Secrets.Ui;
|
|
using ZB.MOM.WW.Telemetry;
|
|
using ZB.MOM.WW.Telemetry.Serilog;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server;
|
|
|
|
/// <summary>
|
|
/// Configures and builds the gateway web application.
|
|
/// </summary>
|
|
public static class GatewayApplication
|
|
{
|
|
private const string StaticAssetsManifestFileName = "ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json";
|
|
|
|
/// <summary>
|
|
/// Builds a configured web application with all gateway services and middleware.
|
|
/// </summary>
|
|
/// <param name="args">Command-line arguments passed to the application.</param>
|
|
/// <returns>A configured web application ready to run.</returns>
|
|
public static WebApplication Build(string[] args)
|
|
{
|
|
WebApplicationBuilder builder = CreateBuilder(args);
|
|
WebApplication app = builder.Build();
|
|
|
|
app.UseGatewayRequestLoggingScope();
|
|
app.UseStaticFiles();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
// The rate limiter must run after routing has selected the endpoint (so its
|
|
// RequireRateLimiting policy metadata is visible) and before the endpoint executes.
|
|
app.UseRateLimiter();
|
|
app.UseAntiforgery();
|
|
app.MapGatewayEndpoints();
|
|
|
|
return app;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a web application builder configured with gateway services.
|
|
/// </summary>
|
|
/// <param name="args">Command-line arguments passed to the application.</param>
|
|
/// <returns>A configured web application builder.</returns>
|
|
public static WebApplicationBuilder CreateBuilder(string[] args)
|
|
{
|
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
|
{
|
|
Args = args,
|
|
ContentRootPath = ResolveContentRootPath(),
|
|
});
|
|
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
|
|
|
ApplyDefaultSecretsStorePath(builder.Configuration);
|
|
|
|
// Resolve ${secret:...} references in configuration BEFORE any config consumer (TLS, Kestrel,
|
|
// GatewayOptions/Ldap/Galaxy validators) reads a value, using a standalone secrets provider
|
|
// (envelope-decrypted via the master key). A token referencing a missing secret fails fast
|
|
// here (SecretNotFoundException); config with no tokens is untouched (no-op), so this is safe
|
|
// to always run. CreateBuilder is synchronous and single-shot at bootstrap, so the two awaits
|
|
// are driven via GetAwaiter().GetResult() (no sync-context deadlock risk during host startup).
|
|
// The content root is passed explicitly because this container is a throwaway
|
|
// ServiceCollection with no IHostEnvironment in it. Without it the library cannot tell
|
|
// "no content root exists" from "no host is registered", so it skips the
|
|
// under-content-root rule — and the migrator below CREATES the store before the real host
|
|
// ever validates. The boot then fails a moment later, having already left an empty
|
|
// database with its -wal/-shm siblings at the very path the rule rejects. That artifact is
|
|
// what made the 2026-08-09 outage read as "the database is there, it's just empty".
|
|
// DO NOT simplify this to the 3-argument overload: it still compiles, the app still boots
|
|
// when the path is correct, and the guard silently stops running at the one moment that
|
|
// matters.
|
|
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
|
|
using (var secretsProvider = new ServiceCollection()
|
|
.AddZbSecrets(builder.Configuration, "Secrets", builder.Environment.ContentRootPath)
|
|
.BuildServiceProvider())
|
|
#pragma warning restore ASP0000
|
|
{
|
|
// Intentionally unconditional: the pre-host expander below needs the secrets
|
|
// store schema to exist before the first ${secret:} resolve. Secrets:RunMigrationsOnStartup
|
|
// governs only the runtime SecretsMigrationHostedService, not this bootstrap path.
|
|
secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
|
|
.MigrateAsync(default).GetAwaiter().GetResult();
|
|
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
|
|
new SecretReferenceExpander(resolver)
|
|
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default)
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
|
|
ConfigureSelfSignedTls(builder);
|
|
|
|
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
|
|
|
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
|
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
|
|
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
|
builder.Services.AddGatewayGrpcAuthorization();
|
|
AddLoginRateLimiter(builder);
|
|
builder.Services.AddHealthChecks()
|
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
|
"auth-store",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Ready })
|
|
// Active, not Ready: a gateway holding no sessions is legitimately ready to serve.
|
|
// See SessionHealthCheck for why zero sessions is healthy.
|
|
.AddTypeActivatedCheck<SessionHealthCheck>(
|
|
"mxaccess-sessions",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Active });
|
|
builder.Services.AddSingleton<GatewayMetrics>();
|
|
builder.AddZbTelemetry(o =>
|
|
{
|
|
o.ServiceName = "mxgateway";
|
|
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
|
|
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
|
o.Exporter = exporter;
|
|
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
|
|
if (!string.IsNullOrWhiteSpace(otlp))
|
|
o.OtlpEndpoint = otlp;
|
|
});
|
|
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
|
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
|
builder.Services.AddWorkerProcessLauncher();
|
|
builder.Services.AddGatewaySessions();
|
|
builder.Services.AddGatewayAlarms();
|
|
builder.Services.AddGatewayDashboard(builder.Configuration);
|
|
// Register the shared Secrets UI authorization policies (secrets:manage + secrets:reveal)
|
|
// additively so they compose with the dashboard's existing AddAuthorization block. The
|
|
// mounted /admin/secrets Blazor page carries [Authorize(Policy = "secrets:manage")].
|
|
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
|
// Register the gateway's browse-scope provider before AddZbGalaxyRepository so the
|
|
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
|
|
// The provider depends on GatewayGrpcAuthorizationInterceptor (registered globally above)
|
|
// having pushed the ambient identity before the library's galaxy service runs. That
|
|
// ordering is still required, but it is no longer trusted silently: with
|
|
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
|
// invariant when the identity is absent, instead of resolving to the full hierarchy
|
|
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
|
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
|
Security.Authorization.GatewayBrowseScopeProvider>();
|
|
|
|
// The Galaxy package binds GalaxyRepositoryOptions but ships no validator or default for the
|
|
// snapshot path (A2 handoff): the gateway owns both because it is the process that writes the
|
|
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
|
|
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
|
|
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
|
|
// byte-identical to the removed appsettings literal on Windows (SEC-33).
|
|
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
|
|
{
|
|
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"MxGateway",
|
|
"galaxy-snapshot.json");
|
|
}
|
|
|
|
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
|
|
|
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
|
|
builder.Services.AddSingleton<
|
|
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
|
Configuration.GalaxyRepositoryOptionsValidator>();
|
|
builder.Services.AddOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>().ValidateOnStart();
|
|
|
|
return builder;
|
|
}
|
|
|
|
// Registers the named fixed-window rate-limiter policy applied to POST /auth/login. The
|
|
// limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on
|
|
// the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition).
|
|
private static void AddLoginRateLimiter(WebApplicationBuilder builder)
|
|
{
|
|
SecurityOptions security =
|
|
builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
|
|
?? new SecurityOptions();
|
|
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy(
|
|
DashboardEndpointRouteBuilderExtensions.LoginRateLimiterPolicy,
|
|
httpContext => DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition(
|
|
httpContext,
|
|
security));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Supplies the default location of the encrypted secrets store when nothing configured one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The store used to default to a bare relative <c>mxgateway-secrets.db</c>, which resolves
|
|
/// against the working directory and therefore normally lands inside the application directory.
|
|
/// That is the shape that lost every API key on a production host: the upgrade procedure renames
|
|
/// the application directory away, the store goes with it, and a fresh empty one appears in its
|
|
/// place with no error. In development the same default writes a database into the source tree.
|
|
/// </para>
|
|
/// <para>
|
|
/// This sets a default for an <em>unset</em> key; it never relocates a value someone configured.
|
|
/// That distinction matters — <see cref="Configuration.GatewayConfigPathRules"/> deliberately
|
|
/// rejects bad configured paths rather than quietly moving them, because silently relocating a
|
|
/// credential store is worse than a boot error. Choosing where to put a value nobody specified
|
|
/// is a different act from overriding one they did.
|
|
/// </para>
|
|
/// <para>
|
|
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
|
|
/// together, and the mechanism is the one SEC-33 already used for
|
|
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
|
|
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
|
|
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
|
|
/// that a working-directory-relative path can cause. On non-Windows hosts
|
|
/// <see cref="Environment.SpecialFolder.CommonApplicationData"/> is typically not writable by a
|
|
/// normal user, so a local run there must set <c>Secrets__SqlitePath</c> explicitly, exactly as
|
|
/// it already must for the auth store.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>This deliberately differs from the <c>ZB.MOM.WW.Secrets</c> library default</b>, which is
|
|
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/>-derived so the family's
|
|
/// cross-platform apps still boot locally without an override. The gateway keeps
|
|
/// <c>CommonApplicationData</c> because it runs as a machine-wide Windows service and its other
|
|
/// two stores — the auth database and the Galaxy snapshot — already live there; splitting them
|
|
/// would be the greater inconsistency. The value set here always wins, so the library default is
|
|
/// unreachable in this app. Do not "fix" the difference by deleting this method: that would
|
|
/// silently move the store, which is the failure this whole rule exists to prevent.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="configuration">The configuration to supply the default into.</param>
|
|
private static void ApplyDefaultSecretsStorePath(IConfiguration configuration)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuration["Secrets:SqlitePath"]))
|
|
{
|
|
return;
|
|
}
|
|
|
|
configuration["Secrets:SqlitePath"] = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"MxGateway",
|
|
"mxgateway-secrets.db");
|
|
}
|
|
|
|
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
|
|
{
|
|
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Configuration.TlsOptions tlsOptions =
|
|
builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>()
|
|
?? new Configuration.TlsOptions();
|
|
|
|
using ILoggerFactory loggerFactory = LoggerFactory.Create(logging =>
|
|
{
|
|
logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
|
|
logging.AddConsole();
|
|
});
|
|
Security.Tls.SelfSignedCertificateProvider provider = new(
|
|
tlsOptions,
|
|
loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
|
|
TimeProvider.System);
|
|
|
|
X509Certificate2 certificate = provider.LoadOrCreate();
|
|
builder.WebHost.ConfigureKestrel(options =>
|
|
// The certificate is intentionally owned by Kestrel for the application
|
|
// lifetime; it is not disposed here.
|
|
options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate));
|
|
}
|
|
|
|
private static string ResolveContentRootPath()
|
|
{
|
|
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
|
|
if (!string.IsNullOrWhiteSpace(configuredContentRootPath)
|
|
&& IsServerContentRoot(configuredContentRootPath))
|
|
{
|
|
return configuredContentRootPath;
|
|
}
|
|
|
|
string currentDirectory = Environment.CurrentDirectory;
|
|
if (IsServerContentRoot(currentDirectory))
|
|
{
|
|
return currentDirectory;
|
|
}
|
|
|
|
string baseDirectory = AppContext.BaseDirectory;
|
|
if (IsServerContentRoot(baseDirectory))
|
|
{
|
|
return baseDirectory;
|
|
}
|
|
|
|
string? discoveredContentRootPath = DiscoverServerContentRoot(currentDirectory)
|
|
?? DiscoverServerContentRoot(baseDirectory);
|
|
|
|
return discoveredContentRootPath ?? baseDirectory;
|
|
}
|
|
|
|
private static string? DiscoverServerContentRoot(string startPath)
|
|
{
|
|
DirectoryInfo? directory = new(startPath);
|
|
while (directory is not null)
|
|
{
|
|
if (IsServerContentRoot(directory.FullName))
|
|
{
|
|
return directory.FullName;
|
|
}
|
|
|
|
string serverProjectPath = Path.Combine(directory.FullName, "src", "ZB.MOM.WW.MxGateway.Server");
|
|
if (IsServerContentRoot(serverProjectPath))
|
|
{
|
|
return serverProjectPath;
|
|
}
|
|
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static bool IsServerContentRoot(string path)
|
|
{
|
|
return File.Exists(Path.Combine(path, "appsettings.json"))
|
|
&& Directory.Exists(Path.Combine(path, "wwwroot"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps gateway endpoints including gRPC services, health checks, and the dashboard.
|
|
/// </summary>
|
|
/// <param name="endpoints">Endpoint route builder to map endpoints to.</param>
|
|
/// <returns>The same endpoint route builder for chaining.</returns>
|
|
public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
|
|
|
endpoints.MapZbHealth();
|
|
endpoints.MapZbMetrics();
|
|
|
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
|
endpoints.MapZbGalaxyRepository();
|
|
endpoints.MapGatewayDashboard();
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
private static string ResolveStaticAssetsManifestPath()
|
|
{
|
|
string manifestPath = Path.Combine(AppContext.BaseDirectory, StaticAssetsManifestFileName);
|
|
|
|
return File.Exists(manifestPath) ? manifestPath : StaticAssetsManifestFileName;
|
|
}
|
|
}
|