Merge branch 'fix/sec-33-34'
ci / java (push) Successful in 2m16s
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / portable (push) Successful in 8m32s

This commit is contained in:
Joseph Doherty
2026-08-07 06:51:31 -04:00
17 changed files with 576 additions and 120 deletions
@@ -0,0 +1,41 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.GalaxyRepository;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Gateway-side startup validation for the shared <see cref="GalaxyRepositoryOptions"/>. The
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
/// non-rooted value silently resolves against the launch working directory.
/// </summary>
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
{
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, GalaxyRepositoryOptions options)
{
if (!options.PersistSnapshot)
{
// Persistence disabled: the snapshot path is never used, so nothing to validate.
return;
}
if (string.IsNullOrWhiteSpace(options.SnapshotCachePath))
{
builder.Add(
"MxGateway:Galaxy:SnapshotCachePath is required when MxGateway:Galaxy:PersistSnapshot is true.");
return;
}
GatewayConfigPathRules.AddIfInvalidPath(
options.SnapshotCachePath,
"MxGateway:Galaxy:SnapshotCachePath must be a valid filesystem path.",
builder);
GatewayConfigPathRules.AddIfNotRooted(
options.SnapshotCachePath,
"MxGateway:Galaxy:SnapshotCachePath must be an absolute (rooted) path so the Galaxy snapshot never lands in the launch working directory.",
builder);
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Shared filesystem-path validation primitives used by more than one options validator
/// (<see cref="GatewayOptionsValidator"/> and <see cref="GalaxyRepositoryOptionsValidator"/>).
/// Both the auth credential store and the Galaxy snapshot are written by the running gateway
/// process, so both must reject paths the host cannot use — the rules live here once so the two
/// validators cannot drift.
/// </summary>
internal static class GatewayConfigPathRules
{
/// <summary>
/// Fails validation when <paramref name="value"/> is not an absolute (rooted) path <em>on the
/// host running the validator</em>. Security-sensitive paths (the auth DB, the self-signed
/// private key, the Galaxy snapshot) must be absolute: a non-rooted value silently resolves
/// against the launch working directory, so the store moves with the CWD and can leak into the
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
/// </summary>
/// <param name="value">The configured path value.</param>
/// <param name="message">The failure message to record when the value is not rooted.</param>
/// <param name="builder">The validation builder accumulating failures.</param>
public static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!Path.IsPathRooted(value))
{
builder.Add(message);
}
}
/// <summary>
/// Fails validation when <paramref name="value"/> is non-blank but not a syntactically valid
/// filesystem path (as judged by <see cref="Path.GetFullPath(string)"/>). Blank values are the
/// caller's required-field concern and pass here.
/// </summary>
/// <param name="value">The configured path value.</param>
/// <param name="message">The failure message to record when the value is not a valid path.</param>
/// <param name="builder">The validation builder accumulating failures.</param>
public static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
try
{
_ = Path.GetFullPath(value);
}
catch (ArgumentException)
{
builder.Add(message);
}
catch (NotSupportedException)
{
builder.Add(message);
}
catch (PathTooLongException)
{
builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
}
}
}
@@ -292,10 +292,23 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// knob, not incorrect behavior.
}
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
// upper-bound pattern.
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
{
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
if (options.QueueCapacity > MaximumEventQueueCapacity)
{
builder.Add(
$"MxGateway:Events:QueueCapacity must be less than or equal to {MaximumEventQueueCapacity} "
+ "so the derived worker event-staging channel (2 x QueueCapacity) cannot overflow.");
}
if (!Enum.IsDefined(options.BackpressurePolicy))
{
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
@@ -524,79 +537,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
builder.RequireThat(value >= 0, message);
}
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
{
// Security-sensitive paths (the auth DB, the self-signed private key) must be absolute:
// a non-rooted value silently resolves against the launch working directory, so the store
// moves with the CWD and can leak into the source tree. Reject rather than auto-root —
// silent relocation of a credential store is worse than a boot error. Blank is handled by
// AddIfBlank; an empty value is not treated as non-rooted here.
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!IsRootedForAnyPlatform(value))
{
builder.Add(message);
}
}
/// <summary>
/// Determines whether <paramref name="value"/> is an absolute path for <em>any</em> platform,
/// not just the host running the validator. This matters on the macOS dev box, where the
/// production <c>appsettings.json</c> ships Windows-absolute paths (<c>C:\ProgramData\...</c>)
/// that <see cref="Path.IsPathRooted(string)"/> reports as non-rooted on Unix. The intent of the
/// rooting check is to reject bare filenames that resolve against the launch working directory,
/// so a valid Windows drive-qualified or UNC path must pass regardless of the current OS.
/// </summary>
private static bool IsRootedForAnyPlatform(string value)
{
// Rooted on the current OS (Unix "/...", or a Windows drive/UNC path when on Windows).
if (Path.IsPathRooted(value))
{
return true;
}
// Windows drive-qualified path ("C:\..." or "C:/...") checked on a non-Windows host.
if (value.Length >= 3
&& char.IsLetter(value[0])
&& value[1] == ':'
&& (value[2] == '\\' || value[2] == '/'))
{
return true;
}
// Windows UNC path ("\\server\share") checked on a non-Windows host.
return value.StartsWith(@"\\", StringComparison.Ordinal);
}
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
try
{
_ = Path.GetFullPath(value);
}
catch (ArgumentException)
{
builder.Add(message);
}
catch (NotSupportedException)
{
builder.Add(message);
}
catch (PathTooLongException)
{
builder.Add(message);
}
catch (IOException)
{
builder.Add(message);
}
}
=> GatewayConfigPathRules.AddIfInvalidPath(value, message, builder);
}
@@ -134,8 +134,29 @@ public static class GatewayApplication
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
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;
}
@@ -39,15 +39,30 @@ public interface IApiKeyCacheInvalidator
/// on every cache miss.
/// </para>
/// <para>
/// Correctness on mutation is provided by two mechanisms: gateway-initiated revoke/rotate/delete
/// call <see cref="Invalidate"/> directly (see <c>DashboardApiKeyManagementService</c>), and the
/// short TTL is the backstop for out-of-band mutations (a direct DB edit, or a revoke issued by the
/// separate <c>apikey</c> CLI process, whose in-memory cache is not this process's cache).
/// Gateway-initiated revoke/rotate/delete call <see cref="Invalidate"/> directly (see
/// <c>DashboardApiKeyManagementService</c>), which bumps a per-key generation counter <em>before</em>
/// evicting so an in-flight verification that started under the old generation discards its own
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
/// take effect immediately" contract true even against a verify that was already in the inner library
/// when the revoke landed (SEC-34 window 3).
/// </para>
/// <para>
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
/// mutations — a direct DB edit, or a revoke issued by the separate <c>apikey</c> CLI process whose
/// in-memory cache is not this process's cache — take effect only after the TTL elapses. (2) A key
/// whose expiry passes while cached keeps authenticating for up to the TTL: expiry is enforced by the
/// inner library verifier, which a cache hit never reaches, and the verification identity the library
/// returns (<c>ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity</c>) carries no expiry timestamp, so
/// the cache cannot cap an entry at the key's <c>ExpiresUtc</c>. Capping it requires the donor library
/// to surface expiry on the verification identity (donor-library ask); until then the TTL bounds this
/// window and it is intentionally kept short (<see cref="SecurityOptions.ApiKeyVerificationCacheSeconds"/>,
/// default 15 s).
/// </para>
/// </remarks>
public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalidator
{
private const string CacheKeyPrefix = "mxgw:apikeyverif:";
private const string TokenPrefix = "mxgw";
private readonly IApiKeyVerifier _inner;
private readonly IMemoryCache _cache;
@@ -58,6 +73,11 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _keyIdIndex =
new(StringComparer.Ordinal);
// keyId -> monotonic generation, bumped by Invalidate before it evicts. A VerifyAsync snapshots
// the generation before calling the inner verifier and only caches if it is unchanged after the
// inner call and again after the Set — closing the revoke-vs-in-flight-repopulation race.
private readonly ConcurrentDictionary<string, long> _generations = new(StringComparer.Ordinal);
/// <summary>Initializes a new instance of the <see cref="CachingApiKeyVerifier"/> class.</summary>
/// <param name="inner">The wrapped verifier (the library verifier) reached on a cache miss.</param>
/// <param name="cache">The shared memory cache.</param>
@@ -105,20 +125,58 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
return cached;
}
// Snapshot the key's generation BEFORE the inner verify so a concurrent Invalidate that runs
// while we are in the library is detected below and its repopulation discarded.
string? tokenKeyId = TryParseKeyId(authorizationHeader);
long generationAtStart = tokenKeyId is null ? 0 : ReadGeneration(tokenKeyId);
ApiKeyVerification result = await _inner.VerifyAsync(authorizationHeader, ct).ConfigureAwait(false);
if (result.Succeeded && result.Identity is not null)
{
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _ttl,
});
IndexCacheKey(result.Identity.KeyId, cacheKey);
TryCacheSuccess(cacheKey, result, tokenKeyId, generationAtStart);
}
return result;
}
// Caches a successful verification unless the key was invalidated while the inner verify was in
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
private void TryCacheSuccess(
string cacheKey,
ApiKeyVerification result,
string? tokenKeyId,
long generationAtStart)
{
// If Invalidate bumped the generation while we were verifying, the identity we hold may be
// stale — do not cache it.
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
{
return;
}
string keyId = result.Identity!.KeyId;
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _ttl,
});
IndexCacheKey(keyId, cacheKey);
// Set-then-recheck: an Invalidate that landed between the pre-Set check and IndexCacheKey
// would have missed this entry (it was not yet indexed). If the generation has moved, evict
// the just-written entry so the revoke still takes effect immediately.
if (tokenKeyId is not null && ReadGeneration(tokenKeyId) != generationAtStart)
{
_cache.Remove(cacheKey);
if (_keyIdIndex.TryGetValue(keyId, out ConcurrentDictionary<string, byte>? set))
{
set.TryRemove(cacheKey, out _);
}
}
}
/// <inheritdoc />
public void Invalidate(string keyId)
{
@@ -127,6 +185,10 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
return;
}
// Bump the generation BEFORE evicting so an in-flight VerifyAsync that snapshotted the old
// generation refuses to cache (or self-evicts) its now-stale repopulation.
_generations.AddOrUpdate(keyId, 1, static (_, current) => current + 1);
if (_keyIdIndex.TryRemove(keyId, out ConcurrentDictionary<string, byte>? cacheKeys))
{
foreach (string cacheKey in cacheKeys.Keys)
@@ -136,6 +198,45 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
}
}
private long ReadGeneration(string keyId) => _generations.TryGetValue(keyId, out long generation)
? generation
: 0;
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
// the same split the authorization interceptor does. Returns null for a header this cache cannot
// attribute to a key id (in which case the generation race-guard is simply not applied).
//
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_'
// split would truncate a key id that itself contained '_', silently disarming the guard for that
// key. This is safe because '_' is the token's field delimiter and both — and the only — key
// creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and
// DashboardApiKeyManagementService.ValidateKeyId each restrict a key id to
// char.IsAsciiLetterOrDigit || '.' || '-'. Key ids are never library-generated, so no path can
// mint one containing '_'.
private static string? TryParseKeyId(string? authorizationHeader)
{
if (string.IsNullOrEmpty(authorizationHeader))
{
return null;
}
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
const string bearer = "Bearer ";
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
? header[bearer.Length..].Trim()
: header;
string[] parts = token.ToString().Split('_');
if (parts.Length < 3
|| !string.Equals(parts[0], TokenPrefix, StringComparison.Ordinal)
|| parts[1].Length == 0)
{
return null;
}
return parts[1];
}
private void IndexCacheKey(string keyId, string cacheKey)
{
ConcurrentDictionary<string, byte> set = _keyIdIndex.GetOrAdd(
@@ -20,7 +20,6 @@
"MxGateway": {
"Authentication": {
"Mode": "ApiKey",
"SqlitePath": "C:\\ProgramData\\MxGateway\\gateway-auth.db",
"PepperSecretName": "MxGateway:ApiKeyPepper",
"RunMigrationsOnStartup": true
},
@@ -82,8 +81,7 @@
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
"CommandTimeoutSeconds": 60,
"DashboardRefreshIntervalSeconds": 30,
"PersistSnapshot": true,
"SnapshotCachePath": "C:\\ProgramData\\MxGateway\\galaxy-snapshot.json"
"PersistSnapshot": true
},
"Alarms": {
"Enabled": true,
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary>
/// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/> (SEC-33): the Galaxy
/// package binds the options but ships no validator, so the gateway rejects a persisted snapshot
/// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS
/// default when the shipped config leaves the path blank.
/// </summary>
public sealed class GalaxyRepositoryOptionsValidatorTests
{
/// <summary>Verifies a blank snapshot path with persistence enabled fails validation.</summary>
[Fact]
public void Validate_Fails_WhenPersistSnapshotAndPathBlank()
{
GalaxyRepositoryOptions options = new() { PersistSnapshot = true, SnapshotCachePath = "" };
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Galaxy:SnapshotCachePath"));
}
/// <summary>Verifies a non-rooted snapshot path with persistence enabled fails validation.</summary>
[Fact]
public void Validate_Fails_WhenPersistSnapshotAndPathNotRooted()
{
GalaxyRepositoryOptions options = new()
{
PersistSnapshot = true,
SnapshotCachePath = "galaxy-snapshot.json",
};
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Galaxy:SnapshotCachePath") && f.Contains("rooted"));
}
/// <summary>Verifies a blank path passes when persistence is disabled (the path is never used).</summary>
[Fact]
public void Validate_Succeeds_WhenPersistSnapshotDisabled()
{
GalaxyRepositoryOptions options = new() { PersistSnapshot = false, SnapshotCachePath = "" };
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a valid, host-rooted snapshot path with persistence enabled passes.</summary>
[Fact]
public void Validate_Succeeds_WhenPersistSnapshotAndPathRooted()
{
GalaxyRepositoryOptions options = new()
{
PersistSnapshot = true,
SnapshotCachePath = Path.Combine(Path.GetTempPath(), "galaxy-snapshot.json"),
};
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves
/// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation
/// passes on any host (SEC-33). Resolving the options triggers the registered validator.
/// </summary>
[Fact]
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
{
using WebApplication app = GatewayApplication.Build([]);
GalaxyRepositoryOptions options =
app.Services.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value;
Assert.False(string.IsNullOrWhiteSpace(options.SnapshotCachePath));
Assert.True(Path.IsPathRooted(options.SnapshotCachePath));
}
}
@@ -598,6 +598,61 @@ public sealed class GatewayOptionsValidatorTests
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
}
/// <summary>
/// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a
/// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
/// relative file. On Windows the same literal is genuinely rooted and passes.
/// </summary>
[Fact]
public void Validate_SqlitePath_RootingIsHostMeaningful()
{
const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db";
GatewayOptions options = CloneWithAuthentication(
ValidOptions(),
new AuthenticationOptions { SqlitePath = windowsLiteral });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
if (OperatingSystem.IsWindows())
{
Assert.True(result.Succeeded);
}
else
{
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
}
}
/// <summary>
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
/// </summary>
[Fact]
public void Validate_Fails_WhenQueueCapacityExceedsUpperBound()
{
GatewayOptions options = CloneWithEvents(
ValidOptions(),
new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Events:QueueCapacity"));
}
/// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
[Fact]
public void Validate_Succeeds_WhenQueueCapacityAtUpperBound()
{
GatewayOptions options = CloneWithEvents(
ValidOptions(),
new EventOptions { QueueCapacity = int.MaxValue / 2 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
@@ -80,7 +80,8 @@ public sealed class GatewayApplicationTests
public async Task Build_MapsMetricsEndpoint()
{
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// started-host test must avoid a fixed port to prevent a bind collision.
// started-host test must avoid a fixed port to prevent a bind collision. The auth SQLite
// store path is isolated to a per-process temp file by TestHostEnvironmentInitializer (SEC-33).
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
await app.StartAsync();
try
@@ -258,7 +259,9 @@ public sealed class GatewayApplicationTests
string expectedFailure)
{
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
// WebApplication-building test must avoid a fixed port to prevent a bind collision. The auth
// store path is isolated by TestHostEnvironmentInitializer (SEC-33), so startup opens a
// writable store and the injected misconfiguration is what fails validation.
await using WebApplication app = GatewayApplication.Build(
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
@@ -97,6 +97,36 @@ public sealed class CachingApiKeyVerifierTests
Assert.Equal(2, inner.CallCount);
}
/// <summary>
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a
/// verification is in flight in the inner library must discard that verification's repopulation,
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation()
{
GatedVerifier inner = new(Success("operator01"));
using MemoryCache cache = NewCache();
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
// Begin a verification and wait until it is parked inside the inner verifier.
Task<ApiKeyVerification> inFlight = verifier.VerifyAsync(Header, CancellationToken.None);
await inner.Entered;
// Revoke while the verify is still awaiting the inner library.
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
// Release the inner verifier; the in-flight call completes but must NOT cache its result.
inner.Release();
ApiKeyVerification result = await inFlight;
Assert.True(result.Succeeded);
// The follow-up request finds no cached entry and reaches the inner verifier again.
await verifier.VerifyAsync(Header, CancellationToken.None);
Assert.Equal(2, inner.CallCount);
}
/// <summary>
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
@@ -196,6 +226,43 @@ public sealed class CachingApiKeyVerifierTests
}
}
// A verifier that parks inside VerifyAsync until Release() is called, so a test can interleave an
// Invalidate with an in-flight verification.
private sealed class GatedVerifier(ApiKeyVerification result) : IApiKeyVerifier
{
private readonly TaskCompletionSource _entered =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _release =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
public int CallCount { get; private set; }
/// <summary>Completes once the first call has entered and parked in the inner verifier.</summary>
public Task Entered => _entered.Task;
/// <summary>Unparks the first (gated) call.</summary>
public void Release() => _release.TrySetResult();
/// <summary>Records the call; the first call parks on the gate, later calls return immediately.</summary>
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
/// <param name="ct">A token to observe for cancellation.</param>
/// <returns>The fixed verification result.</returns>
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
{
bool first = CallCount == 0;
CallCount++;
if (first)
{
_entered.TrySetResult();
await _release.Task.ConfigureAwait(false);
}
return result;
}
}
private sealed class FakeStore : IApiKeyStore
{
/// <summary>Gets the number of times <see cref="MarkUsedAsync"/> has been called.</summary>
@@ -84,5 +84,19 @@ internal static class TestHostEnvironmentInitializer
"secrets.db");
Environment.SetEnvironmentVariable("Secrets__SqlitePath", secretsPath);
}
// Starting the full host eagerly opens the auth SQLite store. Since SEC-33 the shipped
// appsettings.json no longer carries an Authentication:SqlitePath, and the CommonApplicationData
// code default resolves under an unwritable /usr/share on macOS. Point every host-building test at
// a per-process temp store (same pattern as Secrets__SqlitePath above) so host-start tests are
// auto-covered without a per-test override; a test that needs its own store still overrides this.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MxGateway__Authentication__SqlitePath")))
{
string authPath = Path.Combine(
Path.GetTempPath(),
$"mxgw-tests-{Environment.ProcessId}",
"gateway-auth.db");
Environment.SetEnvironmentVariable("MxGateway__Authentication__SqlitePath", authPath);
}
}
}