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 ScadaLink.Commons.Messages.Management; using ScadaLink.Security; namespace ScadaLink.ManagementService; public static class ManagementEndpoints { private static readonly TimeSpan DefaultAskTimeout = TimeSpan.FromSeconds(30); /// /// Resolves the ManagementActor Ask timeout from configuration /// (finding ManagementService-010). Falls back to /// when options are absent or the configured value is not strictly positive — a /// zero/negative timeout would make every management call fail immediately. /// public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options) { if (options is { CommandTimeout: { Ticks: > 0 } configured }) return configured; return DefaultAskTimeout; } public static IEndpointRouteBuilder MapManagementAPI(this IEndpointRouteBuilder endpoints) { endpoints.MapPost("/management", (Delegate)HandleRequest); return endpoints; } private static async Task HandleRequest(HttpContext context) { var logger = context.RequestServices.GetRequiredService>(); // 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(); 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(); var mappingResult = await roleMapper.MapGroupsToRolesAsync( authResult.Groups ?? (IReadOnlyList)Array.Empty()); var permittedSiteIds = mappingResult.IsSystemWideDeployment ? Array.Empty() : 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(); 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>()?.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) }; } /// /// Result of parsing a management request body into a strongly-typed command. /// 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"); } /// /// Parses a management request body — a JSON object with a command name and an /// optional payload — into the strongly-typed command record. The parsed /// is disposed deterministically and the missing-payload /// case does not allocate a throwaway document (finding ManagementService-006). /// 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("{}"); } } }