feat(auditlog): ExecutionId column on AuditEvent + central AuditLog

This commit is contained in:
Joseph Doherty
2026-05-21 14:43:35 -04:00
parent 4002f4197b
commit fd12021984
9 changed files with 1754 additions and 7 deletions

View File

@@ -26,6 +26,13 @@ public sealed record AuditEvent
/// <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>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }

View File

@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another.
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
/// dimensions constrain on equality when set.
/// </summary>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
@@ -21,5 +23,6 @@ public sealed record AuditLogQueryFilter(
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,
Guid? ExecutionId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId");
builder.HasIndex(e => e.ExecutionId)
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
/// script execution / inbound request and is distinct from the per-operation
/// <c>CorrelationId</c>.
///
/// The change is purely additive:
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
/// rows stay <c>NULL</c> (no backfill).
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExecutionId",
table: "AuditLog",
type: "uniqueidentifier",
nullable: true);
// Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ExecutionId",
table: "AuditLog");
}
}
}

View File

@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
@@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");

View File

@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
SourceSiteId, 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.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
{evt.SourceSiteId}, {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});",
@@ -157,6 +157,11 @@ VALUES
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -263,6 +268,7 @@ VALUES
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
ExecutionId uniqueidentifier NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];

View File

@@ -74,8 +74,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 21 init-only properties (alog.md §4).
Assert.Equal(21, properties.Count);
// AuditEvent record exposes 22 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column).
Assert.Equal(22, properties.Count);
}
[Fact]
@@ -90,11 +91,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C).
// index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups.
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
@@ -136,5 +139,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var targetIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
var executionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
}
}

View File

@@ -247,6 +247,34 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
}
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var executionId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
// Two rows share the ExecutionId; one carries a different ExecutionId and
// one leaves it null — both must be excluded by the single-value filter.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
}
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -725,7 +753,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? errorMessage = null) =>
string? errorMessage = null,
Guid? executionId = null) =>
new()
{
EventId = Guid.NewGuid(),
@@ -735,5 +764,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Status = status,
SourceSiteId = siteId,
ErrorMessage = errorMessage,
ExecutionId = executionId,
};
}