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);
}
}