Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -22,6 +22,8 @@ public class AuthEndpointsCsrfTests
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
builder.Services.AddSingleton(TimeProvider.System);
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
@@ -23,6 +23,8 @@ public class AuthPingEndpointTests
var builder = WebApplication.CreateBuilder();
builder.Services.AddRouting();
builder.Services.AddAntiforgery();
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
builder.Services.AddSingleton(TimeProvider.System);
// Dispose the host: an undisposed WebApplication leaks its config
// PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a
// process-wide macOS run-loop thread) and a ConsoleLoggerProcessor
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class CircuitRevocationRegressionTests
{
[Fact]
public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal()
{
var initial = SessionClaimBuilder.Build("developer", "Developer", [],
new RoleMappingResult(Roles.All, [], true), DateTimeOffset.UtcNow);
var mapper = Substitute.For<IGroupRoleMapper<string>>();
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new GroupRoleMapping<string>([], Scope: new RoleMappingResult([], [], false)));
var services = new ServiceCollection().AddLogging();
services.AddSingleton(mapper);
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton(Options.Create(new SecurityOptions()));
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = true }));
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
var state = await auth.GetAuthoritativeStateAsync();
Assert.Contains(state.User.Claims, claim => claim.Type == JwtTokenService.RoleClaimType && claim.Value == Roles.Administrator);
await mapper.DidNotReceive().MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation()
{
var mapping = new RoleMappingResult([Roles.Deployer, Roles.Administrator], ["1"], false);
var mapper = Substitute.For<IGroupRoleMapper<string>>();
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(_ => new GroupRoleMapping<string>(mapping.Roles, Scope: mapping));
var initial = SessionClaimBuilder.Build("alice", "Alice", ["group"], mapping, DateTimeOffset.UtcNow);
var services = new ServiceCollection().AddLogging();
services.AddSingleton(mapper);
var transaction = Substitute.For<IConfigurationCommandTransaction>();
services.AddSingleton(transaction);
services.AddScoped<ConfigurationCommandService>();
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton(Options.Create(new SecurityOptions()));
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
var sites = new SiteScopeService(auth);
Assert.True(await sites.IsSiteAllowedAsync(1));
mapping = new RoleMappingResult([Roles.Viewer], ["2"], false);
var refreshed = await auth.GetAuthenticationStateAsync();
Assert.DoesNotContain(Roles.Deployer, refreshed.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
Assert.False(await sites.IsSiteAllowedAsync(1));
Assert.True(await sites.IsSiteAllowedAsync(2));
// The already-created circuit facade must deny before starting any save transaction.
var commands = scope.ServiceProvider.GetRequiredService<ConfigurationCommandService>();
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => commands.ExecuteAsync(new CreateRoleMappingCommand("blocked", Roles.Viewer)));
await transaction.DidNotReceive().ExecuteAsync(Arg.Any<Func<IServiceProvider, Task<object?>>>());
}
}
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class ParkedMessagesMutationRegressionTests : BunitContext
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task NonnumericSiteIdentifier_RelaysOnlyWhileCurrentSiteGrantAllows(bool revoke)
{
using var system = ActorSystem.Create("parked-mutation-" + Guid.NewGuid().ToString("N"));
var dispatched = new ConcurrentQueue<SiteEnvelope>();
var comms = new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance);
comms.SetCommunicationActor(system.ActorOf(Props.Create(() => new ReplyActor(dispatched))));
Services.AddSingleton(comms);
var sites = Substitute.For<ISiteRepository>();
sites.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { new("Plant A", "plant-a") { Id = 42 } });
Services.AddSingleton(sites);
var auth = new MutableAuth("42");
Services.AddSingleton<AuthenticationStateProvider>(auth);
Services.AddScoped<SiteScopeService>();
Services.AddAuthorizationCore();
Services.AddLogging();
Services.AddSingleton(Substitute.For<IDialogService>());
JSInterop.Mode = JSRuntimeMode.Loose;
var page = Render<ParkedMessages>();
await page.Find("select").ChangeAsync(new() { Value = "plant-a" });
page.WaitForAssertion(() => Assert.Contains("ERP", page.Markup));
await page.FindAll("tr").Single(row => row.TextContent.Contains("ERP")).ClickAsync(new());
page.WaitForAssertion(() => Assert.Contains("Retry", page.Markup));
if (revoke) auth.SiteId = "99";
// The same rendered page still contains the old site and row. The click must
// resolve SiteIdentifier through Site.Id and consult the live grant.
await page.FindAll("button").Single(button => button.TextContent.Trim() == "Retry").ClickAsync(new());
if (revoke)
{
Assert.Empty(dispatched);
Assert.Contains("Site access was revoked", page.Markup);
}
else
{
var envelope = Assert.Single(dispatched);
Assert.Equal("plant-a", envelope.SiteId);
var retry = Assert.IsType<ParkedMessageRetryRequest>(envelope.Message);
Assert.Equal("parked-message-1", retry.MessageId);
Assert.Equal("plant-a", retry.SiteId);
}
await system.Terminate();
}
private sealed class MutableAuth(string siteId) : AuthenticationStateProvider
{
public string SiteId { get; set; } = siteId;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(JwtTokenService.UsernameClaimType, "operator"),
new Claim(JwtTokenService.RoleClaimType, Roles.Deployer),
new Claim(JwtTokenService.SiteIdClaimType, SiteId)
}, "test"))));
}
private sealed class ReplyActor : ReceiveActor
{
public ReplyActor(ConcurrentQueue<SiteEnvelope> dispatched)
{
Receive<SiteEnvelope>(envelope =>
{
if (envelope.Message is ParkedMessageQueryRequest query)
Sender.Tell(new ParkedMessageQueryResponse(query.CorrelationId, envelope.SiteId,
new[] { new ParkedMessageEntry("parked-message-1", "ERP", "Send", "Offline", 2, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow) },
1, 1, 25, true, null, DateTimeOffset.UtcNow));
else if (envelope.Message is ParkedMessageRetryRequest retry)
{
dispatched.Enqueue(envelope);
Sender.Tell(new ParkedMessageRetryResponse(retry.CorrelationId, true));
}
});
}
}
}
@@ -0,0 +1,95 @@
using Akka.Actor;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
public class UiAuditRegressionTests : BunitContext
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LdapCreateFromUi_PersistsAuditWithGeneratedMappingId_OrRollsBack(bool failAudit)
{
using var connection = new SqliteConnection("Data Source=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlite(connection).Options;
Services.AddScoped(_ => new ScadaBridgeDbContext(options));
Services.AddScoped<ISecurityRepository, SecurityRepository>();
Services.AddScoped<IConfigurationReadSession, ConfigurationReadSession>();
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
if (failAudit)
{
var failing = Substitute.For<IAuditService>();
failing.LogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()).Returns(Task.FromException(new InvalidOperationException("audit unavailable")));
Services.AddSingleton(failing);
}
else Services.AddScoped<IAuditService, AuditService>();
Services.AddSingleton(Substitute.For<IDialogService>());
var user = SessionClaimBuilder.Build("administrator", "Administrator", ["admins"],
new RoleMappingResult([Roles.Administrator], [], true), DateTimeOffset.UtcNow);
Services.AddSingleton<AuthenticationStateProvider>(new StaticAuth(user));
Services.AddAuthorizationCore();
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Services.ConfigurationCommandService>();
Services.AddSingleton<IConfigurationCommandTransaction, ConfigurationCommandTransaction>();
Services.AddScoped<ConfigurationCommitActions>();
Services.AddScoped<IConfigurationCommitActions>(sp => sp.GetRequiredService<ConfigurationCommitActions>());
Services.AddLogging();
using var schema = new ScadaBridgeDbContext(options);
await schema.Database.EnsureCreatedAsync();
var page = Render<LdapMappingForm>();
page.Find("input").Change("NewDirectoryGroup");
page.Find("select").Change(Roles.Viewer);
page.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click();
if (failAudit)
{
page.WaitForAssertion(() => Assert.Contains("audit unavailable", page.Markup));
Assert.Empty(await schema.LdapGroupMappings.Where(x => x.LdapGroupName == "NewDirectoryGroup").ToListAsync());
Assert.Empty(await schema.AuditLogEntries.ToListAsync());
return;
}
page.WaitForAssertion(() => Assert.Single(schema.LdapGroupMappings.AsNoTracking().Where(x => x.LdapGroupName == "NewDirectoryGroup").ToList()));
var mapping = await schema.LdapGroupMappings.AsNoTracking().SingleAsync(x => x.LdapGroupName == "NewDirectoryGroup");
var audit = Assert.Single(await schema.AuditLogEntries.AsNoTracking().ToListAsync());
Assert.Equal(mapping.Id.ToString(), audit.EntityId);
Assert.Equal("Create", audit.Action);
// Existing edit form uses the same durable boundary; generated IDs are retained.
page.Dispose();
var edit = Render<LdapMappingForm>(parameters => parameters.Add(p => p.Id, mapping.Id));
edit.Find("input").Change("RenamedDirectoryGroup");
edit.FindAll("button").Single(b => b.TextContent.Contains("Save")).Click();
edit.WaitForAssertion(() => Assert.Equal("RenamedDirectoryGroup", schema.LdapGroupMappings.AsNoTracking().Single(x => x.Id == mapping.Id).LdapGroupName));
edit.Dispose();
var list = Render<LdapMappings>();
list.WaitForAssertion(() => Assert.Contains("RenamedDirectoryGroup", list.Markup));
var row = list.FindAll("tr").Single(r => r.TextContent.Contains("RenamedDirectoryGroup"));
await row.QuerySelector("button.dropdown-item")!.ClickAsync(new());
list.WaitForAssertion(() => Assert.DoesNotContain("RenamedDirectoryGroup", list.Markup));
Assert.False(await schema.LdapGroupMappings.AnyAsync(x => x.Id == mapping.Id));
var auditRows = await schema.AuditLogEntries.AsNoTracking().OrderBy(x => x.Id).ToListAsync();
Assert.Equal(new[] { "Create", "Update", "Delete" }, auditRows.Select(x => x.Action));
Assert.All(auditRows, row => Assert.Equal(mapping.Id.ToString(), row.EntityId));
}
private sealed class StaticAuth(System.Security.Claims.ClaimsPrincipal principal) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(principal));
}
}