fix(management): site-scope ListDataConnections + hoist scope checks above resolve/status oracles (arch-review 2026-08-19 P6-4 + P6-5)
P6-4: HandleListDataConnections took no user at all and returned EVERY site's connections to any authenticated caller, nullifying the row-98 fail-closed gate on HandleGetDataConnection — Get denied exactly what List handed over. DataConnectionPublicShape already scrubs secrets, so the leak was site topology plus a connection-id existence oracle, but it still defeated scoping. List now takes the AuthenticatedUser: an explicit SiteId goes through EnforceSiteScope (the ListAreas shape), and an unfiltered list from a site-scoped non-Administrator caller is narrowed to their permitted sites (the ListSites shape). GetRequiredRoles is untouched — scoping is not a role gate, so the frozen 138-command authz matrix is unaffected. The Central UI Design > Connections page bypasses the actor entirely (ISiteRepository.GetAllDataConnectionsAsync direct) and IS reachable by a scoped user: it is gated RequireDesign, and SiteScopeService explicitly does NOT exempt Designer (register row 86). It now filters sites through SiteScope.FilterSitesAsync and keeps only their connections, mirroring EventLogs/AlarmSummary. P6-5: four handlers resolved their target — and on approve/reject reported its status — BEFORE the scope check, so an out-of-scope caller could tell a real id from a fake one, and read a secured write's lifecycle status, from the error shape alone. The scope check is hoisted above the not-found and status checks in HandleSubmitSecuredWrite, HandleApproveSecuredWrite, HandleRejectSecuredWrite and HandleDebugSnapshot, with the unresolvable case routed through the existing DenyUnresolvedTargetIfScoped so the denial is identical whether or not the target exists. System-wide callers keep their ordinary not-found/status answers, and the P6-1 CAS semantics on approve/reject are unchanged (only the checks above the CAS moved). Tests: 8 new ManagementService tests + 2 CentralUI bUnit tests, all verified to FAIL against the unfixed handlers (src reverted, tests re-run) and pass after — including the unscoped/Administrator control cases proving behaviour for system-wide callers is unchanged. Full ManagementService.Tests 545 green, CentralUI.Tests 1073 green.
This commit is contained in:
@@ -40,6 +40,9 @@ public class DataConnectionsPageTests : BunitContext
|
||||
// Satisfy the page's [Inject] IDialogService — the host that actually
|
||||
// renders the dialog lives in MainLayout, not in bUnit's render scope.
|
||||
Services.AddScoped<IDialogService, DialogService>();
|
||||
// arch-review P6-4: the page now filters its site/connection tree through
|
||||
// SiteScopeService, so it must be resolvable in every render test.
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
AddTestAuth();
|
||||
|
||||
// Default: no health known. Tests that assert badge state override this.
|
||||
@@ -271,4 +274,66 @@ public class DataConnectionsPageTests : BunitContext
|
||||
Assert.Contains(optionLabels, l => l.Contains("Plant-C"));
|
||||
Assert.DoesNotContain(optionLabels, l => l.Contains("Plant-A"));
|
||||
}
|
||||
|
||||
// ── arch-review P6-4: site scoping. This page reads ISiteRepository directly,
|
||||
// bypassing ManagementActor's scope gate, and Designer is NOT exempt from
|
||||
// site scope — so a scoped Designer used to see every site's connections. ──
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_ScopedDesigner_SeesOnlyPermittedSitesAndConnections()
|
||||
{
|
||||
SeedRepos(
|
||||
sites: new[]
|
||||
{
|
||||
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||
new Site("Plant-B", "plant-b") { Id = 2 }
|
||||
},
|
||||
connections: new[]
|
||||
{
|
||||
new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 },
|
||||
new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 }
|
||||
});
|
||||
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Designer"),
|
||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant-A only
|
||||
}, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
var cut = Render<DataConnectionsPage>();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
|
||||
Assert.Contains("Plant-A", cut.Markup);
|
||||
Assert.Contains("PLC-A", cut.Markup);
|
||||
// Neither the out-of-scope site nor its connection may appear anywhere.
|
||||
Assert.DoesNotContain("Plant-B", cut.Markup);
|
||||
Assert.DoesNotContain("PLC-B", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_SystemWideUser_SeesEveryConnection()
|
||||
{
|
||||
// The unscoped case is unchanged: the default test principal is an
|
||||
// Administrator (system-wide), so the filter is a no-op.
|
||||
SeedRepos(
|
||||
sites: new[]
|
||||
{
|
||||
new Site("Plant-A", "plant-a") { Id = 1 },
|
||||
new Site("Plant-B", "plant-b") { Id = 2 }
|
||||
},
|
||||
connections: new[]
|
||||
{
|
||||
new DataConnection("PLC-A", "OpcUa", 1) { Id = 100 },
|
||||
new DataConnection("PLC-B", "OpcUa", 2) { Id = 200 }
|
||||
});
|
||||
|
||||
var cut = Render<DataConnectionsPage>();
|
||||
FindToggleForLabel(cut, "Plant-A")!.Click();
|
||||
FindToggleForLabel(cut, "Plant-B")!.Click();
|
||||
|
||||
Assert.Contains("PLC-A", cut.Markup);
|
||||
Assert.Contains("PLC-B", cut.Markup);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user