feat(transport): fleet-wide bundle-unlock throttle window (register row 52)

The per-IP unlock throttle's counters were a process-local ConcurrentDictionary
on each central node, so the effective fleet cap was N x
MaxUnlockAttemptsPerIpPerHour per client (failing over to the other node handed
the client a fresh budget) and a node restart cleared the window outright.

The window now lives in the central MS SQL BundleUnlockAttempt table — the HA
store both central nodes already share, so no new infrastructure, no new port,
no consensus protocol:

- Commons: BundleUnlockAttempt entity + IBundleUnlockAttemptRepository.
- ConfigurationDatabase: entity config (one index,
  IX_BundleUnlockAttempt_Client_Attempted, serving both the windowed count and
  the prune), repository (AsNoTracking count, ExecuteDeleteAsync prune),
  DbSet, DI registration, migration AddBundleUnlockAttemptTable + the
  hand-written idempotent production script under docs/plans/sql/.
- Transport: IBundleUnlockAttemptWindow seam + SqlBundleUnlockAttemptWindow
  (singleton; resolves the scoped repository from its own scope per call, like
  the central purge singletons). BundleUnlockRateLimiter keeps the policy and
  gains TryRegisterAttemptAsync; BundleImporter.LoadAsync calls it.

Behaviour preserved from row 44: locked bundles spend no hourly budget (the
lockout check still runs first), throttle denials burn no per-bundle strike and
are NOT recorded so they cannot roll the window forward, unattributable clients
share the stricter <unattributed> bucket, and the budget counts attempts rather
than failures. Config keys and their semantics are unchanged, so the rig's 500
override stays valid.

Three deliberate trades, documented in both Transport docs:
- FAILS OPEN. A store error logs a Warning naming the client and cap, and
  permits the attempt. This is a best-effort rate cap over an already
  authenticated, Administrator-gated surface — the availability of legitimate
  imports outranks the cap, and a DB blip must not make the import wizard
  unusable. Falling back to the process-local window was considered and
  rejected: it would still block a legitimate operator's 11th import.
- Approximate. Count-then-insert is not atomic across nodes, so simultaneous
  attempts can overshoot by the number in flight — bounded, self-correcting
  within the window, and not worth provider-specific raw SQL.
- Pruned opportunistically on the unlock path (CAS-claimed at most every 15
  min per process, bounded to 1000 rows, retaining 2x the window, best-effort
  try/catch) rather than by a new background service — Transport owns no
  central scheduler. Same shape as DeploymentService's terminal-record sweep.

Tests: Transport.IntegrationTests 116 passed (+10 — two limiter instances over
one store enforcing a single combined window, restart durability, denials
burning no budget, sliding release, bucket isolation, store-failure fail-open,
caller-cancellation not swallowed, no-shared-window fallback, prune and its
negative control); ConfigurationDatabase 395 passed (+5).
This commit is contained in:
Joseph Doherty
2026-08-15 11:52:34 -04:00
parent 372ea4df1b
commit 677263af57
21 changed files with 3157 additions and 30 deletions
@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
/// <summary>
/// One recorded bundle-unlock passphrase attempt, written to the central
/// <c>BundleUnlockAttempt</c> table. Rows form the trailing-hour sliding window the
/// Transport component's per-client unlock throttle
/// (<c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>) is evaluated against.
/// </summary>
/// <remarks>
/// <para>
/// Register row 52: the throttle's counters used to be per-node and per-process, so the
/// effective fleet cap was N x limit per client and a node restart cleared the window.
/// Central MS SQL is the store both central nodes already share, so putting the window
/// there makes the cap fleet-wide and restart-durable without new infrastructure.
/// </para>
/// <para>
/// Operational history, NOT audit — the table is non-partitioned, lives on the standard
/// <c>[PRIMARY]</c> filegroup and carries no DB-role restriction. Rows are pruned
/// opportunistically once they age past the window; they are not a durable record of
/// who tried to open which bundle (that is the <c>BundleImportUnlockFailed</c> audit row's
/// job, and note this table deliberately records no bundle identity at all).
/// </para>
/// <para>
/// Persistence-ignorant POCO; the EF Core mapping lives in the Configuration Database
/// component. <see cref="AttemptedAtUtc"/> is UTC, like every timestamp in the system.
/// </para>
/// </remarks>
public sealed class BundleUnlockAttempt
{
/// <summary>Surrogate identity key assigned by the store.</summary>
public long Id { get; set; }
/// <summary>
/// The throttle bucket the attempt was counted against — the caller's remote IP, or
/// the shared <c>&lt;unattributed&gt;</c> bucket when no address could be attributed.
/// Sized for an IPv6 literal with a scope id.
/// </summary>
public required string ClientKey { get; set; }
/// <summary>The UTC instant at which the unlock attempt was registered.</summary>
public DateTime AttemptedAtUtc { get; set; }
}