Files
ScadaBridge/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
T

183 lines
7.3 KiB
C#

using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Security;
namespace ScadaLink.CentralUI.Audit;
/// <summary>
/// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI ships no MVC controllers (see <see cref="ScadaLink.CentralUI.Auth.AuthEndpoints"/>
/// and <see cref="ScadaLink.CentralUI.ScriptAnalysis.ScriptAnalysisEndpoints"/>),
/// so the brief's "controller" is implemented as a minimal-API endpoint instead.
/// The endpoint streams to <c>Response.Body</c> directly so the export does NOT
/// buffer the full result set in memory — see
/// <see cref="IAuditLogExportService.ExportAsync"/>.
/// </para>
///
/// <para>
/// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/>
/// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export
/// permission can pull a CSV — the page-level
/// <see cref="AuthorizationPolicies.OperationalAudit"/> gate is read-only
/// and intentionally narrower. The query-string parser silently drops
/// unrecognised values to match the page-level parser in
/// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
/// the same "no constraint" outcome rather than a 400.
/// </para>
/// </summary>
public static class AuditExportEndpoints
{
/// <summary>
/// Default row cap for a single export. Large enough to satisfy realistic
/// operator workflows; mirrors the brief's recommended ceiling. Operators
/// who need more should fall back to the CLI (footnote rendered in the
/// cap-footer line).
/// </summary>
public const int DefaultMaxRows = 100_000;
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
.RequireAuthorization(AuthorizationPolicies.AuditExport);
return endpoints;
}
/// <summary>
/// Handles <c>GET /api/centralui/audit/export</c>. Internal so endpoint
/// tests can call it directly when desirable; the live wire-up goes
/// through the minimal-API map above.
/// </summary>
internal static async Task HandleExportAsync(HttpContext context, IAuditLogExportService exportService)
{
var filter = ParseFilter(context.Request.Query);
var maxRows = ParseMaxRows(context.Request.Query);
// Stamp the response headers BEFORE the first body write so the client
// sees text/csv + an attachment download right away.
var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv";
context.Response.ContentType = "text/csv; charset=utf-8";
context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
// Defeat any intermediate buffering proxy so the operator sees rows
// streaming through as the server flushes each repository page.
context.Response.Headers["Cache-Control"] = "no-store";
await exportService.ExportAsync(filter, maxRows, context.Response.Body, context.RequestAborted);
}
/// <summary>
/// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
/// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c> dimensions are
/// multi-value: a repeated query param yields a multi-element filter list, a
/// single param a one-element list. Unknown enum names / un-parseable Guids /
/// dates are silently dropped (same lax contract as
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
/// a repeated set is dropped, not the whole set.
/// </summary>
/// <remarks>
/// This endpoint reads the source-site filter from the <c>site</c> query key,
/// whereas the ManagementService export endpoint reads it as
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
/// </remarks>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
string? target = TrimToNullable(query, "target");
string? actor = TrimToNullable(query, "actor");
Guid? correlationId = null;
if (query.TryGetValue("correlationId", out var corrValues)
&& Guid.TryParse(corrValues.ToString(), out var parsedCorr))
{
correlationId = parsedCorr;
}
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channels: channels,
Kinds: kinds,
Statuses: statuses,
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}
/// <summary>
/// Optional <c>maxRows=</c> query-string override. Falls back to
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
/// value rather than erroring — same lax contract as the rest of the
/// query parser.
/// </summary>
private static int ParseMaxRows(IQueryCollection query)
{
if (query.TryGetValue("maxRows", out var raw)
&& int.TryParse(raw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
&& parsed > 0)
{
return parsed;
}
return DefaultMaxRows;
}
private static string? TrimToNullable(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
var v = values.ToString();
return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
}
private static DateTime? ParseUtcDate(IQueryCollection query, string key)
{
if (!query.TryGetValue(key, out var values))
{
return null;
}
if (DateTime.TryParse(
values.ToString(),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var parsed))
{
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
return null;
}
}