using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api; /// /// Headless REST gateway over the admin-operations cluster singleton, for CI / scripts that /// need to trigger a deployment without driving the Blazor AdminUI. Mounted on admin-role nodes /// only (where + the singleton proxy live), it forwards to /// the same the "Deploy current /// configuration" button calls. /// /// Auth is a single configured secret (Security:DeployApiKey) checked from the /// X-Api-Key header in fixed time — deliberately orthogonal to the cookie-only web /// auth so automation needs no LDAP login round-trip. The endpoint is AllowAnonymous /// (so the global RequireAuthenticatedUser fallback doesn't 401 it) and enforces the /// key itself; when the key is unconfigured the endpoint is disabled (503), so it is never /// open. /// /// public static class DeployApiEndpoints { /// Header carrying the deploy API key. public const string ApiKeyHeader = "X-Api-Key"; /// Configuration key holding the deploy API secret. public const string ConfigKey = "Security:DeployApiKey"; /// Maps POST /api/deployments — the headless deploy trigger. No-op-safe to call /// on any admin-role node; the handler self-disables (503) until Security:DeployApiKey is set. /// The endpoint route builder. /// Application configuration (read for the deploy API key). /// The same for chaining. public static IEndpointRouteBuilder MapOtOpcUaDeployApi(this IEndpointRouteBuilder app, IConfiguration config) { var configuredKey = config[ConfigKey]; app.MapPost("/api/deployments", async ( HttpContext http, DeployRequest? body, IAdminOperationsClient adminOps, CancellationToken ct) => { if (string.IsNullOrEmpty(configuredKey)) return Results.Problem( "Deploy API is disabled. Set Security:DeployApiKey to enable it.", statusCode: StatusCodes.Status503ServiceUnavailable); if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey)) return Results.Unauthorized(); var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim(); var result = await adminOps.StartDeploymentAsync(createdBy, ct); return ToResult(result); }) .AllowAnonymous() // gated by the API key, not the cookie auth fallback .DisableAntiforgery() // machine endpoint, not a browser form post .WithName("StartDeployment"); return app; } /// Fixed-time compare of the supplied key against the configured secret. False when /// either is empty (so an unconfigured key never authorizes). /// The caller-supplied key (from the X-Api-Key header). /// The configured secret. /// only when both are non-empty and equal. public static bool IsAuthorized(string? provided, string? configuredKey) { if (string.IsNullOrEmpty(configuredKey) || string.IsNullOrEmpty(provided)) return false; return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(configuredKey)); } /// Map a to its HTTP result: Accepted → 202 (with a /// Location), NoChanges → 200, AnotherDeploymentInFlight → 409, Rejected → 422, anything else → 500. /// The deployment outcome from the admin-operations singleton. /// The corresponding HTTP result. public static IResult ToResult(StartDeploymentResult r) => r.Outcome switch { StartDeploymentOutcome.Accepted => Results.Accepted( $"/api/deployments/{r.DeploymentId?.Value}", new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)), StartDeploymentOutcome.NoChanges => Results.Ok( new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)), StartDeploymentOutcome.AnotherDeploymentInFlight => Results.Conflict( new DeployResponse(r.Outcome.ToString(), DeploymentId: null, RevisionHash: null)), StartDeploymentOutcome.Rejected => Results.Problem( r.Message ?? "Deployment rejected.", statusCode: StatusCodes.Status422UnprocessableEntity), _ => Results.Problem($"Unexpected deployment outcome: {r.Outcome}", statusCode: StatusCodes.Status500InternalServerError), }; /// Optional request body. CreatedBy is recorded as the deployment's initiator /// (audit); defaults to "rest-api" when absent. /// Who/what initiated the deploy (for the audit trail). public sealed record DeployRequest(string? CreatedBy); /// Response body for a deploy trigger. /// The name. /// The new deployment id when one was created; otherwise null. /// The config revision hash, when known. public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash); }