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:
+9
-7
@@ -1,16 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
|
||||
/// described in alog.md §4. Column lengths/types and the five named indexes are
|
||||
/// fixed by that specification — keep this in sync with the doc.
|
||||
/// Maps the <see cref="AuditLogRow"/> persistence shape to the central <c>AuditLog</c>
|
||||
/// table described in alog.md §4. Column lengths/types and the named indexes are
|
||||
/// fixed by that specification — keep this in sync with the doc. C3 (Task 2.5) kept
|
||||
/// the table unchanged; the canonical record is mapped onto this row at the repository
|
||||
/// boundary via <c>AuditRowProjection</c>.
|
||||
/// </summary>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
||||
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditLogRow>
|
||||
{
|
||||
// SQL Server's datetime2 provider strips the DateTimeKind flag on the wire
|
||||
// (a column hydrated from the database always surfaces as
|
||||
@@ -33,9 +35,9 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
||||
: null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditEvent"/> to the model builder.</summary>
|
||||
/// <summary>Applies the EF Core type configuration for <see cref="AuditLogRow"/> to the model builder.</summary>
|
||||
/// <param name="builder">The entity type builder to configure.</param>
|
||||
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
||||
public void Configure(EntityTypeBuilder<AuditLogRow> builder)
|
||||
{
|
||||
builder.ToTable("AuditLog");
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
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.
|
||||
/// </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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All <c>*Utc</c>-suffixed <see cref="DateTime"/> properties are invariantly UTC
|
||||
/// (CLAUDE.md: "All timestamps are UTC throughout the system."). The init-setters
|
||||
/// force <see cref="DateTimeKind.Utc"/> on assignment so a value 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record AuditLogRow
|
||||
{
|
||||
/// <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.</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.</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.</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; }
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b =>
|
||||
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities.AuditLogRow", b =>
|
||||
{
|
||||
b.Property<Guid>("EventId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
+58
-14
@@ -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!)
|
||||
|
||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
|
||||
@@ -124,8 +125,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Audit
|
||||
/// <summary>Gets the set of audit log entries.</summary>
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
/// <summary>Gets the set of audit logs.</summary>
|
||||
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
||||
/// <summary>Gets the set of audit log rows (central <c>dbo.AuditLog</c> persistence shape; mapped to/from the canonical record at the repository boundary).</summary>
|
||||
public DbSet<AuditLogRow> AuditLogs => Set<AuditLogRow>();
|
||||
/// <summary>Gets the set of site calls.</summary>
|
||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user