feat(audit)!: ScadaBridge C5 — collapse central dbo.AuditLog to 10 canonical cols + persisted computed cols; CollapseAuditLogToCanonical migration; repo writes canonical directly (Task 2.5)

This commit is contained in:
Joseph Doherty
2026-06-02 14:06:46 -04:00
parent 1737d15f04
commit 68a6bd1720
12 changed files with 2592 additions and 440 deletions
@@ -1,20 +1,41 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using AuditOutcome = ZB.MOM.WW.Audit.AuditOutcome;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// <summary>
/// Transitional EF Core persistence shape for the central <c>dbo.AuditLog</c> table
/// (Audit Log #23). This is the 24-column row formerly modelled by
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent</c>; in C3 (Task 2.5)
/// the canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> became the type at every seam,
/// emit site, DTO boundary, and redactor, and this row type was relocated here as a
/// storage-only entity so the existing table keeps working unchanged.
/// EF Core persistence shape for the central <c>dbo.AuditLog</c> table after the
/// C5 collapse (Audit Log #23, Task 2.5). The table is now the 10 canonical
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> fields stored DIRECTLY plus a set of
/// read-only, server-side <b>persisted computed columns</b> derived from
/// <see cref="DetailsJson"/> (<c>JSON_VALUE</c> … <c>PERSISTED</c>) so the
/// reporting queries stay indexable without re-parsing JSON.
/// </summary>
/// <remarks>
/// <para>
/// The repository maps canonical ⇄ this row at the persistence boundary via
/// <c>ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.AuditRowProjection</c>. C5 replaces
/// this shim + table with the real DetailsJson-backed schema.
/// <b>C5 (Task 2.5).</b> The transitional 24-typed-column shim is retired. The
/// repository writes the 10 canonical columns directly (no <c>Decompose</c>) and
/// the computed columns auto-derive at INSERT; reads build the canonical
/// <c>AuditEvent</c> straight off the canonical columns (no <c>Recompose</c>).
/// </para>
/// <para>
/// <b>Canonical columns (writable):</b> <see cref="EventId"/>,
/// <see cref="OccurredAtUtc"/>, <see cref="Actor"/>, <see cref="Action"/>,
/// <see cref="Outcome"/>, <see cref="Channel"/> (the canonical <c>Category</c>
/// column — for ScadaBridge, Category = channel name), <see cref="Target"/>,
/// <see cref="SourceNode"/>, <see cref="CorrelationId"/>,
/// <see cref="DetailsJson"/>.
/// </para>
/// <para>
/// <b>Persisted computed columns (read-only):</b> <see cref="Kind"/>,
/// <see cref="Status"/>, <see cref="SourceSiteId"/>, <see cref="ExecutionId"/>,
/// <see cref="ParentExecutionId"/> (the five spec'd queryability columns), plus
/// <see cref="IngestedAtUtc"/> (central ingest timestamp, also a DetailsJson
/// field). These are populated by SQL Server from <see cref="DetailsJson"/>; EF
/// never writes them. Their getters expose them as typed
/// (enum / <see cref="Guid"/> / <see cref="DateTime"/>) properties so the
/// existing LINQ filter/aggregate queries keep their meaning; the value
/// converters that turn enum names ⇄ varchar match the JSON_VALUE string output.
/// </para>
/// <para>
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
@@ -27,6 +48,8 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
/// </remarks>
public sealed record AuditLogRow
{
// ── Canonical columns (the 10 ZB.MOM.WW.Audit.AuditEvent fields) ──────────
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
public Guid EventId { get; init; }
@@ -38,7 +61,75 @@ public sealed record AuditLogRow
}
private readonly DateTime _occurredAtUtc;
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.); null/empty for system/anon.</summary>
public string? Actor { get; init; }
/// <summary>Canonical action verb — <c>"{channel}.{kind}"</c> (e.g. <c>ApiOutbound.ApiCall</c>).</summary>
public string Action { get; init; } = string.Empty;
/// <summary>Normalized canonical outcome (Success / Failure / Denied).</summary>
public AuditOutcome Outcome { get; init; }
/// <summary>
/// Trust-boundary channel the audited action crossed. Stored in the canonical
/// <c>Category</c> column (for ScadaBridge the canonical Category IS the channel
/// name); exposed here as the strongly-typed <see cref="AuditChannel"/> enum.
/// </summary>
public AuditChannel Channel { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>The cluster node on which the event was emitted.</summary>
public string? SourceNode { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Canonical JSON extension bag carrying every ScadaBridge domain field.</summary>
public string? DetailsJson { get; init; }
// ── Persisted computed columns (read-only; derived from DetailsJson) ──────
/// <summary>
/// Specific event kind. Computed column <c>JSON_VALUE(DetailsJson,'$.kind')</c>
/// PERSISTED; read-only (the DB derives it on INSERT).
/// </summary>
public AuditKind Kind { get; init; }
/// <summary>
/// Lifecycle status. Computed column <c>JSON_VALUE(DetailsJson,'$.status')</c>
/// PERSISTED; read-only.
/// </summary>
public AuditStatus Status { get; init; }
/// <summary>
/// Site id where the action originated; null for central-direct events. Computed
/// column <c>JSON_VALUE(DetailsJson,'$.sourceSiteId')</c> PERSISTED; read-only.
/// </summary>
public string? SourceSiteId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request. Computed column
/// <c>CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier)</c>
/// PERSISTED; read-only.
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// ExecutionId of the execution that spawned this run; null for top-level runs.
/// Computed column
/// <c>CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier)</c>
/// PERSISTED; read-only.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>
/// UTC timestamp when the central AuditLog store ingested this row; null until
/// central stamps it. Computed column over
/// <c>JSON_VALUE(DetailsJson,'$.ingestedAtUtc')</c> (normalized to UTC datetime2)
/// PERSISTED; read-only.
/// </summary>
public DateTime? IngestedAtUtc
{
get => _ingestedAtUtc;
@@ -47,67 +138,4 @@ public sealed record AuditLogRow
: null;
}
private readonly DateTime? _ingestedAtUtc;
/// <summary>Trust-boundary channel the audited action crossed.</summary>
public AuditChannel Channel { get; init; }
/// <summary>Specific event kind within the channel.</summary>
public AuditKind Kind { get; init; }
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Id of the originating script execution / inbound request.</summary>
public Guid? ExecutionId { get; init; }
/// <summary>ExecutionId of the execution that spawned this run; null for top-level runs.</summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }
/// <summary>The cluster node on which the event was emitted.</summary>
public string? SourceNode { get; init; }
/// <summary>Instance id where the action originated, when applicable.</summary>
public string? SourceInstanceId { get; init; }
/// <summary>Script that initiated the action, when applicable.</summary>
public string? SourceScript { get; init; }
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
public string? Actor { get; init; }
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
public string? Target { get; init; }
/// <summary>Lifecycle status of this row.</summary>
public AuditStatus Status { get; init; }
/// <summary>HTTP status code where applicable.</summary>
public int? HttpStatus { get; init; }
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
public int? DurationMs { get; init; }
/// <summary>Human-readable error summary on failure rows.</summary>
public string? ErrorMessage { get; init; }
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
public string? ErrorDetail { get; init; }
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
public string? RequestSummary { get; init; }
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
public string? ResponseSummary { get; init; }
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
public bool PayloadTruncated { get; init; }
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
public string? Extra { get; init; }
/// <summary>Site-local forwarding state; null on central rows.</summary>
public AuditForwardState? ForwardState { get; init; }
}