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
+37 -76
View File
@@ -314,6 +314,29 @@ public class RoleMapperTests : IDisposable
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
}
[Fact]
public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide()
{
// Security-016: a user in BOTH an unscoped Deployment mapping
// (SCADA-Deploy-All, Id=3) AND a scoped Deployment mapping
// (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A
// grant. The union semantics now preserve the broader grant: the
// unscoped mapping wins, PermittedSiteIds is empty, system-wide.
var siteA = new Site("SiteA", "S-A");
_context.Sites.Add(siteA);
await _context.SaveChangesAsync();
// Mapping Id=4 (SCADA-Deploy-SiteA) is seeded; attach a scope rule for siteA.
_context.SiteScopeRules.Add(new SiteScopeRule { LdapGroupMappingId = 4, SiteId = siteA.Id });
await _context.SaveChangesAsync();
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" });
Assert.Contains("Deployment", result.Roles);
Assert.True(result.IsSystemWideDeployment);
Assert.Empty(result.PermittedSiteIds);
}
[Fact]
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
{
@@ -1030,6 +1053,20 @@ public class Security012GroupLookupFailureTests
Assert.True(result.Success);
Assert.Equal(new[] { "SCADA-Admins" }, result.Groups);
}
[Fact]
public void ServiceAccountBindException_DoesNotInheritLdapException_SoCatchOrderIsCorrect()
{
// Security-019: the LdapAuthService catch chain matches
// ServiceAccountBindException *before* the generic LdapException catch. That only
// produces the distinct "Authentication service is misconfigured" message if the
// exception type is NOT an LdapException subtype (otherwise it would be caught
// by the broader handler first regardless of ordering).
var ex = new ServiceAccountBindException(new InvalidOperationException("boom"));
Assert.IsNotType<Novell.Directory.Ldap.LdapException>(ex);
Assert.IsType<InvalidOperationException>(ex.InnerException);
}
}
#endregion
@@ -1132,82 +1169,6 @@ public class AuthorizationPolicyTests
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
}
[Fact]
public async Task SiteScope_SystemWideDeployer_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment")
// No SiteId claims = system-wide
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("42");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_PermittedSite_Succeeds()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1"),
new(JwtTokenService.SiteIdClaimType, "2")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_UnpermittedSite_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Deployment"),
new(JwtTokenService.SiteIdClaimType, "1")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("99");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task SiteScope_NoDeploymentRole_Fails()
{
var handler = new SiteScopeAuthorizationHandler();
var claims = new List<Claim>
{
new(JwtTokenService.RoleClaimType, "Admin")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var requirement = new SiteScopeRequirement("1");
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
await handler.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
{
var claims = new List<Claim>();