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:
@@ -1309,6 +1309,129 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Site scoping: list filtering + resolve-before-scope oracles
|
||||
// (arch-review 2026-08-19 P6-4 + P6-5)
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// P6-4: the unfiltered list used to return EVERY site's connections to any
|
||||
/// authenticated caller, handing a scoped caller exactly the rows
|
||||
/// <c>GetDataConnection</c> denies them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ListDataConnections_SiteScopedUser_ReturnsOnlyPermittedSitesConnections()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection>
|
||||
{
|
||||
new("ConnA", "OpcUa", 1) { Id = 1 },
|
||||
new("ConnB", "OpcUa", 2) { Id = 2 },
|
||||
});
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(ScopedEnvelope(new ListDataConnectionsCommand(null), new[] { "1" }, "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("ConnA", response.JsonData);
|
||||
Assert.DoesNotContain("ConnB", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListDataConnections_SystemWideUser_ReturnsEveryConnection()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection>
|
||||
{
|
||||
new("ConnA", "OpcUa", 1) { Id = 1 },
|
||||
new("ConnB", "OpcUa", 2) { Id = 2 },
|
||||
});
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ListDataConnectionsCommand(null), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("ConnA", response.JsonData);
|
||||
Assert.Contains("ConnB", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListDataConnections_ScopedAdministrator_ReturnsEveryConnection()
|
||||
{
|
||||
// Administrator bypasses scope even with PermittedSiteIds set — same rule
|
||||
// EnforceSiteScope applies everywhere else.
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllDataConnectionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection>
|
||||
{
|
||||
new("ConnA", "OpcUa", 1) { Id = 1 },
|
||||
new("ConnB", "OpcUa", 2) { Id = 2 },
|
||||
});
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(ScopedEnvelope(new ListDataConnectionsCommand(null), new[] { "1" }, "Administrator"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("ConnA", response.JsonData);
|
||||
Assert.Contains("ConnB", response.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListDataConnections_ExplicitOutOfScopeSiteFilter_ReturnsUnauthorized()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection> { new("ConnB", "OpcUa", 2) { Id = 2 } });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new ListDataConnectionsCommand(2), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P6-5: the not-found check used to run before the scope check, so an
|
||||
/// out-of-scope caller could tell a real instance id from a fake one by the
|
||||
/// error they got back. Both cases must now answer with the same denial.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DebugSnapshot_NonexistentInstanceForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
_templateRepo.GetInstanceByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Instance?)null);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new DebugSnapshotCommand(999), new[] { "1" }, "Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugSnapshot_NonexistentInstanceForSystemWideUser_StillReportsNotFound()
|
||||
{
|
||||
// The fail-closed arm must not change the unscoped caller's answer.
|
||||
_templateRepo.GetInstanceByIdAsync(999, Arg.Any<CancellationToken>())
|
||||
.Returns((Instance?)null);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new DebugSnapshotCommand(999), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("not found", response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Serialization (finding ManagementService-007)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user