using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// /// Minimal-API endpoint a SITE calls to fetch a single deployment's flattened /// configuration by id (the "notify-and-fetch" deploy path): central stages a /// row, then notifies the site of the deployment /// id; the site fetches the staged JSON over HTTP rather than receiving it inside /// an Akka message (avoids the 128 KB frame limit). /// /// /// Route. GET /api/internal/deployments/{deploymentId}/config, mapped /// in the same central-role block as /api/audit/* and /management, so /// it is reachable on the same host/port (and through Traefik to the active node). /// /// /// /// Auth (security boundary). This is a machine-to-machine call — NOT a /// cookie/JWT/LDAP-authenticated operator request. It is gated SOLELY by a /// per-deployment fetch token the site presents in the X-Deployment-Token /// header, compared against the staged row's token in constant time /// (). The endpoint is marked /// .AllowAnonymous(); the central host registers NO authorization /// FallbackPolicy, so an anonymous endpoint is reachable without the /// per-operator Basic/LDAP flow the audit/management endpoints apply by hand. /// The token — short-TTL, single-deployment-scoped, removed on supersession — is /// the entire security boundary. /// /// /// /// Existence hiding. Unknown, superseded (the row is deleted), and expired /// deployments are indistinguishable (all 404 NotFound) to any caller, /// regardless of the presented token. A live, non-expired row with a missing/wrong /// token returns 401 — which DOES confirm the id exists — and that is /// acceptable because deployment ids are unguessable GUIDs and the 256-bit /// per-deployment token is the real security boundary. /// /// public static class DeploymentConfigEndpoints { /// Request header carrying the per-deployment fetch token. public const string TokenHeader = "X-Deployment-Token"; /// /// Registers the GET /api/internal/deployments/{deploymentId}/config /// token-gated fetch endpoint. Marked AllowAnonymous — the per-deployment /// token is the sole security boundary (see the type-level remarks). /// /// The endpoint route builder to register the route on. /// The same builder, for chaining. public static IEndpointRouteBuilder MapDeploymentConfigAPI(this IEndpointRouteBuilder endpoints) { endpoints.MapGet("/api/internal/deployments/{deploymentId}/config", (Delegate)HandleGetConfig) .AllowAnonymous(); return endpoints; } /// /// Thin HTTP adapter: pulls the X-Deployment-Token header and the current /// UTC instant, loads the staged row, and delegates the security decision to the /// pure resolver. /// /// The deployment id route value (the fetch key). /// The HTTP context for the current request. /// The deployment-manager repository resolving the staged row. /// A cancellation token tied to the request lifetime. /// The HTTP result (200 staged JSON, 401, or 404). internal static async Task HandleGetConfig( string deploymentId, HttpContext ctx, IDeploymentManagerRepository repo, CancellationToken ct) { var token = ctx.Request.Headers[TokenHeader].ToString(); var row = await repo.GetPendingDeploymentByIdAsync(deploymentId, ct); return Resolve(row, token, DateTimeOffset.UtcNow); } /// /// Pure, security-critical decision resolver for the fetch endpoint. Order of /// checks matters: existence/TTL are evaluated BEFORE the token so a missing, /// superseded, or expired deployment is indistinguishable (all 404) to a /// caller without a valid token. /// /// The staged pending deployment, or null if unknown/superseded. /// The token presented in the X-Deployment-Token header (may be empty). /// The current UTC instant used for the TTL comparison. /// /// 404 NotFound when is null or expired; /// 401 Unauthorized when the token is missing/empty or does not match; /// otherwise 200 with the staged /// as application/json. /// public static IResult Resolve(PendingDeployment? row, string token, DateTimeOffset nowUtc) { // Unknown OR superseded (supersession deletes the row): hide existence. if (row is null) { return Results.NotFound(); } // Expired (TTL elapsed): treat as gone — also hides existence to a wrong token. if (row.ExpiresAtUtc <= nowUtc) { return Results.NotFound(); } // Token is the entire security boundary; compare in constant time. if (string.IsNullOrEmpty(token) || !DeploymentFetchToken.ConstantTimeEquals(token, row.Token)) { return Results.Unauthorized(); } return Results.Content(row.ConfigurationJson, "application/json"); } }