Files
ScadaBridge/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs
T
Joseph Doherty 6dea84cd28 feat(security): OperationalAudit + AuditExport permissions for Audit Log surface (#23 M7)
Bundle G (#23 M7-T15): replace the temporary Admin-only gate on the Audit
Log surface with two new permission policies — OperationalAudit (read) and
AuditExport (bulk-export) — so the read path and the forensic-export path
can be delegated independently.

ScadaLink.Security
- AuthorizationPolicies: add OperationalAudit + AuditExport policy
  constants; register them via RequireClaim with an explicit role allow-list
  (OperationalAuditRoles, AuditExportRoles) so the role-to-permission
  mapping is documented in one place.
- Default mapping: Admin and Audit roles grant both policies; AuditReadOnly
  grants OperationalAudit only (read access without bulk export); Design
  and Deployment grant neither.

ScadaLink.CentralUI
- AuditLogPage: switch the page-level [Authorize] to the OperationalAudit
  policy and wrap the Export-CSV button in an AuthorizeView gated on
  AuditExport so an OperationalAudit-only operator still sees the page +
  filters but cannot trigger the CSV pull.
- ConfigurationAuditLog: switch from RequireAdmin to OperationalAudit so
  both pages under the Audit nav group share the same gate.
- NavMenu: the Audit nav group now gates on OperationalAudit so the
  section header + both child links match the per-page policies.
- AuditExportEndpoints: switch RequireAuthorization from RequireAdmin to
  AuditExport — this is the authoritative gate; the AuthorizeView on the
  button is just a UX affordance.

Tests
- New AuditLogPagePermissionTests covers the 5 brief-mandated cases plus
  defence-in-depth for Admin-alone and AuditReadOnly users on the endpoint.
- SecurityTests: add policy-level coverage for the new role→permission
  matrix (Theory rows pin every role/policy combination).
- AuditExportEndpointsTests: switch to AddScadaLinkAuthorization() so the
  test host exercises the real production wiring under the new gate.
- AuditLogPageScaffoldTests: wrap the page render in a
  CascadingAuthenticationState so the new in-page AuthorizeView resolves
  the principal.
2026-05-20 21:09:42 -04:00

174 lines
6.6 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"/>.
/// Unknown enum names / un-parseable Guids / dates are silently dropped
/// (same contract as <c>AuditLogPage.ApplyQueryStringFilters</c>).
/// </summary>
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
{
AuditChannel? channel = null;
if (query.TryGetValue("channel", out var channelValues)
&& Enum.TryParse<AuditChannel>(channelValues.ToString(), ignoreCase: true, out var parsedChannel))
{
channel = parsedChannel;
}
AuditKind? kind = null;
if (query.TryGetValue("kind", out var kindValues)
&& Enum.TryParse<AuditKind>(kindValues.ToString(), ignoreCase: true, out var parsedKind))
{
kind = parsedKind;
}
AuditStatus? status = null;
if (query.TryGetValue("status", out var statusValues)
&& Enum.TryParse<AuditStatus>(statusValues.ToString(), ignoreCase: true, out var parsedStatus))
{
status = parsedStatus;
}
string? site = TrimToNullable(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;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
return new AuditLogQueryFilter(
Channel: channel,
Kind: kind,
Status: status,
SourceSiteId: site,
Target: target,
Actor: actor,
CorrelationId: correlationId,
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;
}
}