feat(audit): M5.6 SourceNode sentinel backfill (purge-role) + CLI + runbook note (T5)

This commit is contained in:
Joseph Doherty
2026-06-16 22:02:21 -04:00
parent de2968b03d
commit 55630b48b6
12 changed files with 1399 additions and 10 deletions
@@ -89,9 +89,16 @@ public static class AuditEndpoints
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Default sentinel written by the backfill endpoint when the caller omits <c>sentinel</c>.</summary>
public const string DefaultBackfillSentinel = "unknown";
/// <summary>Default batch size for the backfill endpoint when the caller omits <c>batchSize</c>.</summary>
public const int DefaultBackfillBatchSize = 5000;
/// <summary>
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>, and
/// <c>/api/audit/tree</c> minimal-API endpoints.
/// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>,
/// <c>/api/audit/tree</c>, and <c>POST /api/audit/backfill-source-node</c>
/// minimal-API endpoints.
/// </summary>
/// <param name="endpoints">The endpoint route builder to register routes on.</param>
/// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
@@ -100,6 +107,7 @@ public static class AuditEndpoints
endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
endpoints.MapGet("/api/audit/tree", (Delegate)HandleTree);
endpoints.MapPost("/api/audit/backfill-source-node", (Delegate)HandleBackfillSourceNode);
return endpoints;
}
@@ -279,6 +287,136 @@ public static class AuditEndpoints
return Results.Json(nodes, JsonOptions);
}
// ─────────────────────────────────────────────────────────────────────
// POST /api/audit/backfill-source-node
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Handles <c>POST /api/audit/backfill-source-node</c>: authenticates (Admin role
/// required), reads the JSON body for <c>sentinel</c> / <c>before</c> /
/// <c>batchSize</c>, and calls
/// <see cref="IAuditLogRepository.BackfillSourceNodeAsync"/> on the maintenance
/// path.
///
/// <para>
/// <b>Auth.</b> Admin-only — backfilling the SourceNode column is a one-time ops
/// procedure that mutates the AuditLog table via the maintenance path (NOT the
/// append-only writer role). Restricted to <see cref="AuthorizationPolicies.AuditExportRoles"/>
/// (Administrator) so it is never accessible to Viewer-role users.
/// </para>
///
/// <para>
/// <b>Request body.</b>
/// <code>
/// {
/// "sentinel": "unknown", // optional; default "unknown"
/// "before": "2026-01-01T00:00:00Z", // required ISO-8601 UTC
/// "batchSize": 5000 // optional; default 5000
/// }
/// </code>
/// </para>
///
/// <para>
/// <b>Response (200).</b>
/// <code>{ "rowsUpdated": 12345, "sentinel": "unknown", "before": "2026-01-01T00:00:00Z" }</code>
/// </para>
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <returns>A task that resolves to the HTTP result (200 JSON, 400, 401, or 403).</returns>
internal static async Task<IResult> HandleBackfillSourceNode(HttpContext context)
{
var auth = await AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
// Admin-only: backfilling is a one-time ops procedure on the maintenance path.
if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
{
return Forbidden("Administrator");
}
string bodyText;
try
{
using var reader = new System.IO.StreamReader(context.Request.Body);
bodyText = await reader.ReadToEndAsync(context.RequestAborted);
}
catch (OperationCanceledException)
{
return Results.Json(new { error = "Request cancelled.", code = "CANCELLED" }, statusCode: 499);
}
string sentinel = DefaultBackfillSentinel;
DateTime? beforeUtc = null;
int batchSize = DefaultBackfillBatchSize;
if (!string.IsNullOrWhiteSpace(bodyText))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(bodyText);
var root = doc.RootElement;
if (root.TryGetProperty("sentinel", out var sentinelEl))
{
var s = sentinelEl.GetString();
if (!string.IsNullOrWhiteSpace(s))
{
sentinel = s.Trim();
}
}
if (root.TryGetProperty("before", out var beforeEl))
{
if (DateTime.TryParse(
beforeEl.GetString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var parsed))
{
beforeUtc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
else
{
return Results.Json(
new { error = "Invalid 'before' value; expected ISO-8601 UTC datetime.", code = "BAD_REQUEST" },
statusCode: 400);
}
}
if (root.TryGetProperty("batchSize", out var batchEl) && batchEl.TryGetInt32(out var b) && b > 0)
{
batchSize = b;
}
}
catch (System.Text.Json.JsonException)
{
return Results.Json(
new { error = "Request body must be valid JSON.", code = "BAD_REQUEST" },
statusCode: 400);
}
}
if (beforeUtc is null)
{
return Results.Json(
new { error = "Required field 'before' (ISO-8601 UTC datetime) is missing.", code = "BAD_REQUEST" },
statusCode: 400);
}
var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
var rowsUpdated = await repo.BackfillSourceNodeAsync(sentinel, beforeUtc.Value, batchSize, context.RequestAborted);
return Results.Json(new
{
rowsUpdated,
sentinel,
before = beforeUtc.Value.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
}, JsonOptions);
}
/// <summary>
/// Streams every matching row as RFC 4180 CSV, paging the repository with its
/// keyset cursor and flushing after each page so a large export starts