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
@@ -212,6 +212,58 @@ public class AuditExportCommandTests
}
}
[Fact]
public async Task RunExport_Http403_ReturnsExitCode2()
{
// CLI-018: an HTTP 403 on /api/audit/export must produce exit code 2 per the
// documented CLI contract — the legacy bare-1 return masked auth failures
// as generic command failures.
var path = Path.Combine(Path.GetTempPath(), $"audit-export-403-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.Forbidden,
() => new StringContent("{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
Assert.False(File.Exists(path));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
var path = Path.Combine(Path.GetTempPath(), $"audit-export-401-{Guid.NewGuid():N}.csv");
try
{
var handler = new BodyHandler(HttpStatusCode.BadRequest,
() => new StringContent("{\"error\":\"nope\",\"code\":\"FORBIDDEN\"}", Encoding.UTF8, "application/json"));
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditExportHelpers.RunExportAsync(
client,
new AuditExportArgs { Since = "1h", Until = "0h", Format = "csv", Output = path },
output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public async Task RunExport_Parquet501_PrintsServerMessageAndReturnsNonZero()
{
@@ -281,6 +281,55 @@ public class AuditQueryCommandTests
Assert.NotEqual(0, exit);
}
[Fact]
public async Task RunQuery_Http403_ReturnsExitCode2()
{
// CLI-018: an authorization failure on /api/audit/query (HTTP 403) must
// produce exit code 2 per the documented CLI exit-code contract — the
// legacy bare-1 return masked auth failures as generic command failures.
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
// The server may signal authorization failure via the error code on a
// non-403 status (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
[Fact]
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
{
// Authentication / internal errors (non-403, no auth code) must remain
// exit code 1 — exit 2 is reserved for authorization failures.
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(1, exit);
}
private sealed class ErrorHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
@@ -291,6 +340,16 @@ public class AuditQueryCommandTests
});
}
private sealed class StatusHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
// ---- CLI parsing -------------------------------------------------------
[Fact]
@@ -93,6 +93,7 @@ public class NotificationReportDetailModalTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
[Fact]
@@ -79,6 +79,10 @@ public class NotificationReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
// CentralUI-028: the page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
[Fact]
@@ -94,6 +94,7 @@ public class SiteCallsReportPageTests : BunitContext
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddScoped<ScadaLink.CentralUI.Auth.SiteScopeService>();
}
[Fact]
@@ -484,6 +485,31 @@ public class SiteCallsReportPageTests : BunitContext
});
}
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// Plant B rows in the grid, even though the query response carried both.
// Last AuthenticationStateProvider registration wins on resolution.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("Username", "scoped"),
new Claim(ClaimTypes.Role, "Deployment"),
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
}, "TestAuth"));
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
TimeSpan.FromSeconds(2));
var rows = cut.FindAll("table tbody tr");
Assert.Single(rows);
// Plant A row only; Plant B (FailedId) row must be filtered out.
Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -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
// ========================================================================
+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>();