feat(audit): ScadaBridge C1 — AuditDetails codec (deterministic) + AuditOutcome projection + canonical field builders + ZB.MOM.WW.Audit ref (Task 2.5)
Additive foundation only — no existing type/interface/emitter changed.
Commons now references ZB.MOM.WW.Audit 0.1.0 (Gitea feed, central PM pin).
Adds four pure new types in Commons/Types/Audit/:
AuditDetails (sealed record, 17 domain fields, declaration-order = JSON key order)
AuditDetailsCodec (static; single cached JsonSerializerOptions: camelCase, no-indent,
WhenWritingNull, UnsafeRelaxedJsonEscaping — byte-deterministic across calls)
AuditOutcomeProjector (static; InboundAuthFailure→Denied first, then Delivered→Success,
Failed/Parked/Discarded→Failure, all others→Success)
AuditFieldBuilders (static; BuildAction="{channel}.{kind}", BuildCategory=channel.ToString())
56 new tests in Commons.Tests/Types/Audit/ covering codec round-trip, byte-determinism
(hand-pinned expected JSON string), null/empty sentinel, full projection table,
InboundAuthFailure-Denied precedence, and Action/Category builders. All pass.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge domain fields that travel inside <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// All optional. <c>Channel</c>, <c>Kind</c>, and <c>Status</c> carry the string
|
||||
/// representations of the corresponding ScadaBridge enums for stable JSON serialization.
|
||||
/// <c>ForwardState</c> is deliberately absent — it remains a site-sidecar concern.
|
||||
/// Serialized and deserialized by <see cref="AuditDetailsCodec"/>.
|
||||
/// Property declaration order is load-bearing: <see cref="AuditDetailsCodec"/> relies on it for
|
||||
/// byte-deterministic output (System.Text.Json respects declared order).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C1 of the ScadaBridge audit re-architecture (Task 2.5). Pure types only.
|
||||
/// </remarks>
|
||||
public sealed record AuditDetails
|
||||
{
|
||||
/// <summary>Top-level audit channel (<c>AuditChannel.ToString()</c>).</summary>
|
||||
public string? Channel { get; init; }
|
||||
|
||||
/// <summary>Specific event kind (<c>AuditKind.ToString()</c>).</summary>
|
||||
public string? Kind { get; init; }
|
||||
|
||||
/// <summary>Lifecycle status (<c>AuditStatus.ToString()</c>).</summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>Execution / operation identifier for the audited call.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>Parent execution identifier for nested / cached operations.</summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site identifier where the event originated.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
/// <summary>Instance (Galaxy instance / target resource) identifier.</summary>
|
||||
public string? SourceInstanceId { get; init; }
|
||||
|
||||
/// <summary>Script name or endpoint that initiated the audited action.</summary>
|
||||
public string? SourceScript { get; init; }
|
||||
|
||||
/// <summary>HTTP status code returned by the outbound or inbound call.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>Elapsed duration of the audited action in milliseconds.</summary>
|
||||
public int? DurationMs { get; init; }
|
||||
|
||||
/// <summary>Short error message when the action failed.</summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>Full error detail / stack excerpt when the action failed.</summary>
|
||||
public string? ErrorDetail { get; init; }
|
||||
|
||||
/// <summary>Condensed request representation (URL, method, or payload summary).</summary>
|
||||
public string? RequestSummary { get; init; }
|
||||
|
||||
/// <summary>Condensed response representation (status + body summary).</summary>
|
||||
public string? ResponseSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> if any string field in this record was truncated before serialization.
|
||||
/// Included in JSON even when <c>false</c> (non-nullable bool, never null-omitted).
|
||||
/// </summary>
|
||||
public bool PayloadTruncated { get; init; }
|
||||
|
||||
/// <summary>Freeform extension JSON for fields not covered above.</summary>
|
||||
public string? Extra { get; init; }
|
||||
|
||||
/// <summary>UTC instant when the central AuditLog store ingested this event.</summary>
|
||||
public DateTimeOffset? IngestedAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes and deserializes <see cref="AuditDetails"/> to/from the JSON string that
|
||||
/// occupies <c>ZB.MOM.WW.Audit.AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The options are DETERMINISTIC by design — this is a load-bearing contract:
|
||||
/// canonical <c>AuditEvent</c> uses value-equality (record) and consumers dedup on it, so
|
||||
/// two independent emitters must produce byte-identical <c>DetailsJson</c> for equal inputs.
|
||||
/// The options that make this deterministic:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="JsonNamingPolicy.CamelCase"/> — stable camelCase key names.</item>
|
||||
/// <item><c>WriteIndented = false</c> — no whitespace variation.</item>
|
||||
/// <item><see cref="JsonIgnoreCondition.WhenWritingNull"/> — null fields absent; non-nullable
|
||||
/// <c>bool</c> fields (like <see cref="AuditDetails.PayloadTruncated"/>) always present.</item>
|
||||
/// <item><see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> — angle brackets and other
|
||||
/// extended characters (including <c><redacted></c> markers) are NOT percent-escaped,
|
||||
/// preserving the exact byte value of any existing redaction strings.</item>
|
||||
/// <item>Property declaration order on <see cref="AuditDetails"/> fixes key order —
|
||||
/// System.Text.Json honours declared order, so serialization is stable across calls.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditDetailsCodec
|
||||
{
|
||||
/// <summary>
|
||||
/// Single, cached, immutable options instance. Re-using one instance avoids repeated
|
||||
/// reflection overhead and guarantees the same encoder is used across all serialization calls.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes <paramref name="details"/> to a compact, deterministic JSON string
|
||||
/// suitable for storage in <c>AuditEvent.DetailsJson</c>.
|
||||
/// </summary>
|
||||
public static string Serialize(AuditDetails details)
|
||||
=> JsonSerializer.Serialize(details, Options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a <see cref="AuditDetails"/> from <paramref name="json"/>.
|
||||
/// Returns an empty (all-null) <see cref="AuditDetails"/> when <paramref name="json"/>
|
||||
/// is <c>null</c>, empty, or whitespace — never throws.
|
||||
/// </summary>
|
||||
public static AuditDetails Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new AuditDetails();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditDetails>(json, Options)
|
||||
?? new AuditDetails();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new AuditDetails();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical <c>Action</c> and <c>Category</c> fields for
|
||||
/// <c>ZB.MOM.WW.Audit.AuditEvent</c> from ScadaBridge's channel + kind enums.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildAction"/> produces <c>"{channel}.{kind}"</c> — a stable dot-separated
|
||||
/// identifier that maps directly onto the canonical <c>Action</c> field.</item>
|
||||
/// <item><see cref="BuildCategory"/> produces <c>channel.ToString()</c> — a grouping token
|
||||
/// that maps onto the canonical <c>Category</c> field.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditFieldBuilders
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Action</c> string: <c>"{channel}.{kind}"</c>.
|
||||
/// </summary>
|
||||
public static string BuildAction(AuditChannel channel, AuditKind kind)
|
||||
=> $"{channel}.{kind}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical <c>Category</c> string: the channel name.
|
||||
/// </summary>
|
||||
public static string BuildCategory(AuditChannel channel)
|
||||
=> channel.ToString();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the ScadaBridge (<see cref="AuditStatus"/>, <see cref="AuditKind"/>) pair onto
|
||||
/// the canonical <see cref="AuditOutcome"/> required by <c>ZB.MOM.WW.Audit.AuditEvent</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Projection table:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AuditKind.InboundAuthFailure"/> → <see cref="AuditOutcome.Denied"/>
|
||||
/// (checked <b>first</b>, overrides any status).</item>
|
||||
/// <item><see cref="AuditStatus.Delivered"/> → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// <item><see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
|
||||
/// <see cref="AuditStatus.Discarded"/> → <see cref="AuditOutcome.Failure"/>.</item>
|
||||
/// <item>All other statuses (<c>Submitted</c>, <c>Forwarded</c>, <c>Attempted</c>,
|
||||
/// <c>Skipped</c>) → <see cref="AuditOutcome.Success"/>.</item>
|
||||
/// </list>
|
||||
/// <para>C1 of the ScadaBridge audit re-architecture (Task 2.5).</para>
|
||||
/// </remarks>
|
||||
public static class AuditOutcomeProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects <paramref name="status"/> + <paramref name="kind"/> onto the canonical
|
||||
/// <see cref="AuditOutcome"/>.
|
||||
/// </summary>
|
||||
public static AuditOutcome Project(AuditStatus status, AuditKind kind)
|
||||
{
|
||||
// Auth-failure kind takes absolute precedence — checked before any status rule.
|
||||
if (kind == AuditKind.InboundAuthFailure)
|
||||
return AuditOutcome.Denied;
|
||||
|
||||
return status switch
|
||||
{
|
||||
AuditStatus.Delivered => AuditOutcome.Success,
|
||||
AuditStatus.Failed => AuditOutcome.Failure,
|
||||
AuditStatus.Parked => AuditOutcome.Failure,
|
||||
AuditStatus.Discarded => AuditOutcome.Failure,
|
||||
// Submitted / Forwarded / Attempted / Skipped → in-progress or short-circuit → Success
|
||||
_ => AuditOutcome.Success,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,8 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.Audit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user