677263af57
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).
142 lines
8.7 KiB
C#
142 lines
8.7 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>
|
|
/// Registers the ScadaBridgeDbContext with the provided SQL Server connection string.
|
|
/// </summary>
|
|
/// <param name="services">The service collection to register into.</param>
|
|
/// <param name="connectionString">SQL Server connection string for the central configuration database.</param>
|
|
/// <returns>The same <paramref name="services"/> collection, for chaining.</returns>
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
|
{
|
|
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
|
|
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
|
|
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
|
|
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
|
|
// CreateProtector during model building.
|
|
//
|
|
// POOLING IS DELIBERATELY NOT USED (WP2.2 — verified, not overlooked).
|
|
// AddDbContextPool requires a context with a SINGLE public constructor taking
|
|
// only DbContextOptions<TContext>; EF Core constructs pooled instances through
|
|
// its own activator and cannot supply anything else. ScadaBridgeDbContext has
|
|
// two public constructors and the runtime one takes IDataProtectionProvider,
|
|
// because the encrypting value converter for secret-bearing columns is built
|
|
// during OnModelCreating from that provider. Worse, the model itself DIFFERS
|
|
// between the two constructors (no provider ⇒ no encrypting converter), so a
|
|
// pooled activator would silently produce a context that reads secret columns
|
|
// as ciphertext. Making this poolable means moving the protector out of the
|
|
// constructor and into a DbContextOptions extension — a change to the
|
|
// secrets-at-rest path, which is not a performance refactor. The registration
|
|
// below (a scoped factory overriding AddDbContext's activator) is what makes
|
|
// the provider reach the context at all, and it also bypasses pooling by
|
|
// construction. Revisit only alongside a deliberate secrets-plumbing change.
|
|
services.AddDbContext<ScadaBridgeDbContext>((serviceProvider, options) =>
|
|
{
|
|
options.UseSqlServer(
|
|
connectionString,
|
|
sql => sql.EnableRetryOnFailure(
|
|
maxRetryCount: 5,
|
|
maxRetryDelay: TimeSpan.FromSeconds(30),
|
|
errorNumbersToAdd: null))
|
|
.ConfigureWarnings(w => w.Ignore(
|
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
|
|
});
|
|
|
|
// AddDbContext registers ScadaBridgeDbContext via EF's activator, which only injects
|
|
// DbContextOptions. Override that registration (last registration wins for resolution)
|
|
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
|
|
// value converter for secret columns is always wired up at runtime.
|
|
services.AddScoped(serviceProvider =>
|
|
{
|
|
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>();
|
|
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
|
|
return new ScadaBridgeDbContext(options, protectionProvider);
|
|
});
|
|
|
|
// Process-wide template/instance version watermark. Singleton because the
|
|
// writer (TemplateEngineRepository.SaveChangesAsync, scoped) and the readers
|
|
// (the flatten-session cache and the staleness fast path, also scoped) must
|
|
// observe the same counters across DI scopes. TryAdd so the DeploymentManager
|
|
// registration of the same interface is idempotent.
|
|
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
|
|
|
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
|
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
|
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
|
|
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
|
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
|
|
// Register row 52: the fleet-wide bundle-unlock throttle window. Central-only in
|
|
// practice (Transport is registered in the host's Central branch), but registered
|
|
// here with every other repository so the ConfigurationDatabase surface stays uniform.
|
|
services.AddScoped<IBundleUnlockAttemptRepository, BundleUnlockAttemptRepository>();
|
|
// Auth re-arch: inbound API keys are no longer persisted in SQL Server —
|
|
// the repository now exposes only API-method access, so a plain scoped
|
|
// registration suffices (no peppered-hasher accessor to wire).
|
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
|
|
|
// IPartitionMaintenance drives the daily roll-forward
|
|
// of pf_AuditLog_Month from the central AuditLogPartitionMaintenanceService
|
|
// hosted service. Scoped because the implementation reuses the per-scope
|
|
// ScadaBridgeDbContext for raw-SQL execution; the hosted service opens a
|
|
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
|
|
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
|
|
|
|
services.AddDataProtection()
|
|
.PersistKeysToDbContext<ScadaBridgeDbContext>();
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Obsolete parameterless overload. This previously registered nothing, which meant a
|
|
/// central node wired up with it failed late and opaquely — the first repository
|
|
/// resolution threw a DI exception far from the actual misconfiguration. Use
|
|
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
|
|
/// configured connection string.
|
|
/// </summary>
|
|
/// <param name="services">The service collection (unused; this overload always throws).</param>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Always thrown. The connection string is required; there is no valid no-op registration.
|
|
/// </exception>
|
|
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
|
|
[Obsolete(
|
|
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
|
|
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
|
|
error: true)]
|
|
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
|
{
|
|
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
|
|
// fail fast at wire-up time rather than silently registering nothing and surfacing
|
|
// an opaque DI resolution failure much later.
|
|
throw new InvalidOperationException(
|
|
"AddConfigurationDatabase() requires a connection string. Call " +
|
|
"AddConfigurationDatabase(connectionString) with the configured " +
|
|
"'ScadaBridge:Database:ConfigurationDb' value.");
|
|
}
|
|
}
|