Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs
T
Joseph Doherty d312dfb139 fix(management): honor DisableLogin on the Basic-Auth CLI surfaces
DisableLogin only swapped the cookie auth scheme (AutoLoginAuthenticationHandler),
which covers the interactive UI. The CLI authenticates POST /management, the audit
REST endpoints, and the SignalR debug-stream hub with HTTP Basic, and each ran its
own hardcoded Basic->LDAP check that ignored DisableLogin. In a login-disabled (e.g.
no-LDAP) deployment that locked the CLI out: every call returned 401 AUTH_FAILED.

Add ManagementAuthenticator, which centralizes the management/CLI auth flow:
when ScadaBridge:Security:Auth:DisableLogin is true it synthesizes the same dev
principal as AutoLoginAuthenticationHandler (configured user, all roles, system-wide)
and bypasses Basic->LDAP; otherwise the unchanged Basic->LDAP flow runs. Wired into
ManagementEndpoints (delegates), AuditEndpoints (delegates), and DebugStreamHub
(bypass branch). +6 unit tests; ManagementService.Tests green (140).
2026-06-16 17:12:17 -04:00

197 lines
8.7 KiB
C#

using System.Text;
using System.Text.Json;
using Akka.Actor;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
public static class ManagementEndpoints
{
private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// Resolves the ManagementActor Ask timeout from configuration
/// (finding ManagementService-010). Falls back to <see cref="DefaultAskTimeout"/>
/// when options are absent or the configured value is not strictly positive — a
/// zero/negative timeout would make every management call fail immediately.
/// </summary>
/// <param name="options">The management service options, or <c>null</c> if not configured.</param>
/// <returns>The configured timeout, or <see cref="DefaultAskTimeout"/> when none is set.</returns>
public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options)
{
if (options is { CommandTimeout: { Ticks: > 0 } configured })
return configured;
return DefaultAskTimeout;
}
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>
/// <param name="endpoints">The route builder to add the endpoint to.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
public static IEndpointRouteBuilder MapManagementAPI(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/management", (Delegate)HandleRequest);
return endpoints;
}
/// <summary>
/// Per-request body-size ceiling for the management endpoint. ASP.NET Core's
/// default cap is ~30 MB and would reject Transport (#24) Import calls -- a
/// 100 MB raw bundle base64-inflates to ~140 MB plus envelope. 200 MB is
/// comfortable without going unbounded.
/// </summary>
private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024;
private static async Task<IResult> HandleRequest(HttpContext context)
{
var logger = context.RequestServices.GetRequiredService<ILogger<ManagementActorHolder>>();
// 0. Raise the per-request body-size cap before any body is read.
// The feature is only writable before the request body has been touched.
var maxBodyFeature = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
if (maxBodyFeature is { IsReadOnly: false })
{
maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes;
}
// 1-3. Authenticate: dev/test DisableLogin bypass → else HTTP Basic → LDAP → roles.
// Centralised in ManagementAuthenticator so every CLI surface honours DisableLogin
// identically — the cookie auto-login (AutoLoginAuthenticationHandler) only covers the
// interactive UI, not this Basic-Auth surface.
var auth = await ManagementAuthenticator.AuthenticateAsync(context);
if (auth.Failure is not null)
{
return auth.Failure;
}
var authenticatedUser = auth.User!;
// 4. Parse command from request body
string body;
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
{
body = await reader.ReadToEndAsync();
}
var parse = ParseCommand(body);
if (!parse.Success)
{
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
}
var command = parse.Command!;
// 5. Dispatch to ManagementActor
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
if (holder.ActorRef == null)
{
return Results.Json(new { error = "Management service not ready.", code = "SERVICE_UNAVAILABLE" }, statusCode: 503);
}
var correlationId = Guid.NewGuid().ToString("N");
var envelope = new ManagementEnvelope(authenticatedUser, command, correlationId);
var askTimeout = ResolveAskTimeout(
context.RequestServices.GetService<IOptions<ManagementServiceOptions>>()?.Value);
object response;
try
{
response = await holder.ActorRef.Ask(envelope, askTimeout);
}
catch (Exception ex)
{
logger.LogError(ex, "ManagementActor Ask timed out or failed (CorrelationId={CorrelationId})", correlationId);
return Results.Json(new { error = "Request timed out.", code = "TIMEOUT" }, statusCode: 504);
}
// 6. Map response
return response switch
{
ManagementSuccess success => Results.Text(success.JsonData, "application/json", statusCode: 200),
ManagementError error => Results.Json(new { error = error.Error, code = error.ErrorCode }, statusCode: 400),
ManagementUnauthorized unauth => Results.Json(new { error = unauth.Message, code = "UNAUTHORIZED" }, statusCode: 403),
_ => Results.Json(new { error = "Unexpected response.", code = "INTERNAL_ERROR" }, statusCode: 500)
};
}
/// <summary>
/// Result of parsing a management request body into a strongly-typed command.
/// </summary>
public readonly record struct CommandParseResult(
bool Success, object? Command, string? ErrorMessage, string? ErrorCode)
{
/// <summary>Creates a successful parse result wrapping the given command.</summary>
/// <param name="command">The strongly-typed command object that was parsed.</param>
/// <returns>A successful <see cref="CommandParseResult"/> containing the parsed command.</returns>
public static CommandParseResult Ok(object command) => new(true, command, null, null);
/// <summary>Creates a failed parse result with the given error message.</summary>
/// <param name="message">Human-readable description of the parse failure.</param>
/// <returns>A failed <see cref="CommandParseResult"/> with the error message and <c>BAD_REQUEST</c> code.</returns>
public static CommandParseResult Fail(string message) => new(false, null, message, "BAD_REQUEST");
}
/// <summary>
/// Parses a management request body — a JSON object with a <c>command</c> name and an
/// optional <c>payload</c> — into the strongly-typed command record. The parsed
/// <see cref="JsonDocument"/> is disposed deterministically and the missing-payload
/// case does not allocate a throwaway document (finding ManagementService-006).
/// </summary>
/// <param name="body">The raw JSON request body string.</param>
/// <returns>A <see cref="CommandParseResult"/> with the deserialized command on success, or an error on failure.</returns>
public static CommandParseResult ParseCommand(string body)
{
using JsonDocument doc = ParseDocument(body, out var parseError);
if (parseError != null)
return CommandParseResult.Fail(parseError);
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
return CommandParseResult.Fail("Missing 'command' field.");
var commandName = commandNameElement.GetString();
if (string.IsNullOrWhiteSpace(commandName))
return CommandParseResult.Fail("Empty 'command' field.");
var commandType = ManagementCommandRegistry.Resolve(commandName);
if (commandType == null)
return CommandParseResult.Fail($"Unknown command: '{commandName}'.");
try
{
// Missing payload: deserialize from the empty-object literal rather than
// allocating (and leaking) a throwaway JsonDocument.
var payloadJson = doc.RootElement.TryGetProperty("payload", out var p)
? p.GetRawText()
: "{}";
var command = JsonSerializer.Deserialize(payloadJson, commandType,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
return CommandParseResult.Ok(command);
}
catch (Exception ex)
{
return CommandParseResult.Fail($"Failed to deserialize payload: {ex.Message}");
}
}
private static JsonDocument ParseDocument(string body, out string? error)
{
try
{
error = null;
return JsonDocument.Parse(body);
}
catch
{
error = "Invalid JSON body.";
return JsonDocument.Parse("{}");
}
}
}