feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade)

This commit is contained in:
Joseph Doherty
2026-05-23 16:50:16 -04:00
parent 8fb9eb0ce7
commit f3cb8c0791
2 changed files with 202 additions and 25 deletions

View File

@@ -100,6 +100,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceNode TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
@@ -144,6 +145,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// so it is ALTER-ed in here. Nullable with no default — rows written
// before this migration read back ParentExecutionId = null.
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
// ParentExecutionId above. A deployment that already ran the
// ParentExecutionId branch has an auditlog.db with the 22-column
// schema and no SourceNode column; CREATE TABLE IF NOT EXISTS cannot
// add it, so it is ALTER-ed in here. Nullable with no default — rows
// written before this migration read back SourceNode = null.
AddColumnIfMissing("SourceNode", "TEXT NULL");
}
/// <summary>
@@ -270,13 +279,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
cmd.CommandText = """
INSERT INTO AuditLog (
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$SourceSiteId, $SourceNode, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId, $ParentExecutionId
@@ -289,6 +298,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text);
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
@@ -315,6 +325,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pKind.Value = e.Kind.ToString();
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
pSourceNode.Value = (object?)e.SourceNode ?? DBNull.Value;
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
pActor.Value = (object?)e.Actor ?? DBNull.Value;
@@ -386,7 +397,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
@@ -435,7 +446,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
@@ -522,7 +533,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
@@ -688,22 +699,23 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
SourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceScript = reader.IsDBNull(7) ? null : reader.GetString(7),
Actor = reader.IsDBNull(8) ? null : reader.GetString(8),
Target = reader.IsDBNull(9) ? null : reader.GetString(9),
Status = Enum.Parse<AuditStatus>(reader.GetString(10)),
HttpStatus = reader.IsDBNull(11) ? null : reader.GetInt32(11),
DurationMs = reader.IsDBNull(12) ? null : reader.GetInt32(12),
ErrorMessage = reader.IsDBNull(13) ? null : reader.GetString(13),
ErrorDetail = reader.IsDBNull(14) ? null : reader.GetString(14),
RequestSummary = reader.IsDBNull(15) ? null : reader.GetString(15),
ResponseSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
PayloadTruncated = reader.GetInt32(17) != 0,
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
PayloadTruncated = reader.GetInt32(18) != 0,
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
};
}

View File

@@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId()
public void Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId));
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_23Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
@@ -59,12 +59,12 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(22, columns.Count);
Assert.Equal(23, columns.Count);
var expected = new[]
{
"EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId",
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
"SourceSiteId", "SourceNode", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ExecutionId", "ParentExecutionId",
@@ -78,6 +78,19 @@ public class SqliteAuditWriterSchemaTests
}
}
[Fact]
public void Initialize_creates_AuditLog_with_SourceNode_column()
{
var (writer, dataSource) = CreateWriter(nameof(Initialize_creates_AuditLog_with_SourceNode_column));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
Assert.True(
ColumnExists(connection, "SourceNode"),
"Fresh AuditLog schema must include the SourceNode column.");
}
}
[Fact]
public void Opens_Creates_IX_ForwardState_Occurred_Index()
{
@@ -377,4 +390,156 @@ public class SqliteAuditWriterSchemaTests
Assert.Null(row.ParentExecutionId);
}
}
// ----- SourceNode schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-SourceNode <c>AuditLog</c> schema — the 22-column CREATE TABLE
/// that HAS <c>ExecutionId</c> + <c>ParentExecutionId</c> but is WITHOUT
/// <c>SourceNode</c>. A deployment that ran the ParentExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreSourceNodeSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-SourceNode 22-column
/// schema and returns the open connection. The connection MUST stay open for
/// the lifetime of the test — a shared-cache in-memory database is dropped
/// once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreSourceNodeSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreSourceNodeSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
{
var dataSource = $"file:{nameof(Initialize_adds_SourceNode_to_pre_existing_schema)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ParentExecutionId branch: auditlog.db
// already exists with the 22-column schema and NO SourceNode column.
using var seedConnection = SeedPreSourceNodeSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
Assert.False(ColumnExists(seedConnection, "SourceNode"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing SourceNode column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "SourceNode"),
"SqliteAuditWriter must ALTER the SourceNode column into a pre-existing AuditLog table.");
// A WriteAsync binding $SourceNode must now succeed and round-trip;
// without the ALTER it would fail with "no such column: SourceNode"
// and — because audit writes are best-effort — silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
SourceNode = "node-a",
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees SourceNode already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "SourceNode"));
}
}
[Fact]
public async Task WriteAsync_persists_SourceNode_field()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_SourceNode_field));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
SourceNode = "node-a",
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal("node-a", row.SourceNode);
}
}
[Fact]
public async Task WriteAsync_persists_null_SourceNode()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_persists_null_SourceNode));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
Status = AuditStatus.Submitted,
PayloadTruncated = false,
// SourceNode left null
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.SourceNode);
}
}
}