fix(management-service): resolve ManagementService-001/002/003 — enforce site scope on query/snapshot handlers and DebugStreamHub
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using ScadaLink.ManagementService;
|
||||
|
||||
namespace ScadaLink.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization
|
||||
/// (finding ManagementService-003).
|
||||
/// </summary>
|
||||
public class DebugStreamHubTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
permittedSiteIds: new[] { "1", "2" },
|
||||
instanceSiteId: 2);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
permittedSiteIds: new[] { "1", "2" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed()
|
||||
{
|
||||
// Empty permitted set == system-wide Deployment.
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Deployment" },
|
||||
permittedSiteIds: Array.Empty<string>(),
|
||||
instanceSiteId: 99);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "Admin", "Deployment" },
|
||||
permittedSiteIds: new[] { "1" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive()
|
||||
{
|
||||
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
|
||||
roles: new[] { "admin" },
|
||||
permittedSiteIds: new[] { "1" },
|
||||
instanceSiteId: 99);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
private static ManagementEnvelope ScopedEnvelope(object command, string[] permittedSiteIds, params string[] roles) =>
|
||||
new(new AuthenticatedUser("scopeduser", "Scoped User", roles, permittedSiteIds),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
@@ -478,4 +482,190 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Connection refused", response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Site-scope enforcement tests (findings ManagementService-001 / -002)
|
||||
//
|
||||
// A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must
|
||||
// not be able to read or act on entities belonging to a site outside their
|
||||
// scope. The handlers must throw SiteScopeViolationException, which the
|
||||
// actor maps to ManagementUnauthorized.
|
||||
// ========================================================================
|
||||
|
||||
private void AddSiteRepoWithSite(int siteId, string identifier)
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetSiteByIdAsync(siteId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
|
||||
siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// Instance belongs to site 2; user is scoped to site 1.
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
|
||||
{
|
||||
_templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
var uiRepo = Substitute.For<ICentralUiRepository>();
|
||||
uiRepo.GetAreaTreeBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Instances.Area>());
|
||||
_services.AddScoped(_ => uiRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Entities.Sites.DataConnection("Conn5", "OpcUa", 2) { Id = 5 });
|
||||
_services.AddScoped(_ => siteRepo);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSite_OutOfScopeForAdminUser_ReturnsSuccess()
|
||||
{
|
||||
// Admin role bypasses site scoping even when PermittedSiteIds is set.
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Admin");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryEventLogs_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// Site SITE2 has Id 2; user scoped to site 1.
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetryParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscardParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DebugSnapshot_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
{
|
||||
// Instance 9 belongs to site 2; user scoped to site 1.
|
||||
_templateRepo.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
|
||||
.Returns(new Instance("Pump9") { Id = 9, SiteId = 2 });
|
||||
AddSiteRepoWithSite(2, "SITE2");
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployment");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user