Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs
T
Joseph Doherty 5ab26a4233 fix(transport): enforce the documented per-IP bundle-unlock throttle (row 44)
BundleUnlockRateLimiter was registered (ServiceCollectionExtensions), injected
into BundleImporter, unit-tested, documented on
TransportOptions.MaxUnlockAttemptsPerIpPerHour = 10 and pinned in
appsettings.Central.json — but _unlockRateLimiter was never invoked. A
brute-force passphrase attacker against a stolen bundle was bounded only by the
per-bundle 3-strike lockout, which a re-export side-steps (new ContentHash).

LoadAsync now registers every unlock attempt against the limiter. Ordering is
load-bearing: AFTER the per-bundle lockout check (hammering a locked bundle
spends no hourly budget) and BEFORE the decrypt (so a denial burns no per-bundle
strike — otherwise the throttle would be a way to lock a bundle someone is
legitimately importing). Over budget throws the new
BundleUnlockThrottledException, fail-closed with a clear message on both
surfaces; it lives in Commons because the management surface (Commons-only
references) must catch it to return a curated error rather than MapFault's
generic internal-error text.

Client key plumbing:
- Central UI: new scoped CircuitClientAddressProvider snapshots
  Connection.RemoteIpAddress at circuit establishment — the same constraint and
  the same pattern as CookieAuthenticationStateProvider, since HttpContext is
  null by the time a circuit submits a passphrase.
- CLI/management: additive ManagementEnvelope.ClientIp, stamped by
  ManagementEndpoints and threaded through DispatchCommand to the preview/import
  bundle handlers.
- No attributable address => a single SHARED "<unattributed>" bucket, not a
  bypass: unknown clients get a stricter budget, never none. Per-client
  isolation is only real behind a ForwardedHeaders-trusted proxy, the same
  caveat the login throttle carries (Component-Security.md).

Docs: Component-Transport.md + docs/components/Transport.md record the two-level
ordering, the client-key sourcing, the shared-bucket fallback and a
troubleshooting entry for the new error.

Tests: 6 importer tests (cap enforced; denial != passphrase failure, no strike
burned; per-client isolation; shared unattributed bucket; unencrypted bundles
consume no budget; a locked bundle spends no hourly budget) — 4 of the 6 fail
with the limiter call disabled, i.e. against the dead-code state. Plus
ManagementActor tests pinning the ClientIp forwarding and the curated
throttle error.
2026-08-15 06:01:14 -04:00

185 lines
11 KiB
C#

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers all Central UI services including Blazor, auth state, dialogs, audit query, and script analysis.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <returns>The <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddCentralUI(this IServiceCollection services)
{
services.AddRazorComponents()
.AddInteractiveServerComponents();
services.AddHttpContextAccessor();
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
services.AddCascadingAuthenticationState();
// Snapshots the circuit-establishing request's remote IP (HttpContext is only
// valid then), so circuit-lifetime code can still attribute a client address —
// today the Transport import wizard's per-IP unlock throttle. Explicit factory
// because the type carries a second (test-seam) string ctor.
services.AddScoped(sp => new CircuitClientAddressProvider(
sp.GetRequiredService<Microsoft.AspNetCore.Http.IHttpContextAccessor>()));
// Resolves the current user's permitted site set from their SiteId claims
// so Deployment/Monitoring pages can enforce site scoping.
services.AddScoped<SiteScopeService>();
// Centralised dialog service: pages inject IDialogService and a single
// <DialogHost /> in MainLayout renders the active dialog. See
// Components/Shared/IDialogService.cs.
services.AddScoped<IDialogService, DialogService>();
// CentralUI facade over IAuditLogRepository so the
// results grid can be tested with a stubbed query source.
//
// Registered with an explicit factory so the IServiceScopeFactory ctor is
// always chosen — AuditLogQueryService has a second (test-seam) ctor that
// takes IAuditLogRepository directly, and both are constructor-resolvable,
// so default activation would be ambiguous. The scope-factory ctor opens a
// fresh DbContext per query, which is what keeps the page's auto-load from
// racing AuditFilterBar's site enumeration on the shared scoped context.
services.AddScoped<IAuditLogQueryService>(sp => new AuditLogQueryService(
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ICentralHealthAggregator>()));
// Server-side streaming CSV export.
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// CentralUI facade over IKpiHistoryRepository that
// fetches a raw series and reduces it with KpiSeriesBucketer for the trend chart.
//
// Registered with an explicit factory so the IServiceScopeFactory ctor is
// always chosen — KpiHistoryQueryService has a second (test-seam) ctor that
// takes IKpiHistoryRepository directly, and both are constructor-resolvable,
// so default activation would be ambiguous. The scope-factory ctor opens a
// fresh DbContext per query, mirroring AuditLogQueryService so a chart's
// auto-load never races other reads on the shared circuit-scoped context.
services.AddScoped<IKpiHistoryQueryService>(sp => new KpiHistoryQueryService(
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<IOptions<KpiHistoryOptions>>()));
// OPC UA Tag Browser: facade over CommunicationService.BrowseNodeAsync
// that enforces the CentralUI-side Design-role trust boundary and translates
// transport failures into typed BrowseFailure results for the dialog.
services.AddScoped<IBrowseService, BrowseService>();
// Verify Endpoint: facade over CommunicationService.VerifyEndpointAsync
// that enforces the same CentralUI-side Design-role trust boundary as the browse
// service, serializes the in-progress endpoint config, and translates transport
// failures into typed VerifyEndpointResults. Backs the "Verify endpoint" button
// on the OPC UA endpoint editor (read-only connect probe, never trusts certs).
services.AddScoped<IEndpointVerificationService, EndpointVerificationService>();
// OPC UA Cert Management: facade over the three
// CommunicationService cert-trust relay methods. Enforces the CentralUI-side
// role trust boundary (Trust + Remove require Administrator, List requires
// Designer) and translates transport failures into typed CertTrustResults.
// Backs the "Trust certificate" button on the OPC UA endpoint editor and the
// connection-certificates management page (node-wide site PKI store).
services.AddScoped<ICertManagementService, CertManagementService>();
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
// same Design-role guard + typed-failure translation as the browse
// service. Backs the Test Bindings dialog on the Configure Instance
// page (one-shot live read of every bound attribute, grouped by
// connection).
services.AddScoped<IBindingTester, BindingTester>();
// Operator Alarm Summary: read-only page that aggregates the
// current alarms across a site's Enabled instances. The service fans out
// one debug snapshot per instance via IInstanceSnapshotClient — a thin
// facade over CommunicationService.RequestDebugSnapshotAsync (the same
// single-shot Ask the Debug View uses) — and flattens the alarm states.
services.AddScoped<IInstanceSnapshotClient, CommunicationInstanceSnapshotClient>();
// The fan-out itself stays scoped (it holds scoped repositories + the snapshot
// client), but the page resolves it through SharedAlarmSummaryService — a process
// singleton that memoizes the fan-out per site with single-flight, so N circuits
// watching one site cost ONE fan-out per window instead of N (arch-review WP2.4).
services.AddScoped<AlarmSummaryService>();
services.AddSingleton<IAlarmSummaryService, SharedAlarmSummaryService>();
// Process-level memoized point-in-time KPI snapshots (arch-review WP2.4, finding
// #8's per-circuit half). Singleton on purpose: the memo has to outlive any one
// circuit for N dashboards to share a single SQL round per refresh window.
services.AddSingleton<IKpiSnapshotCache, KpiSnapshotCache>();
// Secured Writes: dispatches the two-person secured-write commands
// (submit / approve / reject / list) to the central ManagementActor through the
// in-process ManagementActorHolder seam — the same Ask path the HTTP /management
// endpoint uses. The server stays the single enforcer of role gating,
// separation-of-duties (no self-approval), the MxGateway device relay on approve,
// and the append-only audit trail; the page only SUBMITS commands.
services.AddScoped<ISecuredWriteService, SecuredWriteService>();
// Connection live-status: projects the per-site health reports'
// name-keyed connection statuses onto a connection-id → ConnectionHealth map
// so the design DataConnections page can render a live badge per connection
// node. Reuses the existing site→central health transport via
// ICentralHealthAggregator — no new plumbing.
services.AddScoped<IConnectionHealthQueryService, ConnectionHealthQueryService>();
// Move-data-connection: dispatches MoveDataConnectionCommand to the
// central ManagementActor through the in-process ManagementActorHolder seam —
// the same Ask path the HTTP /management endpoint uses. The server stays the
// single enforcer of the Designer gate and every move guard (target exists, no
// name collision, no instance binding, no native-alarm-source name reference);
// the move dialog only SUBMITS the command and renders the returned outcome.
services.AddScoped<IDataConnectionMoveService, DataConnectionMoveService>();
// Schema Library: authoring + read accessors for the reusable named
// JSON-Schema library (SharedSchema + ISharedSchemaRepository).
//
// ISchemaLibraryService dispatches the CRUD commands to the central
// ManagementActor through the in-process ManagementActorHolder seam — the same
// Ask path the HTTP /management endpoint uses — so the server stays the single
// enforcer of the Designer gate, the unique-name guard, and JSON-Schema
// validation; the page only SUBMITS commands.
services.AddScoped<ISchemaLibraryService, SchemaLibraryService>();
// ISchemaLibraryQueryService is the READ counterpart that the schema-driven
// value-entry forms reuse to resolve {"$ref":"lib:Name"} pointers — a name →
// schema-JSON map backed by ISharedSchemaRepository over a fresh DI scope per
// query (mirroring the AuditLog / KPI query services, off the circuit-scoped
// DbContext). Read-only; no mutation goes through it.
services.AddScoped<ISchemaLibraryQueryService, SchemaLibraryQueryService>();
// Template inheritance preview: a read-only facade that dispatches
// GetResolvedTemplateMembersCommand to the central ManagementActor through the
// in-process ManagementActorHolder seam (same Ask path as the HTTP /management
// endpoint). Powers the template editor's FULL transitively-inherited member set
// + base-changed staleness banner; never mutates rows, not on the deploy path.
services.AddScoped<ITemplateInheritanceQueryService, TemplateInheritanceQueryService>();
// SMS Notifications: the NotificationListForm Type selector offers only the
// notification channels that actually have a registered delivery adapter. The
// catalog projects the registered INotificationDeliveryAdapter set (Email + SMS,
// registered scoped by AddNotificationOutbox into this same container) to its
// distinct NotificationType set — no hardcoded {Email, Sms}. Scoped because the
// adapters it enumerates are scoped (they hold a scoped INotificationRepository).
services.AddScoped<INotificationChannelCatalog, NotificationChannelCatalog>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);
services.AddScoped<ISharedScriptCatalog, SharedScriptCatalog>();
services.AddScoped<ScriptAnalysisService>();
return services;
}
}