feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 12:37:50 -04:00
parent 5aaf9e2923
commit db707bb0de
127 changed files with 2240 additions and 3886 deletions
@@ -1,9 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
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;
@@ -41,7 +42,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
{
private readonly IServiceProvider _services;
private readonly ILogger<CentralAuditWriter> _logger;
private readonly IAuditPayloadFilter _filter;
private readonly IAuditRedactor _redactor;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
@@ -68,24 +69,25 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// </summary>
/// <param name="services">Service provider used to open a per-call scope for the scoped repository.</param>
/// <param name="logger">Logger for swallowed write-failure diagnostics.</param>
/// <param name="filter">Optional payload filter for truncation and redaction; defaults to a pass-through.</param>
/// <param name="redactor">Optional canonical redactor for truncation and redaction; defaults to the always-safe default.</param>
/// <param name="failureCounter">Optional counter incremented on swallowed repository failures; defaults to a no-op.</param>
/// <param name="nodeIdentity">Optional node identity provider for stamping <c>SourceNode</c> on central-origin rows.</param>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> logger,
IAuditPayloadFilter? filter = null,
IAuditRedactor? redactor = null,
ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// AuditLog-008: never default to null — over-redact instead.
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
// hard-coded sensitive defaults so a composition root that omits the
// real filter still scrubs Authorization / X-Api-Key / Cookie /
// Set-Cookie before persistence.
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
// C3 (Task 2.5): the canonical IAuditRedactor replaces the legacy
// IAuditPayloadFilter. 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;
}
@@ -103,12 +105,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
try
{
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws". AuditLog-008: _filter is now
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
// Redact BEFORE stamping IngestedAtUtc + handing to the repo. The
// redactor contract is "never throws". AuditLog-008: _redactor is
// now non-null (SafeDefaultAuditRedactor fallback) so header
// redaction always runs even in composition roots that omit the
// real filter.
var filtered = _filter.Apply(evt);
// real redactor.
var filtered = _redactor.Apply(evt);
// SourceNode-stamping (Task 12): caller-provided value wins
// (supports any future direct-write callsite that already has its
@@ -124,7 +126,9 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
await using var scope = _services.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
// C3 transitional shim: 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)
@@ -143,17 +147,17 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// misbehaving custom counter does, swallowing here keeps the
// best-effort contract intact.
}
// Log the input event's identifying fields. These three (EventId,
// Kind, Status) are immutable across the filter+stamp chain — the
// `with` clones above touch only SourceNode and IngestedAtUtc — so
// referencing `evt` here is intentional and equivalent to the
// stamped record for diagnostics. If you add a field here that the
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
// post-stamp record name instead, not `evt`.
// 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} (Kind={Kind}, Status={Status})",
evt.EventId, evt.Kind, evt.Status);
"CentralAuditWriter failed for EventId {EventId} (Action={Action}, Outcome={Outcome})",
evt.EventId, evt.Action, evt.Outcome);
}
}
}