9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
164 lines
8.5 KiB
C#
164 lines
8.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
|
|
/// Wraps <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> 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).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Best-effort contract.</b> 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).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Scope-per-call resolution.</b> <see cref="IAuditLogRepository"/> is a SCOPED
|
|
/// EF Core service (registered by <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase</c>). 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
|
|
/// <see cref="IServiceScope"/> per <see cref="WriteAsync"/> invocation, mirroring
|
|
/// the per-message scope pattern used by <c>AuditLogIngestActor</c> and
|
|
/// <c>NotificationOutboxActor</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Idempotency.</b> Persistence is via <c>InsertIfNotExistsAsync</c>, so a
|
|
/// double-emitted event (same <see cref="AuditEvent.EventId"/>) is a silent
|
|
/// no-op — the writer is safe to call from any number of dispatch paths.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class CentralAuditWriter : ICentralAuditWriter
|
|
{
|
|
private readonly IServiceProvider _services;
|
|
private readonly ILogger<CentralAuditWriter> _logger;
|
|
private readonly IAuditRedactor _redactor;
|
|
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
|
private readonly INodeIdentityProvider? _nodeIdentity;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="ServiceCollectionExtensions.AddAuditLog"/>.
|
|
/// Adds the optional
|
|
/// <see cref="ICentralAuditWriteFailureCounter"/> so a swallowed repository
|
|
/// throw bumps the central health surface's
|
|
/// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test
|
|
/// composition roots that don't wire the counter keep their current
|
|
/// behaviour. SourceNode-stamping — adds the optional
|
|
/// <see cref="INodeIdentityProvider"/> 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).
|
|
/// </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="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,
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<IAuditLogRepository>();
|
|
// 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);
|
|
}
|
|
}
|
|
}
|