using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.AuditLog.Central; using ScadaLink.AuditLog.Configuration; using ScadaLink.AuditLog.Payload; using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site.Telemetry; using ScadaLink.Commons.Interfaces.Services; namespace ScadaLink.AuditLog; /// /// Composition root for the Audit Log (#23) component. /// /// /// /// M1 registered + the validator. M2 Bundle E /// extends the surface with the site-side writer chain /// ( + + /// ) and the telemetry collaborators /// (, , /// , , /// ). /// /// /// Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call /// Audit (#22). IAuditLogRepository is registered by /// ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase, /// so the caller (the Host on the central node) must also call that. /// /// public static class ServiceCollectionExtensions { /// Configuration section bound to . public const string ConfigSectionName = "AuditLog"; /// Configuration section bound to . public const string SiteWriterSectionName = "AuditLog:SiteWriter"; /// Configuration section bound to . public const string SiteTelemetrySectionName = "AuditLog:SiteTelemetry"; /// Configuration section bound to . public const string PartitionMaintenanceSectionName = "AuditLog:PartitionMaintenance"; /// /// Registers the Audit Log (#23) component services: options, the site /// SQLite writer chain (primary + ring fallback + failure-counter sink), /// and the site-→central telemetry collaborators. Idempotent re-registration /// is not supported; call this exactly once per . /// public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); // M1: top-level AuditLogOptions + validator (redaction policy, payload caps, etc.). services.AddOptions() .Bind(config.GetSection(ConfigSectionName)) .ValidateOnStart(); services.AddSingleton, AuditLogOptionsValidator>(); // M5 Bundle A: payload filter — truncates oversized RequestSummary / // ResponseSummary / ErrorDetail / Extra fields between event // construction and persistence. Bundle B layers header / body / // SQL-parameter redaction onto the same singleton; Bundle C wires it // into the FallbackAuditWriter / CentralAuditWriter / IngestActor // paths. Singleton — the filter is stateless and the IOptionsMonitor // dependency picks up M5-T8 hot reloads on its own. services.AddSingleton(); // M5 Bundle B: per-stage redactor-failure counter. NoOp default; // Bundle C replaces this binding with the Site Health Monitoring // bridge that surfaces failures as AuditRedactionFailure on the site // health report. services.TryAddSingleton(); // M2 Bundle E: site writer + telemetry options bindings. // BindConfiguration is not used because the configuration root supplied // by the caller may not be the application root — we go through the // section explicitly so a partial IConfiguration (e.g. a test stub // anchored on the AuditLog section's parent) still works. services.AddOptions() .Bind(config.GetSection(SiteWriterSectionName)); services.AddOptions() .Bind(config.GetSection(SiteTelemetrySectionName)); // SqliteAuditWriter is a singleton with a single owned SqliteConnection // and a background writer Task; multiple instances would race on the // same file. Registered concretely so the ISiteAuditQueue + IAuditWriter // forwards below resolve to the same instance — the actor must observe // the writes made via the hot-path interface. services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); // RingBufferFallback: drop-oldest in-memory ring used by // FallbackAuditWriter when the primary SQLite writer throws. Default // capacity is fine for M2 (1024). services.AddSingleton(); // IAuditWriteFailureCounter: NoOp default. Bundle G overrides this // binding with the real Site Health Monitoring counter. Registered // before FallbackAuditWriter so the factory can resolve it. services.AddSingleton(); // The script-thread surface is FallbackAuditWriter (primary + ring + // counter), not the raw SqliteAuditWriter — primary failures must NEVER // abort the user-facing action. // Bundle C (M5-T6): the IAuditPayloadFilter singleton above is wired // through the factory so every event written through this surface is // truncated + redacted before it hits SQLite (and the ring on // failure). services.AddSingleton(sp => new FallbackAuditWriter( primary: sp.GetRequiredService(), ring: sp.GetRequiredService(), failureCounter: sp.GetRequiredService(), logger: sp.GetRequiredService>(), filter: sp.GetRequiredService())); // ISiteStreamAuditClient: NoOp default. This binding remains correct for // central/test composition roots that have no SiteCommunicationActor. // The real implementation is ClusterClientSiteAuditClient, which pushes // audit telemetry to central over Akka ClusterClient via the site's // SiteCommunicationActor — the Host wires it directly into the // SiteAuditTelemetryActor's Props.Create call for site roles (it cannot // be a DI singleton because it needs the SiteCommunicationActor IActorRef, // created during Akka bootstrap, not at DI-composition time). services.AddSingleton(); // M3 Bundle F: site-side dual emitter for cached-call lifecycle // telemetry. ScriptRuntimeContext.ExternalSystem.CachedCall / // Database.CachedWrite resolves this through DI and pushes one combined // packet per lifecycle event; the forwarder writes the audit half // through IAuditWriter and the operational half through the // IOperationTrackingStore. The audit writer is always wired (the M2 // chain above); the operational tracking store is SITE-ONLY (registered // by ScadaLink.SiteRuntime). On a Central composition root the tracking // store has no registration, so the factory resolves it with GetService // (returning null) — the forwarder degrades to "audit-only" emission, // mirroring the lazy IAuditWriter chain established in M2. services.AddSingleton(sp => new CachedCallTelemetryForwarder( sp.GetRequiredService(), sp.GetService(), sp.GetRequiredService>())); // M3 Bundle F: bridge the store-and-forward retry-loop observer hook // to the cached-call forwarder so per-attempt + terminal telemetry // emitted from the S&F retry sweep lands on the same SQLite hot-path // as the script-thread CachedSubmit row. Registered as a singleton // and also bound to ICachedCallLifecycleObserver so AddStoreAndForward // can resolve it through DI (Bundle F StoreAndForward wiring change). services.AddSingleton(); services.AddSingleton( sp => sp.GetRequiredService()); // M6 Bundle E (T8): central audit-write failure counter — NoOp default // for site/test composition roots that don't wire the central health // snapshot. AddAuditLogCentralMaintenance below replaces this binding // with the AuditCentralHealthSnapshot implementation so increments // surface on the central dashboard. services.TryAddSingleton(); // M4 Bundle B: central direct-write audit writer used by // NotificationOutboxActor (Bundle B) and Inbound API (Bundle C/D) to // emit AuditLog rows that originate ON central, not via site telemetry. // Singleton — the writer is stateless; its per-call scope opens a fresh // IAuditLogRepository (a SCOPED EF Core service registered by // ScadaLink.ConfigurationDatabase). The interface (ICentralAuditWriter) // is intentionally distinct from IAuditWriter so site composition roots // do not accidentally bind it; central composition roots that include // AddConfigurationDatabase get a working implementation transparently. // Bundle C (M5-T6): wire the IAuditPayloadFilter into the factory so // NotificationOutboxActor + Inbound API rows are truncated + redacted // before they hit MS SQL. // M6 Bundle E (T8): also wire the ICentralAuditWriteFailureCounter // so swallowed repo throws bump the central health counter. services.AddSingleton(sp => new CentralAuditWriter( sp, sp.GetRequiredService>(), sp.GetRequiredService(), sp.GetRequiredService())); return services; } /// /// Audit Log (#23) M2 Bundle G + M5 Bundle C — swap the default /// and /// registrations for the /// real / /// bridges so the /// FallbackAuditWriter primary-failure counter AND the /// DefaultAuditPayloadFilter redactor-failure counter both surface in the /// site health report payload as /// SiteHealthReport.SiteAuditWriteFailures + /// SiteHealthReport.AuditRedactionFailure. /// /// /// /// Must be called AFTER both (registers the /// NoOp defaults this method replaces) and /// ScadaLink.HealthMonitoring.ServiceCollectionExtensions.AddHealthMonitoring /// or AddSiteHealthMonitoring (registers the /// the bridges depend on). Resolving /// or /// without the latter throws /// at GetRequiredService /// time — by design, since a silent NoOp would mask a misconfiguration. /// /// /// Idempotent — calling twice replaces each descriptor without piling up /// registrations. /// /// /// Site-side only for M5: the central composition root keeps the NoOp /// defaults; the central health-metric surface that would expose /// AuditRedactionFailure next to the existing central counters /// ships in M6. /// /// public static IServiceCollection AddAuditLogHealthMetricsBridge(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); services.Replace( ServiceDescriptor.Singleton()); services.Replace( ServiceDescriptor.Singleton()); // M6 Bundle E (T6): the site-side backlog reporter polls the // SqliteAuditWriter every 30 s and pushes the snapshot into the // collector so the next SiteHealthReport carries a fresh // SiteAuditBacklog field. Registered alongside the other site-only // metric bridges so AddAuditLog (which runs on central too) stays // free of hosted-service registrations that would resolve a missing // ISiteHealthCollector on central. services.AddHostedService(); return services; } /// /// Audit Log (#23) M6-T5 Bundle D — central-only registration for the /// hosted service plus /// its binding. Must be /// called from the Central role's composition root (not from a site /// composition root); the underlying IPartitionMaintenance /// implementation is registered by AddConfigurationDatabase and /// only exists on the central node. /// /// /// /// Separated from because AddAuditLog is /// also invoked from site composition roots — silently starting a /// hosted service that resolves an unregistered dependency on a site /// would fail every tick. Keeping the central-only registration in its /// own helper preserves the "every Add* call is safe to issue /// from any composition root" invariant. /// /// public static IServiceCollection AddAuditLogCentralMaintenance( this IServiceCollection services, IConfiguration config) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); services.AddOptions() .Bind(config.GetSection(PartitionMaintenanceSectionName)); services.AddHostedService(); // M6 Bundle E (T8 + T9): central health snapshot — a single object // that owns the CentralAuditWriteFailures + AuditRedactionFailure // Interlocked counters AND surfaces them on // IAuditCentralHealthSnapshot. The same instance is bound to BOTH // writer-side interfaces (ICentralAuditWriteFailureCounter + // IAuditRedactionFailureCounter) so every central-side increment // routes into the shared counters; site nodes keep their existing // Site bridges (registered by AddAuditLogHealthMetricsBridge) so // the same counter type does not shadow the site-side metric. // The snapshot itself has no actor-system dependency — the // per-site stalled latch is fed by SiteAuditTelemetryStalledTracker // which the Akka bootstrap wires up after ActorSystem.Create returns // (the tracker is NOT registered here because its construction // requires ActorSystem, which is not a DI-resolvable singleton). services.AddSingleton(); services.AddSingleton( sp => sp.GetRequiredService()); services.Replace(ServiceDescriptor.Singleton( sp => sp.GetRequiredService())); // M6 Bundle E (T9): override the NoOp IAuditRedactionFailureCounter // (registered by AddAuditLog) with the CentralAuditRedactionFailureCounter // bridge so payload-filter throws on CentralAuditWriter / // AuditLogIngestActor paths surface on the central dashboard. The // bridge is a thin wrapper around the AuditCentralHealthSnapshot // singleton so all central redactor failures route into the same // counter as CentralAuditWriteFailures. The site composition root // overrides this binding AGAIN via AddAuditLogHealthMetricsBridge — // central nodes do not call that bridge, so this is the final // binding on a central host. Mirrors the M5 Bundle C // HealthMetricsAuditRedactionFailureCounter shape one-for-one. services.Replace(ServiceDescriptor.Singleton()); return services; } }