Files
scadalink-design/src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs
Joseph Doherty e6341580b3 test(audit): lock null-provider passthrough on CentralAuditWriter
Two follow-ups flagged by code review on Tasks 11/12:

- Lock the back-compat contract for CentralAuditWriter's optional
  `nodeIdentity = null` ctor parameter with two explicit tests
  (`WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected` and
  `WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected`). The previous
  null-provider path was only exercised incidentally via legacy
  CentralAuditWriterTests setups; the new tests make the contract explicit
  and distinct from the "provider supplied, returns null" path.

- Document why the catch-block log references `evt` rather than the
  post-stamp record: the three logged fields (EventId, Kind, Status) are
  immutable across the filter+stamp chain, so referencing either name is
  equivalent — but the comment warns future maintainers to switch names if
  they ever add a field the chain mutates (e.g. SourceNode).
2026-05-23 17:18:45 -04:00

153 lines
7.6 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.AuditLog.Payload;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.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>ScadaLink.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 IAuditPayloadFilter? _filter;
private readonly ICentralAuditWriteFailureCounter _failureCounter;
private readonly INodeIdentityProvider? _nodeIdentity;
/// <summary>
/// Bundle C (M5-T6) — 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 the M4 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"/>.
/// M6 Bundle E (T8) — 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 (Task 12) — 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 M4 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>
public CentralAuditWriter(
IServiceProvider services,
ILogger<CentralAuditWriter> logger,
IAuditPayloadFilter? filter = null,
ICentralAuditWriteFailureCounter? failureCounter = null,
INodeIdentityProvider? nodeIdentity = null)
{
_services = services ?? throw new ArgumentNullException(nameof(services));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_filter = filter;
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
_nodeIdentity = nodeIdentity;
}
/// <summary>
/// Persists <paramref name="evt"/> into the central <c>AuditLog</c> table
/// idempotently on <see cref="AuditEvent.EventId"/>. Stamps
/// <see cref="AuditEvent.IngestedAtUtc"/> from the central-side clock.
/// Internal failures are logged and swallowed — never thrown.
/// </summary>
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
{
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
// filter contract is "never throws"; the null-coalesce keeps the
// M4 test composition roots (no filter passed) working unchanged.
var filtered = _filter?.Apply(evt) ?? evt;
// SourceNode-stamping (Task 12): 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>();
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
await repo.InsertIfNotExistsAsync(stamped, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
// Audit failure NEVER aborts the user-facing action — swallow and log.
// M6 Bundle E (T8): 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. 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`.
_logger.LogWarning(
ex,
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
evt.EventId, evt.Kind, evt.Status);
}
}
}