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
@@ -2,10 +2,11 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
@@ -45,30 +46,36 @@ public class AuditLogRepository : IAuditLogRepository
throw new ArgumentNullException(nameof(evt));
}
// C3 transitional shim: the canonical record carries the ScadaBridge domain
// fields inside DetailsJson — decompose it into the typed 24-column values the
// existing dbo.AuditLog table expects. Central rows leave ForwardState null
// (it is a site-storage-only concern, never on a central row).
var r = AuditRowProjection.Decompose(evt);
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
// the conversion in C# rather than relying on parameter type inference —
// SqlClient would otherwise bind enums as int by default.
var channel = evt.Channel.ToString();
var kind = evt.Kind.ToString();
var status = evt.Status.ToString();
var forwardState = evt.ForwardState?.ToString();
var channel = r.Channel.ToString();
var kind = r.Kind.ToString();
var status = r.Status.ToString();
string? forwardState = null;
// FormattableString interpolation parameterises every value (no concatenation),
// so this is safe against injection even for the string columns.
try
{
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {r.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
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.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});",
({r.EventId}, {r.OccurredAtUtc}, {r.IngestedAtUtc}, {channel}, {kind}, {r.CorrelationId}, {r.ExecutionId}, {r.ParentExecutionId},
{r.SourceSiteId}, {r.SourceNode}, {r.SourceInstanceId}, {r.SourceScript}, {r.Actor}, {r.Target}, {status},
{r.HttpStatus}, {r.DurationMs}, {r.ErrorMessage}, {r.ErrorDetail}, {r.RequestSummary},
{r.ResponseSummary}, {r.PayloadTruncated}, {r.Extra}, {forwardState});",
ct);
}
catch (SqlException ex) when (
@@ -85,7 +92,7 @@ VALUES
ex,
"InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
ex.Number,
evt.EventId);
r.EventId);
}
}
@@ -103,7 +110,10 @@ VALUES
throw new ArgumentNullException(nameof(paging));
}
var query = _context.Set<AuditEvent>().AsNoTracking();
// C3 transitional shim: the typed-column filter predicates query the
// AuditLogRow persistence shape as before (C6 retargets how the filter is
// applied); the materialized rows are recomposed into canonical records.
var query = _context.Set<AuditLogRow>().AsNoTracking();
// Multi-value dimensions: a null OR empty list means "no constraint"
// (the { Count: > 0 } guard prevents an empty list collapsing to a
@@ -181,13 +191,47 @@ VALUES
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
}
return await query
var rows = await query
.OrderByDescending(e => e.OccurredAtUtc)
.ThenByDescending(e => e.EventId)
.Take(paging.PageSize)
.ToListAsync(ct);
return rows.Select(RowToCanonical).ToList();
}
/// <summary>
/// C3 transitional shim: recompose a canonical <see cref="AuditEvent"/> from a
/// materialized <see cref="AuditLogRow"/> read back from <c>dbo.AuditLog</c>.
/// <c>ForwardState</c> is dropped (central rows never carry it; it is not a
/// canonical / DetailsJson field).
/// </summary>
private static AuditEvent RowToCanonical(AuditLogRow row)
=> AuditRowProjection.Recompose(new AuditRowProjection.AuditRowValues(
EventId: row.EventId,
OccurredAtUtc: row.OccurredAtUtc,
IngestedAtUtc: row.IngestedAtUtc,
Channel: row.Channel,
Kind: row.Kind,
Status: row.Status,
CorrelationId: row.CorrelationId,
ExecutionId: row.ExecutionId,
ParentExecutionId: row.ParentExecutionId,
SourceSiteId: row.SourceSiteId,
SourceNode: row.SourceNode,
SourceInstanceId: row.SourceInstanceId,
SourceScript: row.SourceScript,
Actor: row.Actor,
Target: row.Target,
HttpStatus: row.HttpStatus,
DurationMs: row.DurationMs,
ErrorMessage: row.ErrorMessage,
ErrorDetail: row.ErrorDetail,
RequestSummary: row.RequestSummary,
ResponseSummary: row.ResponseSummary,
PayloadTruncated: row.PayloadTruncated,
Extra: row.Extra));
/// <inheritdoc />
public async Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
{
@@ -674,7 +718,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
return await _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)