fix(security): close auth & site-scoping gaps across 8 findings

Resolves the auth-theme batch from the 2026-05-28 baseline review (8 findings
across Security/CentralUI/ManagementService/CLI). The most consequential gaps:
NotificationReport + SiteCallsReport now route through SiteScopeService so a
site-scoped Deployment user cannot see or act on other sites' rows (CUI-028);
QueryAuditLogCommand is no longer "any authenticated user" — gated Admin-only
to match /api/audit/query's strictness (MS-018); RoleMapper preserves the
broader grant when a user is in both an unscoped and scoped Deployment LDAP
group, instead of silently narrowing to the scoped set (Sec-016); and the
dead SiteScopeRequirement/Handler are deleted so SiteScopeService is
unambiguously the sole site-scoping mechanism (Sec-017). Pending findings:
172 → 164.
This commit is contained in:
Joseph Doherty
2026-05-28 03:35:29 -04:00
parent f93b7b99bb
commit e536178323
28 changed files with 814 additions and 196 deletions
@@ -530,4 +530,81 @@ public class AuditEndpointsTests
Assert.Null(paging.AfterEventId);
Assert.Null(paging.AfterOccurredAtUtc);
}
// ─────────────────────────────────────────────────────────────────────
// ApplySiteScope (Management-019)
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
{
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
// Deployment, audit roles with no scope rules attached). The filter
// should pass through with no restriction added.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Admin" }, Array.Empty<string>());
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Same(filter, result);
}
[Fact]
public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
{
// No explicit sourceSiteId from the caller — the helper must restrict
// the query to the user's permitted set, otherwise a site-scoped audit
// user could read every site's rows.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter();
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.NotNull(result!.SourceSiteIds);
Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
{
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
{
// Caller explicitly asked for a site they cannot see — the helper signals
// "403" by returning null rather than silently producing an empty page.
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.Null(result);
}
[Fact]
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
{
var user = new ScadaLink.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
}
@@ -94,6 +94,36 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Deployment", response.Message);
}
[Fact]
public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
{
// ManagementService-018: QueryAuditLogCommand used to fall through to the
// default "any authenticated user" case, allowing a Deployment-only or
// no-role caller to read the configuration audit log via /management
// even though /api/audit/query enforces OperationalAuditRoles. The fix
// gates this legacy command to Admin so the older route is never looser
// than the new REST endpoint.
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
[Fact]
public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployment");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("Admin", response.Message);
}
// ========================================================================
// 2. Read-only query passes without special role
// ========================================================================