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>
/// ///
/// <para> /// <para>
/// The route is admin-gated to mirror the NavMenu (<c>RequireAdmin</c> wraps /// The route is gated on the <see cref="AuthorizationPolicies.AuditExport"/>
/// the Audit section). The query-string parser silently drops unrecognised /// policy (#23 M7-T15 / Bundle G) so only roles with the bulk-export
/// values to match the page-level parser in /// 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 /// <c>AuditLogPage.ApplyQueryStringFilters</c> — an unknown enum value yields
/// the same "no constraint" outcome rather than a 400. /// the same "no constraint" outcome rather than a 400.
/// </para> /// </para>
@@ -43,7 +46,7 @@ public static class AuditExportEndpoints
public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapAuditExportEndpoints(this IEndpointRouteBuilder endpoints)
{ {
endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync) endpoints.MapGet("/api/centralui/audit/export", HandleExportAsync)
.RequireAuthorization(AuthorizationPolicies.RequireAdmin); .RequireAuthorization(AuthorizationPolicies.AuditExport);
return endpoints; return endpoints;
} }

View File

@@ -108,11 +108,15 @@
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@* Audit — Admin only. Hosts the new Audit Log page (#23 M7) @* Audit — gated on the OperationalAudit policy (#23 M7-T15
and the renamed Configuration Audit Log (IAuditService / Bundle G). Hosts the new Audit Log page (#23 M7) and
config-change viewer). Both items are Admin-gated, so the renamed Configuration Audit Log (IAuditService
the section header sits inside the same policy block. *@ config-change viewer). Both items share the same gate,
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> 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"> <Authorized Context="auditContext">
<div role="presentation" class="nav-section-header">Audit</div> <div role="presentation" class="nav-section-header">Audit</div>
<li class="nav-item"> <li class="nav-item">

View File

@@ -1,9 +1,10 @@
@page "/audit/log" @page "/audit/log"
@attribute [Authorize] @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ScadaLink.CentralUI.Components.Audit @using ScadaLink.CentralUI.Components.Audit
@using ScadaLink.CentralUI.Services @using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Entities.Audit @using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Types.Audit @using ScadaLink.Commons.Types.Audit
@using ScadaLink.Security
@inject IAuditLogQueryService AuditLogQueryService @inject IAuditLogQueryService AuditLogQueryService
<PageTitle>Audit Log</PageTitle> <PageTitle>Audit Log</PageTitle>
@@ -24,16 +25,26 @@
SignalR-driven download because the request can stream 100k rows directly SignalR-driven download because the request can stream 100k rows directly
to the response body without buffering through the Blazor circuit. The to the response body without buffering through the Blazor circuit. The
href reflects the most recently applied filter; before Apply is clicked, href reflects the most recently applied filter; before Apply is clicked,
an unconstrained export is exposed. *@ an unconstrained export is exposed.
<div class="mb-3 d-flex justify-content-end">
<a class="btn btn-outline-secondary btn-sm" Bundle G (#23 M7-T15) gates the button on the AuditExport policy so an
href="@ExportUrl" OperationalAudit-only operator (read access without bulk export) sees the
download page + filters but cannot trigger the CSV pull. The endpoint itself is
role="button" gated separately, so a hand-crafted URL still 403s — the AuthorizeView
aria-label="Export current view to CSV"> here is the user-facing affordance, not the authoritative check. *@
Export CSV <AuthorizeView Policy="@AuthorizationPolicies.AuditExport">
</a> <Authorized Context="exportContext">
</div> <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 @* 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 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.CentralUI.Components
@using ScadaLink.Commons.Entities.Audit @using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] @attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@inject ICentralUiRepository CentralUiRepository @inject ICentralUiRepository CentralUiRepository
@inject IJSRuntime JS @inject IJSRuntime JS

View File

@@ -3,12 +3,98 @@ using Microsoft.Extensions.DependencyInjection;
namespace ScadaLink.Security; 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 static class AuthorizationPolicies
{ {
public const string RequireAdmin = "RequireAdmin"; public const string RequireAdmin = "RequireAdmin";
public const string RequireDesign = "RequireDesign"; public const string RequireDesign = "RequireDesign";
public const string RequireDeployment = "RequireDeployment"; 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) public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
{ {
services.AddAuthorization(options => services.AddAuthorization(options =>
@@ -21,6 +107,19 @@ public static class AuthorizationPolicies
options.AddPolicy(RequireDeployment, policy => options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment")); 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>(); services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();

View File

@@ -71,18 +71,18 @@ public class AuditExportEndpointsTests
web.ConfigureServices(services => web.ConfigureServices(services =>
{ {
services.AddRouting(); services.AddRouting();
// The endpoint is admin-gated; the tests run as // The endpoint is AuditExport-gated (#23 M7-T15 Bundle G);
// pre-authenticated principals built by FakeAuthHandler // the tests run as pre-authenticated principals built by
// (everyone has the Admin role) so the RequireAdmin policy // FakeAuthHandler (everyone has the Admin role), which is
// succeeds. // one of AuditExportRoles, so the policy succeeds.
services.AddAuthentication(FakeAuthHandler.SchemeName) services.AddAuthentication(FakeAuthHandler.SchemeName)
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>( .AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, _ => { }); FakeAuthHandler.SchemeName, _ => { });
services.AddAuthorization(options => // Use the real production policy wiring so the endpoint's
{ // updated AuditExport gate (#23 M7-T15 Bundle G) is what
options.AddPolicy(AuthorizationPolicies.RequireAdmin, policy => // the tests exercise. The fake principal carries the
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin")); // "Admin" role, which AuditExportRoles permits.
}); services.AddScadaLinkAuthorization();
services.AddSingleton(repo); services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>(); services.AddScoped<IAuditLogExportService, AuditLogExportService>();
}); });
@@ -224,8 +224,8 @@ public class AuditExportEndpointsTests
/// <summary> /// <summary>
/// Test-only authentication handler that signs every request in as an Admin. /// Test-only authentication handler that signs every request in as an Admin.
/// Lets the endpoint's <c>RequireAdmin</c> policy pass without spinning up /// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
/// the real cookie + LDAP pipeline. /// passes without spinning up the real cookie + LDAP pipeline.
/// </summary> /// </summary>
private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions> private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{ {

View File

@@ -0,0 +1,323 @@
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Bunit;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.CentralUI.Audit;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Security;
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
///
/// <para>
/// Bundle G introduces two new policies:
/// <list type="bullet">
/// <item><c>OperationalAudit</c> — read access to the Audit Log page +
/// Configuration Audit Log page + nav group.</item>
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
/// the streaming export endpoint.</item>
/// </list>
/// Both policies are satisfied by the <c>Audit</c> role and (defence in depth)
/// the <c>Admin</c> role — admins see everything by convention in this
/// codebase. The tests pin both the page-level + endpoint-level enforcement,
/// and the Export-button visibility split.
/// </para>
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private void WireUpPageDependencies()
{
// The page hosts AuditFilterBar + AuditResultsGrid which depend on
// ISiteRepository and IAuditLogQueryService — provide stand-ins so
// a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
}
private IRenderedComponent<AuditLogPage> RenderAuditLogPage(params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
WireUpPageDependencies();
// Page-level [Authorize(Policy=...)] is enforced by the router in a
// live app. bUnit renders the component directly, so we wrap the
// page in a CascadingAuthenticationState so the in-page
// AuthorizeView for the Export button can read the principal.
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<AuditLogPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<AuditLogPage>();
}
// ─────────────────────────────────────────────────────────────────────
// Test 1: WithoutOperationalAudit_PageReturns403_OrHidden
// ─────────────────────────────────────────────────────────────────────
//
// Page-level enforcement is the [Authorize(Policy = "OperationalAudit")]
// attribute on the .razor page. We can't easily smoke-test routing here,
// so we verify the attribute is present + the policy denies a principal
// that holds none of the permitting roles.
[Fact]
public async Task WithoutOperationalAudit_PolicyDenies()
{
// A Design-only user (no Audit, no Admin) must NOT satisfy the
// OperationalAudit policy.
var services = new ServiceCollection();
services.AddLogging();
services.AddScadaLinkAuthorization();
using var provider = services.BuildServiceProvider();
var authService = provider.GetRequiredService<IAuthorizationService>();
var principal = BuildPrincipal("Design");
var result = await authService.AuthorizeAsync(
principal, null, AuthorizationPolicies.OperationalAudit);
Assert.False(result.Succeeded);
}
[Fact]
public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
// Sanity-pin the attribute so the page-level gate can't regress to
// [Authorize] (any-authenticated) by accident.
var attributes = typeof(AuditLogPage)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
[Fact]
public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
// ConfigurationAuditLog mirrors the gate — both Audit-group pages
// share the OperationalAudit permission so the nav-group policy
// remains coherent with the per-page gates.
var configType = typeof(ScadaLink.CentralUI.Components.Pages.Audit.ConfigurationAuditLog);
var attributes = configType
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
// ─────────────────────────────────────────────────────────────────────
// Test 2 + 3: Export button visibility split.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
{
// The "Audit" role grants OperationalAudit + AuditExport in the
// default mapping, so we test the split by handing the user ONLY
// an extra-narrow role that we map ONLY to OperationalAudit: a
// fresh "AuditReadOnly" role (see AuthorizationPolicies).
var cut = RenderAuditLogPage("AuditReadOnly");
cut.WaitForAssertion(() =>
{
// The page rendered (heading + container present) but the
// Export-CSV anchor is gone because AuditExport is denied.
Assert.Contains("Audit Log", cut.Markup);
Assert.DoesNotContain("Export CSV", cut.Markup);
});
}
[Fact]
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
{
var cut = RenderAuditLogPage("Audit");
cut.WaitForAssertion(() =>
{
Assert.Contains("Audit Log", cut.Markup);
Assert.Contains("Export CSV", cut.Markup);
});
}
[Fact]
public void AdminUser_SeesPage_AndExportButton()
{
// Admin holds every permission by convention — both policies must
// succeed for a plain Admin user.
var cut = RenderAuditLogPage("Admin");
cut.WaitForAssertion(() =>
{
Assert.Contains("Audit Log", cut.Markup);
Assert.Contains("Export CSV", cut.Markup);
});
}
// ─────────────────────────────────────────────────────────────────────
// Test 4 + 5: Endpoint-level enforcement.
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
{
// A user holding only Design must NOT be able to call the export
// endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
{
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_AdminAlone_Returns200()
{
// Admin alone (no Audit role) must still pass — defence in depth.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
[Fact]
public async Task AuditExportEndpoint_AuditReadOnly_Returns403()
{
// AuditReadOnly grants OperationalAudit but NOT AuditExport, so the
// endpoint must refuse — the page is readable but the bulk export
// path is gated separately.
var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" });
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// Helper: tiny in-process host with the real AuthorizationPolicies.
// ─────────────────────────────────────────────────────────────────────
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildEndpointHostAsync(
string[] roles)
{
var repo = Substitute.For<IAuditLogRepository>();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()),
Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddAuthentication(FakeAuthHandler.SchemeName)
.AddScheme<FakeAuthHandlerOptions, FakeAuthHandler>(
FakeAuthHandler.SchemeName, opts => opts.Roles = roles);
// Real policies — the whole point of these tests is to
// exercise the production AddScadaLinkAuthorization wiring.
services.AddScadaLinkAuthorization();
services.AddSingleton(repo);
services.AddScoped<IAuditLogExportService, AuditLogExportService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapAuditExportEndpoints();
});
});
});
var host = await hostBuilder.StartAsync();
var client = host.GetTestClient();
return (client, repo, host);
}
/// <summary>
/// Test-only authentication handler that signs every request in with
/// the configured set of roles.
/// </summary>
private sealed class FakeAuthHandler : AuthenticationHandler<FakeAuthHandlerOptions>
{
public const string SchemeName = "FakeAuth";
public FakeAuthHandler(
IOptionsMonitor<FakeAuthHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
foreach (var role in Options.Roles)
{
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
}
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
{
public string[] Roles { get; set; } = Array.Empty<string>();
}
}

View File

@@ -61,7 +61,19 @@ public class AuditLogPageScaffoldTests : BunitContext
nav.NavigateTo($"/audit/log?{query}"); nav.NavigateTo($"/audit/log?{query}");
} }
return Render<AuditLogPage>(); // Bundle G (#23 M7-T15): the page now hosts an in-component
// AuthorizeView around the Export-CSV button, so the page MUST
// render inside a CascadingAuthenticationState. The router supplies
// this in production; bUnit hosts the page directly so we wrap it
// here.
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<AuditLogPage>(0);
builder.CloseComponent();
})));
return host.FindComponent<AuditLogPage>();
} }
private IRenderedComponent<NavMenu> RenderNavMenu(params string[] roles) private IRenderedComponent<NavMenu> RenderNavMenu(params string[] roles)

View File

@@ -1077,6 +1077,59 @@ public class AuthorizationPolicyTests
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, 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] [Fact]