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:
@@ -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><unattributed></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; }
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Register row 52: read/write access to the central <c>BundleUnlockAttempt</c> table,
|
||||
/// the fleet-wide sliding window behind the Transport component's per-client bundle-unlock
|
||||
/// throttle. Both central nodes share this store, so the documented
|
||||
/// <c>MaxUnlockAttemptsPerIpPerHour</c> cap is a fleet cap rather than a per-process one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The throttle is a best-effort rate cap layered over an already-authenticated,
|
||||
/// role-gated surface. Callers are expected to treat a store failure as fail-open (the
|
||||
/// availability of legitimate imports outranks the cap), so no method here is required to
|
||||
/// be transactional with respect to the caller's own work.
|
||||
/// </remarks>
|
||||
public interface IBundleUnlockAttemptRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Counts the attempts recorded for <paramref name="clientKey"/> at or after
|
||||
/// <paramref name="sinceUtc"/> — the trailing-window depth the cap is compared against.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">Throttle bucket (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="sinceUtc">Inclusive UTC lower bound of the trailing window.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The number of attempts inside the window.</returns>
|
||||
Task<int> CountAttemptsSinceAsync(string clientKey, DateTime sinceUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends one attempt row for <paramref name="clientKey"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">Throttle bucket (client IP, or the shared unattributed bucket).</param>
|
||||
/// <param name="attemptedAtUtc">UTC instant of the attempt.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task that completes when the row is committed.</returns>
|
||||
Task RecordAttemptAsync(string clientKey, DateTime attemptedAtUtc, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes attempt rows older than <paramref name="beforeUtc"/>, up to
|
||||
/// <paramref name="maxRows"/> in one bounded pass. Bounded so an opportunistic prune on
|
||||
/// a request path can never turn into an unbounded delete.
|
||||
/// </summary>
|
||||
/// <param name="beforeUtc">Exclusive UTC upper bound — rows strictly older than this are eligible.</param>
|
||||
/// <param name="maxRows">Maximum rows to delete in this pass; must be positive.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The number of rows deleted.</returns>
|
||||
Task<int> PruneAttemptsOlderThanAsync(DateTime beforeUtc, int maxRows, CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user