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; /// /// Minimal-API endpoint hosting the Audit Log CSV export (#23 M7-T14 / Bundle F). /// /// /// CentralUI ships no MVC controllers (see /// and ), /// so the brief's "controller" is implemented as a minimal-API endpoint instead. /// The endpoint streams to Response.Body directly so the export does NOT /// buffer the full result set in memory — see /// . /// /// /// /// The route is gated on the /// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export /// permission can pull a CSV — the page-level /// gate is read-only /// and intentionally narrower. The query-string parser silently drops /// unrecognised values to match the page-level parser in /// AuditLogPage.ApplyQueryStringFilters — an unknown enum value yields /// the same "no constraint" outcome rather than a 400. /// /// public static class AuditExportEndpoints { /// /// 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). /// 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; } /// /// Handles GET /api/centralui/audit/export. Internal so endpoint /// tests can call it directly when desirable; the live wire-up goes /// through the minimal-API map above. /// 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); } /// /// Parses the query-string into an . The /// channel/kind/status/site 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 /// AuditLogPage.ApplyQueryStringFilters) — an unparseable value within /// a repeated set is dropped, not the whole set. /// /// /// This endpoint reads the source-site filter from the site query key, /// whereas the ManagementService export endpoint reads it as /// sourceSiteId. The divergence is deliberate — each endpoint matches /// its own CLI / UI URL builder — so do NOT "fix" the two to one key name. /// internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) { var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); var statuses = AuditQueryParamParsers.ParseEnumList(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); } /// /// Optional maxRows= query-string override. Falls back to /// on a missing / non-positive / unparseable /// value rather than erroring — same lax contract as the rest of the /// query parser. /// 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; } }