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");
}
}