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