183 lines
7.3 KiB
C#
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;
|
|
}
|
|
}
|