using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central; /// /// Central-only direct-write implementation of . /// Wraps as a best-effort /// audit emission path for components that originate audit events ON the central /// node (Notification Outbox dispatch, Inbound API) — NOT for site telemetry /// ingest (that path is the SiteAudit → AuditLogIngestActor batched flow). /// /// /// /// Best-effort contract. Audit-write failures NEVER abort the user-facing /// action (alog.md §13). The writer catches every exception thrown by repository /// resolution or the insert call, logs at warning, and returns successfully. /// Callers may still wrap the call in their own try/catch (defensive — the writer /// is supposed to swallow). /// /// /// Scope-per-call resolution. is a SCOPED /// EF Core service (registered by ZB.MOM.WW.ScadaBridge.ConfigurationDatabase). The /// writer itself is registered as a singleton (so all callers share one instance), /// so it cannot hold a scope across calls — it opens a fresh /// per invocation, mirroring /// the per-message scope pattern used by AuditLogIngestActor and /// NotificationOutboxActor. /// /// /// Idempotency. Persistence is via InsertIfNotExistsAsync, so a /// double-emitted event (same ) is a silent /// no-op — the writer is safe to call from any number of dispatch paths. /// /// public sealed class CentralAuditWriter : ICentralAuditWriter { private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly IAuditRedactor _redactor; private readonly ICentralAuditWriteFailureCounter _failureCounter; private readonly INodeIdentityProvider? _nodeIdentity; /// /// The central direct-write path used by the /// NotificationOutboxActor dispatch and the Inbound API middleware also /// needs to truncate + redact before the row hits MS SQL. The filter is /// optional so test composition roots that don't pass one keep /// working (they only ever write small payloads); production DI registers /// the real filter via . /// Adds the optional /// so a swallowed repository /// throw bumps the central health surface's /// CentralAuditWriteFailures counter. Defaults to a NoOp so test /// composition roots that don't wire the counter keep their current /// behaviour. SourceNode-stamping — adds the optional /// so central-origin rows (Notification /// Outbox dispatch, Inbound API) carry the writing central node's /// identifier when the caller hasn't already supplied one. Optional / /// defaulting-to-null so test composition roots that don't pass a /// provider keep working — the caller-wins discipline means an absent /// provider simply leaves SourceNode at whatever the caller set (often /// null, which is the legacy behaviour). /// /// Service provider used to open a per-call scope for the scoped repository. /// Logger for swallowed write-failure diagnostics. /// Optional canonical redactor for truncation and redaction; defaults to the always-safe default. /// Optional counter incremented on swallowed repository failures; defaults to a no-op. /// Optional node identity provider for stamping SourceNode on central-origin rows. public CentralAuditWriter( IServiceProvider services, ILogger logger, IAuditRedactor? redactor = null, ICentralAuditWriteFailureCounter? failureCounter = null, INodeIdentityProvider? nodeIdentity = null) { _services = services ?? throw new ArgumentNullException(nameof(services)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Never default to null — over-redact instead. // Wired via the canonical IAuditRedactor seam. // SafeDefaultAuditRedactor applies HTTP header redaction with // hard-coded sensitive defaults so a composition root that omits the // real redactor still scrubs Authorization / X-Api-Key / Cookie / // Set-Cookie before persistence. _redactor = redactor ?? SafeDefaultAuditRedactor.Instance; _failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter(); _nodeIdentity = nodeIdentity; } /// public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { if (evt is null) { // Defensive — a null event is a programming bug at the caller and // produces no meaningful audit row. Log and return. _logger.LogWarning("CentralAuditWriter.WriteAsync received null event; ignoring."); return; } try { // Redact BEFORE stamping IngestedAtUtc + handing to the repo. The // redactor contract is "never throws". _redactor is // now non-null (SafeDefaultAuditRedactor fallback) so header // redaction always runs even in composition roots that omit the // real redactor. var filtered = _redactor.Apply(evt); // SourceNode-stamping: caller-provided value wins // (supports any future direct-write callsite that already has its // own node id); otherwise stamp from the local // INodeIdentityProvider, when one is wired. Production DI on // central nodes always supplies the provider; legacy test // composition roots that don't pass it leave SourceNode at // whatever the caller set (often null), preserving back-compat. if (filtered.SourceNode is null && _nodeIdentity?.NodeName is { } nodeName) { filtered = filtered with { SourceNode = nodeName }; } await using var scope = _services.CreateAsyncScope(); var repo = scope.ServiceProvider.GetRequiredService(); // IngestedAtUtc is a DetailsJson field on the // canonical record, so stamp it via the projection helper. var stamped = AuditRowProjection.WithIngestedAtUtc(filtered, DateTime.UtcNow); await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false); } catch (Exception ex) { // Audit failure NEVER aborts the user-facing action — swallow and log. // Also surface the failure on the central health // counter so a sustained audit-write outage is visible on the // health dashboard rather than disappearing into the log file. try { _failureCounter.Increment(); } catch { // Counter must NEVER throw — defence in depth. Even if a // misbehaving custom counter does, swallowing here keeps the // best-effort contract intact. } // Log the input event's identifying fields. EventId + Action are // immutable across the redact+stamp chain — the `with` clones above // touch only SourceNode and DetailsJson — so referencing `evt` here // is intentional and equivalent to the stamped record for // diagnostics. Action = "{Channel}.{Kind}" carries the kind; the // canonical Outcome carries the coarse status (fine-grained Status // lives in DetailsJson). _logger.LogWarning( ex, "CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})", evt.EventId, evt.Action, evt.Outcome); } } }