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.
This commit is contained in:
@@ -23,9 +23,12 @@ namespace ScadaLink.CentralUI.Audit;
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The route is admin-gated to mirror the NavMenu (<c>RequireAdmin</c> wraps
|
||||
/// the Audit section). The query-string parser silently drops unrecognised
|
||||
/// values to match the page-level parser in
|
||||
/// 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>
|
||||
@@ -43,7 +46,7 @@ public static class AuditExportEndpoints
|
||||
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
|
||||
.RequireAuthorization(AuthorizationPolicies.RequireAdmin);
|
||||
.RequireAuthorization(AuthorizationPolicies.AuditExport);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -108,11 +108,15 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Audit — Admin only. Hosts the new Audit Log page (#23 M7)
|
||||
and the renamed Configuration Audit Log (IAuditService
|
||||
config-change viewer). Both items are Admin-gated, so
|
||||
the section header sits inside the same policy block. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
||||
/ Bundle G). Hosts the new Audit Log page (#23 M7) and
|
||||
the renamed Configuration Audit Log (IAuditService
|
||||
config-change viewer). Both items share the same gate,
|
||||
so the section header sits inside the same policy block:
|
||||
a non-audit user does not even see the heading.
|
||||
OperationalAudit is satisfied by the Admin, Audit, and
|
||||
AuditReadOnly roles. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||
<Authorized Context="auditContext">
|
||||
<div role="presentation" class="nav-section-header">Audit</div>
|
||||
<li class="nav-item">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/audit/log"
|
||||
@attribute [Authorize]
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@using ScadaLink.CentralUI.Components.Audit
|
||||
@using ScadaLink.CentralUI.Services
|
||||
@using ScadaLink.Commons.Entities.Audit
|
||||
@using ScadaLink.Commons.Types.Audit
|
||||
@using ScadaLink.Security
|
||||
@inject IAuditLogQueryService AuditLogQueryService
|
||||
|
||||
<PageTitle>Audit Log</PageTitle>
|
||||
@@ -24,16 +25,26 @@
|
||||
SignalR-driven download because the request can stream 100k rows directly
|
||||
to the response body without buffering through the Blazor circuit. The
|
||||
href reflects the most recently applied filter; before Apply is clicked,
|
||||
an unconstrained export is exposed. *@
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="@ExportUrl"
|
||||
download
|
||||
role="button"
|
||||
aria-label="Export current view to CSV">
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
an unconstrained export is exposed.
|
||||
|
||||
Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an
|
||||
OperationalAudit-only operator (read access without bulk export) sees the
|
||||
page + filters but cannot trigger the CSV pull. The endpoint itself is
|
||||
gated separately, so a hand-crafted URL still 403s — the AuthorizeView
|
||||
here is the user-facing affordance, not the authoritative check. *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.AuditExport">
|
||||
<Authorized Context="exportContext">
|
||||
<div class="mb-3 d-flex justify-content-end">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="@ExportUrl"
|
||||
download
|
||||
role="button"
|
||||
aria-label="Export current view to CSV">
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
|
||||
drilldown drawer; the grid stays in "no events" mode until the user applies a
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using ScadaLink.CentralUI.Components
|
||||
@using ScadaLink.Commons.Entities.Audit
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||
@inject ICentralUiRepository CentralUiRepository
|
||||
@inject IJSRuntime JS
|
||||
|
||||
|
||||
Reference in New Issue
Block a user