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:
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user