124 lines
5.9 KiB
C#
124 lines
5.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="PendingDeployment"/> 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).
|
|
///
|
|
/// <para>
|
|
/// <b>Route.</b> <c>GET /api/internal/deployments/{deploymentId}/config</c>, mapped
|
|
/// in the same central-role block as <c>/api/audit/*</c> and <c>/management</c>, so
|
|
/// it is reachable on the same host/port (and through Traefik to the active node).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Auth (security boundary).</b> 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 <c>X-Deployment-Token</c>
|
|
/// header, compared against the staged row's token in constant time
|
|
/// (<see cref="DeploymentFetchToken.ConstantTimeEquals"/>). The endpoint is marked
|
|
/// <c>.AllowAnonymous()</c>; the central host registers NO authorization
|
|
/// <c>FallbackPolicy</c>, 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Existence hiding.</b> Unknown, superseded (the row is deleted), and expired
|
|
/// deployments are indistinguishable (all <c>404 NotFound</c>) to any caller,
|
|
/// regardless of the presented token. A live, non-expired row with a missing/wrong
|
|
/// token returns <c>401</c> — 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.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class DeploymentConfigEndpoints
|
|
{
|
|
/// <summary>Request header carrying the per-deployment fetch token.</summary>
|
|
public const string TokenHeader = "X-Deployment-Token";
|
|
|
|
/// <summary>
|
|
/// Registers the <c>GET /api/internal/deployments/{deploymentId}/config</c>
|
|
/// token-gated fetch endpoint. Marked <c>AllowAnonymous</c> — the per-deployment
|
|
/// token is the sole security boundary (see the type-level remarks).
|
|
/// </summary>
|
|
/// <param name="endpoints">The endpoint route builder to register the route on.</param>
|
|
/// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
|
|
public static IEndpointRouteBuilder MapDeploymentConfigAPI(this IEndpointRouteBuilder endpoints)
|
|
{
|
|
endpoints.MapGet("/api/internal/deployments/{deploymentId}/config", (Delegate)HandleGetConfig)
|
|
.AllowAnonymous();
|
|
return endpoints;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Thin HTTP adapter: pulls the <c>X-Deployment-Token</c> header and the current
|
|
/// UTC instant, loads the staged row, and delegates the security decision to the
|
|
/// pure <see cref="Resolve"/> resolver.
|
|
/// </summary>
|
|
/// <param name="deploymentId">The deployment id route value (the fetch key).</param>
|
|
/// <param name="ctx">The HTTP context for the current request.</param>
|
|
/// <param name="repo">The deployment-manager repository resolving the staged row.</param>
|
|
/// <param name="ct">A cancellation token tied to the request lifetime.</param>
|
|
/// <returns>The HTTP result (200 staged JSON, 401, or 404).</returns>
|
|
internal static async Task<IResult> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>404</c>) to a
|
|
/// caller without a valid token.
|
|
/// </summary>
|
|
/// <param name="row">The staged pending deployment, or <c>null</c> if unknown/superseded.</param>
|
|
/// <param name="token">The token presented in the <c>X-Deployment-Token</c> header (may be empty).</param>
|
|
/// <param name="nowUtc">The current UTC instant used for the TTL comparison.</param>
|
|
/// <returns>
|
|
/// <c>404 NotFound</c> when <paramref name="row"/> is null or expired;
|
|
/// <c>401 Unauthorized</c> when the token is missing/empty or does not match;
|
|
/// otherwise <c>200</c> with the staged <see cref="PendingDeployment.ConfigurationJson"/>
|
|
/// as <c>application/json</c>.
|
|
/// </returns>
|
|
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");
|
|
}
|
|
}
|