Compare commits

...

7 Commits

Author SHA1 Message Date
Joseph Doherty e088dfabf7 fix(secrets): honor env-supplied LDAP bind password in live LDAP integration suite; clarify pre-host migration
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m12s
ci / java (push) Successful in 2m0s
ci / portable (push) Successful in 6m51s
2026-07-16 11:24:23 -04:00
Joseph Doherty 2e7245dd55 docs(secrets): G-5 master-key operator note (Environment provider via NSSM env) 2026-07-16 11:13:16 -04:00
Joseph Doherty 22a878c36e feat(secrets): mount /admin/secrets + AddSecretsAuthorization in the dashboard (G-6) 2026-07-16 11:10:13 -04:00
Joseph Doherty b79e119ada feat(secrets): source LDAP bind password via ${secret:ldap/mxgateway/bind}; drop plaintext defaults (G-4) 2026-07-16 10:56:20 -04:00
Joseph Doherty a2538039c0 feat(secrets): register runtime AddZbSecrets on the gateway host 2026-07-16 10:35:35 -04:00
Joseph Doherty 1533cd3909 feat(secrets): pre-host ${secret:} config expansion in GatewayApplication.CreateBuilder (G-4 mechanism) 2026-07-16 10:33:40 -04:00
Joseph Doherty 9552b79481 build(secrets): add ZB.MOM.WW.Secrets 0.1.2 package refs + nuget source mapping 2026-07-16 10:19:06 -04:00
16 changed files with 496 additions and 13 deletions
+83 -1
View File
@@ -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 |
+9
View File
@@ -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
```
+2
View File
@@ -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&lt;AuthorizationOptions&gt;(o =&gt; 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);
}
}
}