Compare commits
7 Commits
5f4ecabc8a
...
e088dfabf7
| Author | SHA1 | Date | |
|---|---|---|---|
| e088dfabf7 | |||
| 2e7245dd55 | |||
| 22a878c36e | |||
| b79e119ada | |||
| a2538039c0 | |||
| 1533cd3909 | |||
| 9552b79481 |
@@ -245,7 +245,7 @@ dev/test GLAuth posture (`glauth.md`), not a production posture.
|
||||
| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
|
||||
| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
|
||||
| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. |
|
||||
| `MxGateway:Ldap:ServiceAccountPassword` | `${secret:ldap/mxgateway/bind}` | Search-account password. **No longer a committed plaintext value:** `appsettings.json` ships the reference `${secret:ldap/mxgateway/bind}`, which the pre-host `${secret:}` expander resolves at startup from the encrypted secrets store (the code-side design default is now blank, so a missing/unresolved value fails closed rather than falling back to a leaked credential). Seed the value once with `secret set ldap/mxgateway/bind <value>` (the store's master key must be present via `ZB_SECRETS_MASTER_KEY`); startup aborts with `SecretNotFoundException` if the secret is absent. An operator may instead override it directly with the env var `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form) — a plain literal there is used as-is and the secret lookup is skipped. |
|
||||
| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
|
||||
| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
|
||||
| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
|
||||
@@ -255,6 +255,88 @@ When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`,
|
||||
must be in range. See `glauth.md` for the shared dev instance and the
|
||||
dev→production hardening posture.
|
||||
|
||||
## Secrets Master Key
|
||||
|
||||
`${secret:...}` tokens in configuration — currently just
|
||||
`MxGateway:Ldap:ServiceAccountPassword` (see above) — are resolved at startup
|
||||
from an encrypted local SQLite store, bound from the top-level `Secrets`
|
||||
section (a sibling of `MxGateway`, not nested under it):
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `Secrets:SqlitePath` | `mxgateway-secrets.db` | Path to the encrypted secrets store, resolved relative to the app content root when not rooted. |
|
||||
| `Secrets:MasterKey:Source` | `Environment` | Key-encryption-key (KEK) provider. `Environment` reads a base64-encoded 32-byte key from an env var; `Dpapi` uses a machine-bound key file instead (see below). |
|
||||
| `Secrets:MasterKey:EnvVarName` | `ZB_SECRETS_MASTER_KEY` | Env var name the `Environment` provider reads the KEK from. |
|
||||
|
||||
The gateway always opens (and, if needed, creates and schema-migrates) this
|
||||
store at startup, even when zero `${secret:}` tokens are configured.
|
||||
|
||||
### Delivering the master key on the NSSM box
|
||||
|
||||
MxAccessGateway is a single Windows/NSSM box with no clustering, so the
|
||||
simplest posture applies: the `Environment` provider delivers
|
||||
`ZB_SECRETS_MASTER_KEY` via the NSSM service environment — the same mechanism
|
||||
already used for `MxGateway__Ldap__ServiceAccountPassword` overrides and the
|
||||
other `AppEnvironmentExtra` entries (see `wonder-app-vd03`/`windev` deploy
|
||||
notes). The key is **never committed** to `appsettings.json` or the repo.
|
||||
|
||||
```text
|
||||
nssm get MxAccessGw AppEnvironmentExtra
|
||||
nssm set MxAccessGw AppEnvironmentExtra ZB_SECRETS_MASTER_KEY=<base64-32-bytes>
|
||||
```
|
||||
|
||||
`AppEnvironmentExtra` is the same newline-separated `KEY=VALUE` block that
|
||||
already carries `ASPNETCORE_ENVIRONMENT`, `MxGateway__Worker__ExecutablePath`,
|
||||
and the API-key pepper (see the `wonder-app-vd03`/`windev` deploy notes) —
|
||||
`nssm set ... AppEnvironmentExtra` **replaces the entire block**, so always
|
||||
`get` first and re-include every existing entry alongside the new key, then
|
||||
restart the service for it to take effect.
|
||||
|
||||
### Seeding a secret
|
||||
|
||||
Once the master key is in place, seed (or update) a secret on the box with
|
||||
the `secret` CLI (`ZB.MOM.WW.Secrets.Cli`). The CLI is config-driven — it
|
||||
reads `Secrets:SqlitePath` and the master-key settings from its own
|
||||
`appsettings.json` plus environment variables, exactly like the gateway — so
|
||||
run it with the same `ZB_SECRETS_MASTER_KEY` set and, if it isn't colocated
|
||||
with a config that already points at the gateway's store, an explicit
|
||||
`Secrets__SqlitePath` override so both processes read/write the same file:
|
||||
|
||||
```text
|
||||
set ZB_SECRETS_MASTER_KEY=<base64-32-bytes>
|
||||
set Secrets__SqlitePath=C:\ProgramData\MxGateway\mxgateway-secrets.db
|
||||
secret set ldap/mxgateway/bind <the real LDAP bind password>
|
||||
```
|
||||
|
||||
A mismatched `Secrets:SqlitePath` between the CLI and the gateway silently
|
||||
seeds a different store, and the gateway then fails closed at startup as if
|
||||
the secret were never set.
|
||||
|
||||
### Fail-closed
|
||||
|
||||
If a referenced `${secret:...}` token cannot be resolved — the secret is
|
||||
missing from the store, or `ZB_SECRETS_MASTER_KEY` is absent or wrong — the
|
||||
gateway refuses to start (`SecretNotFoundException`, or a master-key error
|
||||
raised before that). There is no fallback to a blank or leaked credential.
|
||||
|
||||
### Alternative: DPAPI
|
||||
|
||||
`Secrets:MasterKey:Source = Dpapi` uses a machine-bound key file instead of an
|
||||
env var — stronger at-rest binding (the key never appears in the service
|
||||
environment or process listing) at the cost of tying the store to one
|
||||
machine, which complicates DR restore (the key file does not travel with a
|
||||
backup/restore of the `.db` file alone). Not used on the current NSSM
|
||||
deployments; documented here as the alternative posture if the env-var
|
||||
exposure becomes a concern.
|
||||
|
||||
### Rotation / DR
|
||||
|
||||
Losing the KEK makes the store permanently unrecoverable — there is no
|
||||
recovery path other than re-seeding every secret from scratch. Back the KEK
|
||||
up in the org secret vault, not just on the box. Full KEK rotation
|
||||
(re-wrapping every secret under a new key, `RewrapAll`) is a future item and
|
||||
is **not yet available**.
|
||||
|
||||
## Protocol Options
|
||||
|
||||
| Option | Default | Description |
|
||||
|
||||
@@ -236,10 +236,19 @@ LDAP groups resolve to the `Admin` role succeeds and emits the role claim;
|
||||
the password into `FailureMessage`; an unknown username fails authentication; and
|
||||
an unreachable LDAP server is absorbed into a failed result rather than throwing.
|
||||
|
||||
`appsettings.json` now ships the LDAP bind password as the unexpanded
|
||||
`${secret:ldap/mxgateway/bind}` token (resolved at gateway startup by the
|
||||
pre-host secrets expander, which this suite's bare `ConfigurationBuilder`
|
||||
does not run). Before running the live LDAP suite, set
|
||||
`MxGateway__Ldap__ServiceAccountPassword` to the real GLAuth service-account
|
||||
password (dev value `serviceaccount123` for the shared GLAuth) so the suite
|
||||
binds with the real password instead of the literal token.
|
||||
|
||||
Run the LDAP live tests explicitly:
|
||||
|
||||
```bash
|
||||
$env:MXGATEWAY_RUN_LIVE_LDAP_TESTS = "1"
|
||||
$env:MxGateway__Ldap__ServiceAccountPassword = "serviceaccount123"
|
||||
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
|
||||
```
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<package pattern="ZB.MOM.WW.Audit" />
|
||||
<package pattern="ZB.MOM.WW.Theme" />
|
||||
<package pattern="ZB.MOM.WW.GalaxyRepository" />
|
||||
<package pattern="ZB.MOM.WW.Secrets" />
|
||||
<package pattern="ZB.MOM.WW.Secrets.*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -161,12 +161,32 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(appSettingsPath, optional: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
// Same section production binds in AddZbLdapAuth(configuration, "MxGateway:Ldap").
|
||||
// Get<T> returns null only when the section is absent; appsettings.json always
|
||||
// carries it, so fall back to shared defaults defensively rather than throw.
|
||||
return configuration.GetSection("MxGateway:Ldap").Get<LibraryLdapOptions>()
|
||||
LibraryLdapOptions options = configuration.GetSection("MxGateway:Ldap").Get<LibraryLdapOptions>()
|
||||
?? new LibraryLdapOptions();
|
||||
|
||||
// appsettings.json now ships the bind password as the unexpanded
|
||||
// "${secret:ldap/mxgateway/bind}" token (resolved at gateway startup by the
|
||||
// pre-host secrets expander, which this bare ConfigurationBuilder does not run).
|
||||
// AddEnvironmentVariables() above lets MxGateway__Ldap__ServiceAccountPassword
|
||||
// override the token with the real password. Fail loud rather than silently
|
||||
// binding with the literal token string, which would make every live test in
|
||||
// this file fail with a confusing LDAP bind error instead of an actionable one.
|
||||
if (string.IsNullOrEmpty(options.ServiceAccountPassword)
|
||||
|| options.ServiceAccountPassword.StartsWith("${secret:", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live LDAP tests require the real bind password via the "
|
||||
+ "MxGateway__Ldap__ServiceAccountPassword environment variable "
|
||||
+ "(appsettings now ships a ${secret:} token that this suite does not expand). "
|
||||
+ "Set it before running.");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class LdapOptions
|
||||
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
|
||||
|
||||
/// <summary>Gets the service account password.</summary>
|
||||
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
||||
public string ServiceAccountPassword { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the LDAP attribute name for user names.</summary>
|
||||
public string UserNameAttribute { get; init; } = "cn";
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</NavRailSection>
|
||||
<NavRailSection Title="Admin" Key="admin">
|
||||
<NavRailItem Href="/apikeys" Text="API Keys" />
|
||||
<NavRailItem Href="/admin/secrets" Text="Secrets" />
|
||||
<NavRailItem Href="/settings" Text="Settings" />
|
||||
</NavRailSection>
|
||||
</Nav>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<Router AppAssembly="@typeof(Routes).Assembly">
|
||||
<Router AppAssembly="@typeof(Routes).Assembly"
|
||||
AdditionalAssemblies="new[]{ typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly }">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
|
||||
@@ -132,6 +132,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
// cookie scheme and redirected to /login.
|
||||
endpoints.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly)
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.ViewerPolicy);
|
||||
|
||||
return endpoints;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Configuration;
|
||||
@@ -15,6 +16,11 @@ 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;
|
||||
|
||||
@@ -64,11 +70,35 @@ public static class GatewayApplication
|
||||
});
|
||||
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, 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).
|
||||
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
|
||||
using (var secretsProvider = new ServiceCollection()
|
||||
.AddZbSecrets(builder.Configuration, "Secrets")
|
||||
.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);
|
||||
@@ -96,6 +126,10 @@ public static class GatewayApplication
|
||||
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.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.GalaxyRepository" Version="0.2.0" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.1.2" />
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Ui" Version="0.1.2" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Secrets": {
|
||||
"SqlitePath": "mxgateway-secrets.db",
|
||||
"MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
|
||||
"RunMigrationsOnStartup": true,
|
||||
"ResolveCacheTtl": "00:00:30"
|
||||
},
|
||||
"MxGateway": {
|
||||
"Authentication": {
|
||||
"Mode": "ApiKey",
|
||||
@@ -26,7 +32,7 @@
|
||||
"AllowInsecure": true,
|
||||
"SearchBase": "dc=zb,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"ServiceAccountPassword": "${secret:ldap/mxgateway/bind}",
|
||||
"UserNameAttribute": "cn",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf"
|
||||
|
||||
@@ -11,7 +11,16 @@ public sealed class GatewayOptionsTests
|
||||
[Fact]
|
||||
public void OptionsBinding_UsesDesignDefaults()
|
||||
{
|
||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>());
|
||||
// The LDAP bind password now defaults to blank (sourced from ${secret:ldap/mxgateway/bind}
|
||||
// at runtime), so enabled-LDAP options fail validation until a value is supplied. Seed one
|
||||
// so binding validates; the blank design default itself is asserted below.
|
||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
|
||||
});
|
||||
|
||||
// The bind password is no longer a leaked plaintext literal; its design default is blank.
|
||||
Assert.Equal(string.Empty, new LdapOptions().ServiceAccountPassword);
|
||||
|
||||
Assert.Equal(AuthenticationMode.ApiKey, options.Authentication.Mode);
|
||||
// The default is derived from CommonApplicationData (C:\ProgramData on Windows,
|
||||
@@ -75,7 +84,9 @@ public sealed class GatewayOptionsTests
|
||||
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
|
||||
["MxGateway:Events:QueueCapacity"] = "256",
|
||||
["MxGateway:Dashboard:Enabled"] = "false",
|
||||
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608"
|
||||
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608",
|
||||
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
|
||||
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
|
||||
});
|
||||
|
||||
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
|
||||
@@ -118,7 +129,9 @@ public sealed class GatewayOptionsTests
|
||||
using ServiceProvider services = BuildServices(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName"
|
||||
["MxGateway:Authentication:PepperSecretName"] = "RawPepperSecretName",
|
||||
// Blank-by-default bind password must be supplied so enabled LDAP validates on bind.
|
||||
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password"
|
||||
});
|
||||
|
||||
IGatewayConfigurationProvider provider = services.GetRequiredService<IGatewayConfigurationProvider>();
|
||||
|
||||
@@ -6,17 +6,27 @@ namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidatorTests
|
||||
{
|
||||
// A non-blank LDAP bind password for tests. The shipped design default is now blank
|
||||
// (string.Empty) so the secret-sourced value fails closed if unresolved; enabled-LDAP test
|
||||
// options must therefore supply an explicit password to pass the required-field check.
|
||||
private const string TestBindPassword = "test-bind-password";
|
||||
|
||||
// Constructs the minimal valid GatewayOptions by relying on each sub-option's
|
||||
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
||||
private static GatewayOptions ValidOptions() => new();
|
||||
// The one exception is the LDAP bind password (blank by design default), which we supply.
|
||||
private static GatewayOptions ValidOptions() => new() { Ldap = ValidLdapOptions() };
|
||||
|
||||
// Enabled LDAP options with all class defaults plus the non-blank bind password.
|
||||
private static LdapOptions ValidLdapOptions() => new() { ServiceAccountPassword = TestBindPassword };
|
||||
|
||||
// Returns enabled LDAP options that pass all checks except Port.
|
||||
// The class defaults already satisfy the blank-field checks; we only
|
||||
// override Enabled (must be true to exercise the port check) and Port.
|
||||
// The class defaults already satisfy the remaining blank-field checks; we override Enabled
|
||||
// (must be true to exercise the port check), Port, and the now-blank-by-default bind password.
|
||||
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Port = port,
|
||||
ServiceAccountPassword = TestBindPassword,
|
||||
};
|
||||
|
||||
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
||||
@@ -667,12 +677,38 @@ public sealed class GatewayOptionsValidatorTests
|
||||
{
|
||||
GatewayOptions options = CloneWithLdap(
|
||||
ValidOptions(),
|
||||
new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false });
|
||||
new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
AllowInsecure = false,
|
||||
ServiceAccountPassword = TestBindPassword,
|
||||
});
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
private static GatewayOptions WithSecurity(SecurityOptions security) => new() { Security = security };
|
||||
/// <summary>
|
||||
/// Verifies enabled LDAP with a blank <see cref="LdapOptions.ServiceAccountPassword"/> fails
|
||||
/// validation. Locks in the fail-closed design default (blank) introduced when the bind password
|
||||
/// moved to the encrypted secrets store (<c>${secret:ldap/mxgateway/bind}</c>): a blanked/unresolved
|
||||
/// password must abort startup rather than silently binding with an empty credential.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenLdapEnabledAndServiceAccountPasswordBlank()
|
||||
{
|
||||
GatewayOptions options = CloneWithLdap(
|
||||
ValidOptions(),
|
||||
new LdapOptions { Enabled = true, ServiceAccountPassword = string.Empty });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled."));
|
||||
}
|
||||
|
||||
private static GatewayOptions WithSecurity(SecurityOptions security)
|
||||
=> new() { Security = security, Ldap = ValidLdapOptions() };
|
||||
|
||||
/// <summary>Verifies the default security options pass validation.</summary>
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.Secrets.Configuration;
|
||||
using ZB.MOM.WW.Secrets.DependencyInjection;
|
||||
using ZB.MOM.WW.Secrets.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the exact pre-host <c>${secret:}</c> expansion flow that
|
||||
/// <see cref="ZB.MOM.WW.MxGateway.Server.GatewayApplication.CreateBuilder"/> runs before the host is
|
||||
/// built: a throwaway <c>AddZbSecrets</c> container migrates the SQLite store, then a
|
||||
/// <see cref="SecretReferenceExpander"/> rewrites configuration tokens in place. A seeded reference
|
||||
/// resolves to its plaintext; a reference to an absent secret is fail-closed
|
||||
/// (<see cref="SecretNotFoundException"/>).
|
||||
/// </summary>
|
||||
public sealed class PreHostSecretExpansionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext()
|
||||
{
|
||||
using var fixture = SecretsFixture.Create();
|
||||
const string plaintext = "s3cr3t-plaintext-value";
|
||||
await fixture.SeedAsync("test/value", plaintext);
|
||||
|
||||
IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:test/value}"));
|
||||
|
||||
await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default);
|
||||
|
||||
Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G-4: the shipped <c>appsettings.json</c> sources the LDAP bind password from
|
||||
/// <c>${secret:ldap/mxgateway/bind}</c>. Proves that exact reference at that exact key resolves to
|
||||
/// the seeded plaintext through the same pre-host expander the host runs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword()
|
||||
{
|
||||
using var fixture = SecretsFixture.Create();
|
||||
const string bindPassword = "seeded-bind-password";
|
||||
await fixture.SeedAsync("ldap/mxgateway/bind", bindPassword);
|
||||
|
||||
IConfigurationRoot config = fixture.BuildConfig(
|
||||
("MxGateway:Ldap:ServiceAccountPassword", "${secret:ldap/mxgateway/bind}"));
|
||||
|
||||
await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default);
|
||||
|
||||
Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
|
||||
{
|
||||
using var fixture = SecretsFixture.Create();
|
||||
|
||||
IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:missing}"));
|
||||
|
||||
await Assert.ThrowsAsync<SecretNotFoundException>(
|
||||
() => new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained secrets store: a unique temp SQLite path + a per-test master-key env var
|
||||
/// (so parallel tests never collide), wired through the real <c>AddZbSecrets</c> DI graph the
|
||||
/// host uses. Disposal removes the temp file and clears the env var.
|
||||
/// </summary>
|
||||
private sealed class SecretsFixture : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly string _sqlitePath;
|
||||
private readonly string _envVarName;
|
||||
|
||||
private SecretsFixture(ServiceProvider provider, string sqlitePath, string envVarName)
|
||||
{
|
||||
_provider = provider;
|
||||
_sqlitePath = sqlitePath;
|
||||
_envVarName = envVarName;
|
||||
}
|
||||
|
||||
public ISecretResolver Resolver => _provider.GetRequiredService<ISecretResolver>();
|
||||
|
||||
public static SecretsFixture Create()
|
||||
{
|
||||
string unique = Guid.NewGuid().ToString("N");
|
||||
string sqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-secrets-{unique}.db");
|
||||
string envVarName = $"ZB_SECRETS_MASTER_KEY_TEST_{unique}";
|
||||
Environment.SetEnvironmentVariable(envVarName, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
|
||||
|
||||
IConfigurationRoot secretsConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:SqlitePath"] = sqlitePath,
|
||||
["Secrets:MasterKey:Source"] = "Environment",
|
||||
["Secrets:MasterKey:EnvVarName"] = envVarName,
|
||||
["Secrets:RunMigrationsOnStartup"] = "true",
|
||||
["Secrets:ResolveCacheTtl"] = "00:00:30",
|
||||
})
|
||||
.Build();
|
||||
|
||||
ServiceProvider provider = new ServiceCollection()
|
||||
.AddZbSecrets(secretsConfig, "Secrets")
|
||||
.BuildServiceProvider();
|
||||
|
||||
// Mirror the pre-host path: migrate the store explicitly before the first resolve.
|
||||
provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default).GetAwaiter().GetResult();
|
||||
|
||||
return new SecretsFixture(provider, sqlitePath, envVarName);
|
||||
}
|
||||
|
||||
// Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the
|
||||
// CLI 'secret set' verb uses.
|
||||
public async Task SeedAsync(string name, string plaintext)
|
||||
{
|
||||
var secretName = new SecretName(name);
|
||||
ISecretCipher cipher = _provider.GetRequiredService<ISecretCipher>();
|
||||
ISecretStore store = _provider.GetRequiredService<ISecretStore>();
|
||||
StoredSecret sealed_ = cipher.Encrypt(secretName, plaintext, SecretContentType.Text);
|
||||
await store.UpsertAsync(sealed_, default);
|
||||
}
|
||||
|
||||
public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
|
||||
.Build();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
Environment.SetEnvironmentVariable(_envVarName, null);
|
||||
|
||||
// The SQLite store runs in WAL mode with connection pooling, so a pooled handle can
|
||||
// outlive _provider.Dispose() and keep the .db (plus its -wal/-shm sidecars) open. Clear
|
||||
// the pool first, then delete all three files so no temp artifact leaks between tests.
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Best-effort cleanup of the temp store; a locked file must not fail the test.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the shared Secrets UI is wired into the dashboard: the
|
||||
/// <c>AddSecretsAuthorization</c> policies are registered so the mounted
|
||||
/// <c>/admin/secrets</c> Blazor page (<c>[Authorize(Policy = "secrets:manage")]</c>) and its
|
||||
/// reveal action resolve, AND that the dashboard's real Administrator principal shape actually
|
||||
/// satisfies them (the claim-type-compatibility risk class that bit HistorianGateway). The
|
||||
/// browser reveal/redirect behaviour is verified separately on the box.
|
||||
/// </summary>
|
||||
public sealed class DashboardSecretsMountTests
|
||||
{
|
||||
private const string ManagePolicy = "secrets:manage";
|
||||
private const string RevealPolicy = "secrets:reveal";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Build registers the shared Secrets authorization policies
|
||||
/// (<c>secrets:manage</c> + <c>secrets:reveal</c>) added additively via
|
||||
/// <c>Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())</c>.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Build_RegistersSecretsAuthorizationPolicies()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
|
||||
IAuthorizationPolicyProvider policyProvider =
|
||||
app.Services.GetRequiredService<IAuthorizationPolicyProvider>();
|
||||
|
||||
AuthorizationPolicy? managePolicy = await policyProvider.GetPolicyAsync(ManagePolicy);
|
||||
AuthorizationPolicy? revealPolicy = await policyProvider.GetPolicyAsync(RevealPolicy);
|
||||
|
||||
Assert.NotNull(managePolicy);
|
||||
Assert.NotNull(revealPolicy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the dashboard's Administrator principal — constructed exactly the way
|
||||
/// <see cref="DashboardAutoLoginAuthenticationHandler.CreatePrincipal(string?)"/> mints it
|
||||
/// (role claim on <c>ZbClaimTypes.Role</c>, carrying <c>Administrator</c>, authenticated) —
|
||||
/// satisfies BOTH secrets policies. This locks claim-type compatibility: if roles were ever
|
||||
/// issued on a divergent claim type, <c>RequireRole("Administrator")</c> would no longer match
|
||||
/// and this test would fail.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AdministratorPrincipal_SatisfiesSecretsPolicies()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IAuthorizationService authorization =
|
||||
app.Services.GetRequiredService<IAuthorizationService>();
|
||||
ClaimsPrincipal administrator = DashboardAutoLoginAuthenticationHandler.CreatePrincipal("multi-role");
|
||||
|
||||
AuthorizationResult manage = await authorization.AuthorizeAsync(administrator, resource: null, ManagePolicy);
|
||||
AuthorizationResult reveal = await authorization.AuthorizeAsync(administrator, resource: null, RevealPolicy);
|
||||
|
||||
Assert.True(manage.Succeeded);
|
||||
Assert.True(reveal.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an anonymous, role-less principal is DENIED both secrets policies, proving the
|
||||
/// policies actually gate on the Administrator role rather than admitting everyone.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AnonymousPrincipal_IsDeniedSecretsPolicies()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IAuthorizationService authorization =
|
||||
app.Services.GetRequiredService<IAuthorizationService>();
|
||||
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
|
||||
|
||||
AuthorizationResult manage = await authorization.AuthorizeAsync(anonymous, resource: null, ManagePolicy);
|
||||
AuthorizationResult reveal = await authorization.AuthorizeAsync(anonymous, resource: null, RevealPolicy);
|
||||
|
||||
Assert.False(manage.Succeeded);
|
||||
Assert.False(reveal.Succeeded);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||
|
||||
@@ -51,5 +52,37 @@ internal static class TestHostEnvironmentInitializer
|
||||
"gateway-selfsigned.pfx");
|
||||
Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", certPath);
|
||||
}
|
||||
|
||||
// Host-building tests must not depend on a seeded secret store. The shipped appsettings.json
|
||||
// sources the LDAP bind password from ${secret:ldap/mxgateway/bind}, which the pre-host
|
||||
// expander in GatewayApplication.CreateBuilder resolves before the host is built; with no
|
||||
// seeded store (as on CI) that resolution fails closed with SecretNotFoundException. Supplying
|
||||
// the bind password as an environment override — the same supported way an operator can — makes
|
||||
// config["MxGateway:Ldap:ServiceAccountPassword"] a plain literal (the env provider outranks the
|
||||
// JSON provider), so the expander sees no ${secret:} prefix and skips it. No store/seed needed.
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MxGateway__Ldap__ServiceAccountPassword")))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MxGateway__Ldap__ServiceAccountPassword", "test-bind-password");
|
||||
}
|
||||
|
||||
// The runtime AddZbSecrets registration still runs SqliteSecretsStoreMigrator.MigrateAsync on
|
||||
// startup, which needs a valid master key and a writable store path. Point the store at a
|
||||
// per-process temp file (not the test working dir) and supply a throwaway base64 32-byte key so
|
||||
// migration succeeds without touching any real/shared secrets store.
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ZB_SECRETS_MASTER_KEY")))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ZB_SECRETS_MASTER_KEY",
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Secrets__SqlitePath")))
|
||||
{
|
||||
string secretsPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"mxgw-tests-{Environment.ProcessId}",
|
||||
"secrets.db");
|
||||
Environment.SetEnvironmentVariable("Secrets__SqlitePath", secretsPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user