feat(audit): M5.6 SourceNode sentinel backfill (purge-role) + CLI + runbook note (T5)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user