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:
Joseph Doherty
2026-05-20 21:09:42 -04:00
parent 8744630adb
commit 6dea84cd28
9 changed files with 538 additions and 33 deletions

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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

View File

@@ -3,12 +3,98 @@ using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.Security;
/// <summary>
/// Centralised authorization policy names + the role→permission mapping
/// that defines them.
///
/// <para>
/// The codebase uses a thin role-claim model: each policy expresses a
/// permission, satisfied when the principal carries any role claim
/// (<see cref="JwtTokenService.RoleClaimType"/>) that maps to that
/// permission. Role names are free strings configured via
/// <see cref="ScadaLink.Commons.Entities.Security.LdapGroupMapping"/> rows
/// (see <see cref="RoleMapper"/>) — there is no permission claim, just a
/// fan-out from role to allowed policies.
/// </para>
///
/// <para>
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
/// <list type="table">
/// <listheader>
/// <term>Role</term>
/// <description>Policies granted</description>
/// </listheader>
/// <item>
/// <term><c>Admin</c></term>
/// <description><see cref="RequireAdmin"/>,
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
/// every permission by convention so an Admin-only user never loses
/// access to a new surface.</description>
/// </item>
/// <item>
/// <term><c>Design</c></term>
/// <description><see cref="RequireDesign"/></description>
/// </item>
/// <item>
/// <term><c>Deployment</c></term>
/// <description><see cref="RequireDeployment"/></description>
/// </item>
/// <item>
/// <term><c>Audit</c></term>
/// <description><see cref="OperationalAudit"/>,
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
/// </item>
/// <item>
/// <term><c>AuditReadOnly</c></term>
/// <description><see cref="OperationalAudit"/> only — operators who
/// should see the Audit Log + drill in to incidents but not pull bulk
/// CSV exports. Use this when delegating triage without granting
/// forensic-export capability.</description>
/// </item>
/// </list>
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path
/// reads them whether the role is one of the four built-ins above or any
/// future addition. Adding a role here means adding the LDAP mapping row in
/// the deployment; no schema migration is needed.
/// </para>
/// </summary>
public static class AuthorizationPolicies
{
public const string RequireAdmin = "RequireAdmin";
public const string RequireDesign = "RequireDesign";
public const string RequireDeployment = "RequireDeployment";
/// <summary>
/// Read access to the Audit Log #23 surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
/// <c>Admin</c> role.
/// </summary>
public const string OperationalAudit = "OperationalAudit";
/// <summary>
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
/// <see cref="OperationalAudit"/> so a triage operator can read the
/// table without being able to exfiltrate it in bulk. Granted to the
/// <c>Audit</c> role and the <c>Admin</c> role.
/// </summary>
public const string AuditExport = "AuditExport";
/// <summary>
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
/// so the seed/docs and the policy stay in lockstep.
/// </summary>
internal static readonly string[] OperationalAuditRoles = { "Admin", "Audit", "AuditReadOnly" };
/// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
/// export permission.
/// </summary>
internal static readonly string[] AuditExportRoles = { "Admin", "Audit" };
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
{
services.AddAuthorization(options =>
@@ -21,6 +107,19 @@ public static class AuthorizationPolicies
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment"));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks
// whether *any* role claim's value is in the allowed set, so a
// user with role=Admin (and nothing else) satisfies the
// OperationalAudit policy without needing a separate Audit
// role claim.
options.AddPolicy(OperationalAudit, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
options.AddPolicy(AuditExport, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
});
services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();