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:
@@ -1,137 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
||||
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties on this record are
|
||||
/// invariantly UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// Their init-setters call <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>
|
||||
/// to force <see cref="DateTimeKind.Utc"/> on assignment, so a value built from a
|
||||
/// <c>DateTime</c> literal or re-hydrated from a SQL Server <c>datetime2</c> column
|
||||
/// (which strips the <c>Kind</c> flag on the wire) cannot leak downstream as
|
||||
/// <see cref="DateTimeKind.Unspecified"/> or be silently re-interpreted as local
|
||||
/// time. The unrelated <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications"/>
|
||||
/// surface uses <see cref="DateTimeOffset"/> for the same UTC guarantee; this
|
||||
/// entity stays on <see cref="DateTime"/> to match the partitioned SQL Server
|
||||
/// <c>datetime2</c> column shape required by the AuditLog table.
|
||||
/// </remarks>
|
||||
public sealed record AuditEvent
|
||||
{
|
||||
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the audited action occurred at its source. The value
|
||||
/// MUST be in UTC ("All timestamps are UTC throughout the system." — CLAUDE.md).
|
||||
/// The init-setter forces <see cref="DateTimeKind.Utc"/> on assignment via
|
||||
/// <see cref="DateTime.SpecifyKind(DateTime, DateTimeKind)"/>, so any
|
||||
/// construction path that supplies a value with <see cref="DateTimeKind.Unspecified"/>
|
||||
/// (e.g. a <c>DateTime</c> literal, JSON deserialisation, or a SQL Server
|
||||
/// <c>datetime2</c> read where the value bypassed the EF converter) is
|
||||
/// re-tagged as UTC rather than treated as local time downstream. Producers
|
||||
/// are still expected to supply values that ARE genuinely UTC — the setter
|
||||
/// only fixes the <c>Kind</c> flag, it cannot re-interpret a local-time value.
|
||||
/// </summary>
|
||||
public DateTime OccurredAtUtc
|
||||
{
|
||||
get => _occurredAtUtc;
|
||||
init => _occurredAtUtc = DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
private readonly DateTime _occurredAtUtc;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the row was ingested at central; null on the site hot-path.
|
||||
/// The value MUST be in UTC when non-null; the init-setter forces
|
||||
/// <see cref="DateTimeKind.Utc"/> on assignment, matching
|
||||
/// <see cref="OccurredAtUtc"/>'s contract.
|
||||
/// </summary>
|
||||
public DateTime? IngestedAtUtc
|
||||
{
|
||||
get => _ingestedAtUtc;
|
||||
init => _ingestedAtUtc = value.HasValue
|
||||
? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
|
||||
: 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 (see alog.md §4).</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 — the universal
|
||||
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
||||
/// is the per-operation lifecycle id).
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
|
||||
/// run was spawned by another; null for top-level runs. Lets a spawned
|
||||
/// execution point back at its spawner for cross-run correlation.
|
||||
/// </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 — `node-a` / `node-b` for
|
||||
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
|
||||
/// for central-originated rows. Stamped by the writing node from
|
||||
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
|
||||
/// has since been retired don't block ingest.
|
||||
/// </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 (script trust boundary), 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 (outbound API + inbound API).</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; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
@@ -7,6 +7,12 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
|
||||
/// Failures must NEVER abort the user-facing action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C3 (Task 2.5): the event type is the canonical <see cref="ZB.MOM.WW.Audit.AuditEvent"/>.
|
||||
/// The local seam is retained (rather than collapsed onto <c>ZB.MOM.WW.Audit.IAuditWriter</c>)
|
||||
/// so it stays a distinct DI binding from <see cref="ICentralAuditWriter"/> and so the many
|
||||
/// existing site/central implementations and test fakes keep their identity.
|
||||
/// </remarks>
|
||||
public interface IAuditWriter
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -34,7 +34,7 @@ public interface ISiteAuditQueue
|
||||
/// <see cref="MarkForwardedAsync"/> will yield the same rows again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AuditLog-001: cached-lifecycle <see cref="AuditEvent.Kind"/>s
|
||||
/// AuditLog-001: cached-lifecycle audit kinds
|
||||
/// (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||
@@ -52,7 +52,7 @@ public interface ISiteAuditQueue
|
||||
/// <summary>
|
||||
/// AuditLog-001: returns up to <paramref name="limit"/> rows in
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditForwardState.Pending"/>
|
||||
/// whose <see cref="AuditEvent.Kind"/> belongs to the cached-call lifecycle
|
||||
/// whose audit kind belongs to the cached-call lifecycle
|
||||
/// vocabulary (<see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.CachedSubmit"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.ApiCallCached"/>,
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AuditKind.DbWriteCached"/>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user