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
@@ -0,0 +1,193 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Transitional canonical ⇄ 24-column shim for the two AuditLog storage
/// implementations (site SQLite, central SQL Server). C3 keeps the existing
/// 24-column tables UNCHANGED; this helper decomposes a canonical
/// <see cref="ZB.MOM.WW.Audit.AuditEvent"/> into the typed domain values the
/// columns expect (Channel/Kind/Status enums + the <see cref="AuditDetails"/>
/// fields) and recomposes a canonical record from those column values.
/// </summary>
/// <remarks>
/// <para>
/// C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical record
/// only carries Action/Category/Outcome at the top level and stashes every
/// ScadaBridge domain field inside <c>DetailsJson</c>; the legacy storage rows
/// carry the domain fields as typed columns. This shim bridges the two without
/// any schema change. C4 replaces the site shim with the real DetailsJson
/// schema; C5 the central one.
/// </para>
/// <para>
/// <c>ForwardState</c> is deliberately NOT part of this projection — it is a
/// site-storage-only concern carried alongside the canonical record by the site
/// SQLite writer, never inside <c>DetailsJson</c> and never on a central row.
/// </para>
/// </remarks>
public static class AuditRowProjection
{
/// <summary>
/// The decomposed domain view of a canonical <see cref="AuditEvent"/> — the
/// values the 24 storage columns expect. Built by <see cref="Decompose"/> from
/// the canonical top-level fields plus the <see cref="AuditDetails"/> bag.
/// </summary>
public readonly record struct AuditRowValues(
Guid EventId,
DateTime OccurredAtUtc,
DateTime? IngestedAtUtc,
AuditChannel Channel,
AuditKind Kind,
AuditStatus Status,
Guid? CorrelationId,
Guid? ExecutionId,
Guid? ParentExecutionId,
string? SourceSiteId,
string? SourceNode,
string? SourceInstanceId,
string? SourceScript,
string? Actor,
string? Target,
int? HttpStatus,
int? DurationMs,
string? ErrorMessage,
string? ErrorDetail,
string? RequestSummary,
string? ResponseSummary,
bool PayloadTruncated,
string? Extra);
/// <summary>
/// Decomposes a canonical record into the typed column values. Channel/Kind/Status
/// come from <c>DetailsJson</c> (the strings written by
/// <see cref="ScadaBridgeAuditEventFactory"/>); a missing/unparseable discriminator
/// falls back to the first enum member (defensive — production rows always carry them).
/// </summary>
public static AuditRowValues Decompose(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
var channel = ParseEnum(d.Channel, AuditChannel.ApiInbound);
var kind = ParseEnum(d.Kind, AuditKind.InboundRequest);
var status = ParseEnum(d.Status, AuditStatus.Submitted);
// The canonical OccurredAtUtc is UTC by construction; columns store a
// Kind=Utc DateTime so downstream UTC/local conversions are safe
// (CLAUDE.md: "All timestamps are UTC throughout the system.").
var occurred = DateTime.SpecifyKind(evt.OccurredAtUtc.UtcDateTime, DateTimeKind.Utc);
DateTime? ingested = d.IngestedAtUtc.HasValue
? DateTime.SpecifyKind(d.IngestedAtUtc.Value.UtcDateTime, DateTimeKind.Utc)
: null;
return new AuditRowValues(
EventId: evt.EventId,
OccurredAtUtc: occurred,
IngestedAtUtc: ingested,
Channel: channel,
Kind: kind,
Status: status,
CorrelationId: evt.CorrelationId,
ExecutionId: d.ExecutionId,
ParentExecutionId: d.ParentExecutionId,
SourceSiteId: d.SourceSiteId,
SourceNode: evt.SourceNode,
SourceInstanceId: d.SourceInstanceId,
SourceScript: d.SourceScript,
// Canonical Actor is a required non-null string; an empty Actor maps
// back to a NULL column (legacy rows stored null for system/anon).
Actor: string.IsNullOrEmpty(evt.Actor) ? null : evt.Actor,
Target: evt.Target,
HttpStatus: d.HttpStatus,
DurationMs: d.DurationMs,
ErrorMessage: d.ErrorMessage,
ErrorDetail: d.ErrorDetail,
RequestSummary: d.RequestSummary,
ResponseSummary: d.ResponseSummary,
PayloadTruncated: d.PayloadTruncated,
Extra: d.Extra);
}
/// <summary>
/// Recomposes a canonical <see cref="AuditEvent"/> from the typed column values read
/// back from storage. The inverse of <see cref="Decompose"/>: Action/Category/Outcome
/// are rebuilt via the field builders / outcome projector, and every domain field is
/// re-serialized into <c>DetailsJson</c> via <see cref="AuditDetailsCodec"/>.
/// </summary>
public static AuditEvent Recompose(in AuditRowValues v)
{
var details = new AuditDetails
{
Channel = v.Channel.ToString(),
Kind = v.Kind.ToString(),
Status = v.Status.ToString(),
ExecutionId = v.ExecutionId,
ParentExecutionId = v.ParentExecutionId,
SourceSiteId = v.SourceSiteId,
SourceInstanceId = v.SourceInstanceId,
SourceScript = v.SourceScript,
HttpStatus = v.HttpStatus,
DurationMs = v.DurationMs,
ErrorMessage = v.ErrorMessage,
ErrorDetail = v.ErrorDetail,
RequestSummary = v.RequestSummary,
ResponseSummary = v.ResponseSummary,
PayloadTruncated = v.PayloadTruncated,
Extra = v.Extra,
IngestedAtUtc = v.IngestedAtUtc.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(v.IngestedAtUtc.Value, DateTimeKind.Utc))
: null,
};
return new AuditEvent
{
EventId = v.EventId,
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(v.OccurredAtUtc, DateTimeKind.Utc)),
Actor = v.Actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(v.Channel, v.Kind),
Category = AuditFieldBuilders.BuildCategory(v.Channel),
Outcome = AuditOutcomeProjector.Project(v.Status, v.Kind),
Target = v.Target,
SourceNode = v.SourceNode,
CorrelationId = v.CorrelationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
/// <summary>
/// Returns a copy of <paramref name="evt"/> with the central-side ingest timestamp
/// stamped into its <c>DetailsJson</c> (<see cref="AuditDetails.IngestedAtUtc"/>).
/// C3 transitional shim: <c>IngestedAtUtc</c> is a DetailsJson field on the canonical
/// record, so the central ingest paths stamp it here rather than on a top-level
/// property as the legacy bespoke record allowed.
/// </summary>
public static AuditEvent WithIngestedAtUtc(AuditEvent evt, DateTimeOffset ingestedAtUtc)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson) with
{
IngestedAtUtc = ingestedAtUtc.ToUniversalTime(),
};
return evt with { DetailsJson = AuditDetailsCodec.Serialize(d) };
}
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback) where TEnum : struct, Enum
=> !string.IsNullOrEmpty(value) && Enum.TryParse<TEnum>(value, ignoreCase: false, out var parsed)
? parsed
: fallback;
}
/// <summary>
/// Convenience extension that decomposes a canonical <see cref="AuditEvent"/> into its
/// typed 24-field <see cref="AuditRowProjection.AuditRowValues"/> view. Lets callers
/// (and tests) read the ScadaBridge domain fields — Channel/Kind/Status + the DetailsJson
/// fields — as typed properties off a canonical row.
/// </summary>
public static class AuditEventRowExtensions
{
/// <summary>Decomposes this canonical record into its typed 24-field view.</summary>
public static AuditRowProjection.AuditRowValues AsRow(this AuditEvent evt)
=> AuditRowProjection.Decompose(evt);
}
@@ -0,0 +1,125 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
/// <summary>
/// Single construction point for the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>
/// from ScadaBridge's domain vocabulary. Every emit site builds its row through
/// <see cref="Create"/> so the canonical-field mapping (Channel/Kind/Status →
/// Action/Category/Outcome, every other domain field → <see cref="AuditDetails"/>
/// inside <see cref="ZB.MOM.WW.Audit.AuditEvent.DetailsJson"/>) is applied
/// identically everywhere — no per-site drift.
/// </summary>
/// <remarks>
/// <para>C3 of the ScadaBridge audit re-architecture (Task 2.5). The canonical
/// record is the type at every seam, emit site, DTO boundary, and redactor; the
/// ScadaBridge domain fields ride in <c>DetailsJson</c> via
/// <see cref="AuditDetailsCodec"/>.</para>
/// <para>Mapping (see Task 2.5 spec):
/// <list type="bullet">
/// <item><c>Action</c> = <see cref="AuditFieldBuilders.BuildAction"/>(channel, kind).</item>
/// <item><c>Category</c> = <see cref="AuditFieldBuilders.BuildCategory"/>(channel) (= channel name).</item>
/// <item><c>Outcome</c> = <see cref="AuditOutcomeProjector.Project"/>(status, kind).</item>
/// <item><c>DetailsJson</c> carries Channel/Kind/Status (as strings) + every other
/// ScadaBridge domain field. <c>ForwardState</c> is NOT a DetailsJson field — it is
/// a site-storage-only concern handled by the site SQLite shim.</item>
/// </list>
/// </para>
/// </remarks>
public static class ScadaBridgeAuditEventFactory
{
/// <summary>
/// Builds the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/> for one ScadaBridge
/// audit row. <paramref name="channel"/>/<paramref name="kind"/>/<paramref name="status"/>
/// drive the canonical Action/Category/Outcome and are also recorded (as strings) in
/// <c>DetailsJson</c>; all remaining ScadaBridge domain fields are carried in
/// <c>DetailsJson</c> too.
/// </summary>
/// <param name="channel">Trust-boundary channel the audited action crossed.</param>
/// <param name="kind">Specific event kind within the channel.</param>
/// <param name="status">Lifecycle status of this row.</param>
/// <param name="eventId">Idempotency key. Defaults to a fresh <see cref="Guid"/> when omitted.</param>
/// <param name="occurredAtUtc">When the action occurred (UTC). Defaults to <see cref="DateTime.UtcNow"/> when omitted.</param>
/// <param name="actor">Authenticated actor for inbound paths (API key name, user, "system", etc.).</param>
/// <param name="target">Target of the action (external system, db connection, list name, inbound method).</param>
/// <param name="sourceNode">Cluster node that emitted the event (top-level canonical field).</param>
/// <param name="correlationId">Per-operation lifecycle correlation id (top-level canonical field).</param>
/// <param name="executionId">Originating script-execution / inbound-request id (DetailsJson).</param>
/// <param name="parentExecutionId">Spawning execution's id (DetailsJson).</param>
/// <param name="sourceSiteId">Site id where the action originated (DetailsJson).</param>
/// <param name="sourceInstanceId">Instance id where the action originated (DetailsJson).</param>
/// <param name="sourceScript">Script that initiated the action (DetailsJson).</param>
/// <param name="httpStatus">HTTP status code where applicable (DetailsJson).</param>
/// <param name="durationMs">Duration of the audited action in ms (DetailsJson).</param>
/// <param name="errorMessage">Human-readable error summary on failure rows (DetailsJson).</param>
/// <param name="errorDetail">Verbose error detail (stack/exception) on failure rows (DetailsJson).</param>
/// <param name="requestSummary">Truncated/redacted request summary (DetailsJson).</param>
/// <param name="responseSummary">Truncated/redacted response summary (DetailsJson).</param>
/// <param name="payloadTruncated">True when summaries were truncated to the payload cap (DetailsJson).</param>
/// <param name="extra">Free-form JSON extension for channel-specific extras (DetailsJson).</param>
/// <param name="ingestedAtUtc">UTC ingest timestamp (central-set; DetailsJson).</param>
public static AuditEvent Create(
AuditChannel channel,
AuditKind kind,
AuditStatus status,
Guid? eventId = null,
DateTime? occurredAtUtc = null,
string? actor = null,
string? target = null,
string? sourceNode = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceSiteId = null,
string? sourceInstanceId = null,
string? sourceScript = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
string? errorDetail = null,
string? requestSummary = null,
string? responseSummary = null,
bool payloadTruncated = false,
string? extra = null,
DateTimeOffset? ingestedAtUtc = null)
{
var details = new AuditDetails
{
Channel = channel.ToString(),
Kind = kind.ToString(),
Status = status.ToString(),
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = sourceSiteId,
SourceInstanceId = sourceInstanceId,
SourceScript = sourceScript,
HttpStatus = httpStatus,
DurationMs = durationMs,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
PayloadTruncated = payloadTruncated,
Extra = extra,
IngestedAtUtc = ingestedAtUtc,
};
return new AuditEvent
{
EventId = eventId ?? Guid.NewGuid(),
// DateTimeOffset assumes UTC when the source DateTime is Unspecified/Utc;
// every ScadaBridge OccurredAt value is UTC by contract.
OccurredAtUtc = new DateTimeOffset(
DateTime.SpecifyKind(occurredAtUtc ?? DateTime.UtcNow, DateTimeKind.Utc)),
Actor = actor ?? string.Empty,
Action = AuditFieldBuilders.BuildAction(channel, kind),
Category = AuditFieldBuilders.BuildCategory(channel),
Outcome = AuditOutcomeProjector.Project(status, kind),
Target = target,
SourceNode = sourceNode,
CorrelationId = correlationId,
DetailsJson = AuditDetailsCodec.Serialize(details),
};
}
}