feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository

CentralAuditWriter injects INodeIdentityProvider and stamps the event before
handing to the repository. AuditLogRepository.InsertIfNotExistsAsync now
includes SourceNode in the INSERT column list. Caller-provided value wins
(supports any future direct-write callsite that already has its own node id).
This commit is contained in:
Joseph Doherty
2026-05-23 17:11:23 -04:00
parent 479870e40c
commit 974a36826a
5 changed files with 143 additions and 6 deletions

View File

@@ -43,6 +43,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
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
@@ -56,18 +57,27 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
/// 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.
/// 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)
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>
@@ -93,6 +103,18 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
// 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 };

View File

@@ -183,7 +183,14 @@ public static class ServiceCollectionExtensions
sp,
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
sp.GetRequiredService<IAuditPayloadFilter>(),
sp.GetRequiredService<ICentralAuditWriteFailureCounter>()));
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
// SourceNode-stamping (Task 12): wire the local node identity so
// central-origin rows (Notification Outbox dispatch, Inbound API)
// carry the writing node's identifier when the caller hasn't
// already supplied one. GetRequiredService — the production
// composition root in SiteServiceRegistration registers the
// provider as a singleton on both site and central paths.
sp.GetRequiredService<INodeIdentityProvider>()));
return services;
}

View File

@@ -65,12 +65,12 @@ public class AuditLogRepository : IAuditLogRepository
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.SourceSiteId}, {evt.SourceNode}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
ct);