fix(management-service): resolve ManagementService-004,006,007,013 — PipeTo dispatch, JsonDocument disposal, unified serialization, endpoint tests; re-triage MS-009

This commit is contained in:
Joseph Doherty
2026-05-16 21:22:01 -04:00
parent da955042aa
commit 57679d49f2
5 changed files with 340 additions and 78 deletions

View File

@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Akka.Actor;
using Newtonsoft.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.ExternalSystems;
@@ -42,6 +43,25 @@ public class ManagementActor : ReceiveActor
Receive<ManagementEnvelope>(HandleEnvelope);
}
/// <summary>
/// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
/// camelCase matches what the CLI / HTTP layer expect (finding ManagementService-007).
/// </summary>
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Serializes a command result to JSON using <see cref="System.Text.Json"/> — the same
/// serializer the HTTP endpoint uses — with cycle-safe settings.
/// </summary>
public static string SerializeResult(object? result) =>
JsonSerializer.Serialize(result, ResultSerializerOptions);
private void HandleEnvelope(ManagementEnvelope envelope)
{
var sender = Sender;
@@ -57,27 +77,42 @@ public class ManagementActor : ReceiveActor
return;
}
// Process command asynchronously with scoped DI
Task.Run(async () =>
{
using var scope = _serviceProvider.CreateScope();
try
{
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
var json = JsonConvert.SerializeObject(result, Formatting.None);
sender.Tell(new ManagementSuccess(correlationId, json));
}
catch (SiteScopeViolationException ex)
{
sender.Tell(new ManagementUnauthorized(correlationId, ex.Message));
}
catch (Exception ex)
{
_logger.LogError(ex, "Management command {Command} failed (CorrelationId={CorrelationId})",
envelope.Command.GetType().Name, correlationId);
sender.Tell(new ManagementError(correlationId, ex.Message, "COMMAND_FAILED"));
}
});
// Process the command and pipe the mapped result back to the captured sender.
// PipeTo (rather than Task.Run + Tell-from-continuation) is the project's Akka.NET
// convention: faults are mapped in the failure continuation, no actor state is
// captured in the closure, and synchronous faults in command setup are still mapped.
ProcessCommand(envelope, user)
.PipeTo(sender,
success: result => result,
failure: ex => MapFault(ex, correlationId, envelope.Command));
}
/// <summary>
/// Runs a command on a scoped service provider and maps the result to a management
/// response message. Returns a faulted task on error so the PipeTo failure
/// continuation maps it uniformly.
/// </summary>
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
{
using var scope = _serviceProvider.CreateScope();
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
}
/// <summary>
/// Maps an exception from command processing to the appropriate management response.
/// </summary>
private object MapFault(Exception ex, string correlationId, object command)
{
// PipeTo wraps continuation exceptions; unwrap to find the real cause.
var cause = ex is AggregateException agg ? agg.Flatten().InnerException ?? ex : ex;
if (cause is SiteScopeViolationException scope)
return new ManagementUnauthorized(correlationId, scope.Message);
_logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
command.GetType().Name, correlationId);
return new ManagementError(correlationId, cause.Message, "COMMAND_FAILED");
}
private static string? GetRequiredRole(object command) => command switch
@@ -345,8 +380,23 @@ public class ManagementActor : ReceiveActor
}
/// <summary>
/// Helper to log an audit entry after a successful mutation.
/// Logs an audit entry after a successful mutation.
/// </summary>
/// <remarks>
/// Audit-logging contract (finding ManagementService-009). Every mutating operation is
/// audited exactly once, by whichever layer owns the write:
/// <list type="bullet">
/// <item>Handlers that mutate a repository directly (site, area, data-connection,
/// external-system, notification, security, API-key, scope-rule) call this helper
/// explicitly after the successful change.</item>
/// <item>Handlers that delegate to a domain service (<c>TemplateService</c>,
/// <c>SharedScriptService</c>, <c>InstanceService</c>, <c>AreaService</c>,
/// <c>SiteService</c>, <c>TemplateFolderService</c>, <c>DeploymentService</c>,
/// <c>ArtifactDeploymentService</c>) do NOT call this helper — those services own their
/// own <see cref="IAuditService"/> dependency and audit internally. Adding an explicit
/// <see cref="AuditAsync"/> call in such a handler would double-log.</item>
/// </list>
/// </remarks>
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
{
var auditService = sp.GetRequiredService<IAuditService>();

View File

@@ -77,46 +77,19 @@ public static class ManagementEndpoints
permittedSiteIds);
// 4. Parse command from request body
JsonDocument doc;
try
string body;
using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
{
doc = await JsonDocument.ParseAsync(context.Request.Body);
}
catch
{
return Results.Json(new { error = "Invalid JSON body.", code = "BAD_REQUEST" }, statusCode: 400);
body = await reader.ReadToEndAsync();
}
if (!doc.RootElement.TryGetProperty("command", out var commandNameElement))
var parse = ParseCommand(body);
if (!parse.Success)
{
return Results.Json(new { error = "Missing 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
return Results.Json(new { error = parse.ErrorMessage, code = parse.ErrorCode }, statusCode: 400);
}
var commandName = commandNameElement.GetString();
if (string.IsNullOrWhiteSpace(commandName))
{
return Results.Json(new { error = "Empty 'command' field.", code = "BAD_REQUEST" }, statusCode: 400);
}
var commandType = ManagementCommandRegistry.Resolve(commandName);
if (commandType == null)
{
return Results.Json(new { error = $"Unknown command: '{commandName}'.", code = "BAD_REQUEST" }, statusCode: 400);
}
object command;
try
{
var payloadElement = doc.RootElement.TryGetProperty("payload", out var p)
? p
: JsonDocument.Parse("{}").RootElement;
command = JsonSerializer.Deserialize(payloadElement.GetRawText(), commandType,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
catch (Exception ex)
{
return Results.Json(new { error = $"Failed to deserialize payload: {ex.Message}", code = "BAD_REQUEST" }, statusCode: 400);
}
var command = parse.Command!;
// 5. Dispatch to ManagementActor
var holder = context.RequestServices.GetRequiredService<ManagementActorHolder>();
@@ -148,4 +121,68 @@ public static class ManagementEndpoints
_ => 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)
{
public static CommandParseResult Ok(object command) => new(true, command, null, null);
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>
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("{}");
}
}
}