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

@@ -1077,6 +1077,59 @@ public class AuthorizationPolicyTests
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
// ─────────────────────────────────────────────────────────────────────
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15).
// Default mapping (see AuthorizationPolicies XML doc):
// Admin → OperationalAudit + AuditExport
// Audit → OperationalAudit + AuditExport
// AuditReadOnly → OperationalAudit only
// Design → neither
// Deployment → neither
// ─────────────────────────────────────────────────────────────────────
[Theory]
[InlineData("Admin")]
[InlineData("Audit")]
[InlineData("AuditReadOnly")]
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
}
[Theory]
[InlineData("Design")]
[InlineData("Deployment")]
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
}
[Theory]
[InlineData("Admin")]
[InlineData("Audit")]
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
{
var principal = CreatePrincipal(new[] { role });
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
[Theory]
[InlineData("AuditReadOnly")]
[InlineData("Design")]
[InlineData("Deployment")]
public async Task AuditExportPolicy_UngrantedRoles_Fail(string role)
{
// AuditReadOnly is the load-bearing case: it grants OperationalAudit
// (read) but NOT AuditExport (bulk export) — the split that lets a
// triage operator drill in without exfiltrating the table.
var principal = CreatePrincipal(new[] { role });
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
[Fact]