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
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
namespace ScadaLink.CLI.Commands;
@@ -147,10 +148,18 @@ public static class AuditExportHelpers
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync();
// CLI-018: honour the documented "authorization failure → exit 2"
// contract on the REST audit surface as well. HTTP 403 is the
// primary signal; the server may also surface UNAUTHORIZED /
// FORBIDDEN via the JSON error envelope on a non-403 status.
var errorCode = TryExtractErrorCode(message);
var isAuthFailure = (int)response.StatusCode == 403
|| string.Equals(errorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|| string.Equals(errorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
OutputFormatter.WriteError(
string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message,
"ERROR");
return 1;
errorCode ?? "ERROR");
return isAuthFailure ? 2 : 1;
}
await using var source = await response.Content.ReadAsStreamAsync();
@@ -163,4 +172,32 @@ public static class AuditExportHelpers
output.WriteLine($"Exported audit log to {args.Output}");
return 0;
}
/// <summary>
/// Best-effort parse of the server's JSON error envelope (<c>{ "error": ..., "code": ... }</c>)
/// to extract the <c>code</c> field. Returns null if the body is empty, not valid JSON, or
/// has no <c>code</c> property — callers fall back to "ERROR" in that case.
/// </summary>
internal static string? TryExtractErrorCode(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("code", out var codeProp)
&& codeProp.ValueKind == JsonValueKind.String)
{
return codeProp.GetString();
}
}
catch (JsonException)
{
// Body is not a JSON envelope (e.g. an HTML proxy error page); no code to extract.
}
return null;
}
}
@@ -189,7 +189,9 @@ public static class AuditQueryHelpers
{
OutputFormatter.WriteError(
response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR");
return 1;
// CLI-018: surface the documented "authorization failure → exit 2"
// contract for the audit REST surface too, not just /management.
return CommandHelpers.IsAuthorizationFailure(response) ? 2 : 1;
}
using var doc = JsonDocument.Parse(response.JsonData);
+1 -1
View File
@@ -164,7 +164,7 @@ internal static class CommandHelpers
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
/// </summary>
private static bool IsAuthorizationFailure(ManagementResponse response)
internal static bool IsAuthorizationFailure(ManagementResponse response)
{
if (response.StatusCode == 403)
return true;