6dea84cd28
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.
174 lines
6.6 KiB
C#
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;
|
|
}
|
|
}
|