refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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.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>
|
||||
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>
|
||||
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. Decode Basic Auth
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.Json(new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401);
|
||||
}
|
||||
|
||||
string username, password;
|
||||
try
|
||||
{
|
||||
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
|
||||
var colon = decoded.IndexOf(':');
|
||||
if (colon < 0) throw new FormatException();
|
||||
username = decoded[..colon];
|
||||
password = decoded[(colon + 1)..];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401);
|
||||
}
|
||||
|
||||
// 2. LDAP authentication
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed.", code = "AUTH_FAILED" },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
// 3. Role resolution
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(
|
||||
authResult.Groups ?? (IReadOnlyList<string>)Array.Empty<string>());
|
||||
|
||||
var permittedSiteIds = mappingResult.IsSystemWideDeployment
|
||||
? Array.Empty<string>()
|
||||
: mappingResult.PermittedSiteIds.ToArray();
|
||||
|
||||
var authenticatedUser = new AuthenticatedUser(
|
||||
authResult.Username!,
|
||||
authResult.DisplayName!,
|
||||
mappingResult.Roles.ToArray(),
|
||||
permittedSiteIds);
|
||||
|
||||
// 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>
|
||||
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>
|
||||
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>
|
||||
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("{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user