feat(deploy): token-gated internal deployment-config fetch endpoint

This commit is contained in:
Joseph Doherty
2026-06-26 12:34:34 -04:00
parent a61865daa0
commit 381d26d1b1
3 changed files with 248 additions and 0 deletions
@@ -369,6 +369,10 @@ try
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit // Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
// / AuditExport role sets. // / AuditExport role sets.
app.MapAuditAPI(); app.MapAuditAPI();
// Notify-and-fetch deploy (#2/#3): site-facing token-gated fetch of a staged
// deployment's flattened config. Machine-to-machine — AllowAnonymous, gated
// solely by the per-deployment X-Deployment-Token (no central FallbackPolicy).
app.MapDeploymentConfigAPI();
app.MapHub<ZB.MOM.WW.ScadaBridge.ManagementService.DebugStreamHub>("/hubs/debug-stream"); app.MapHub<ZB.MOM.WW.ScadaBridge.ManagementService.DebugStreamHub>("/hubs/debug-stream");
// Compile and register all Inbound API method scripts at startup // Compile and register all Inbound API method scripts at startup
@@ -0,0 +1,123 @@
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");
}
}
@@ -0,0 +1,121 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Unit tests for the pure decision resolver behind the token-gated internal
/// deployment-config fetch endpoint (<see cref="DeploymentConfigEndpoints.Resolve"/>).
///
/// <para>
/// This endpoint is machine-to-machine: a site presents a per-deployment fetch
/// token in the <c>X-Deployment-Token</c> header and the token is the entire
/// security boundary. The resolver is extracted so the security-critical
/// decision logic is unit-testable without standing up a web host. The mapping
/// from the resolver result to the documented HTTP status codes is:
/// <list type="bullet">
/// <item>unknown / superseded (null row) → 404 NotFound</item>
/// <item>expired row → 404 NotFound (does not leak existence to a wrong token)</item>
/// <item>missing / wrong token → 401 Unauthorized</item>
/// <item>valid row + correct token + not expired → 200 with the staged JSON</item>
/// </list>
/// </para>
/// </summary>
public class DeploymentConfigEndpointsTests
{
private const string ValidToken = "test-deployment-token-abc123";
private const string ConfigJson = "{\"instanceId\":42,\"attributes\":[]}";
private static readonly DateTimeOffset Now = new(2026, 6, 26, 12, 0, 0, TimeSpan.Zero);
private static PendingDeployment Row(string token = ValidToken, DateTimeOffset? expiresAtUtc = null) =>
new(
deploymentId: "deploy-1",
instanceId: 42,
revisionHash: "hash-1",
configurationJson: ConfigJson,
token: token,
createdAtUtc: Now.AddMinutes(-1),
expiresAtUtc: expiresAtUtc ?? Now.AddMinutes(5));
private static int? StatusOf(IResult result) => (result as IStatusCodeHttpResult)?.StatusCode;
[Fact]
public void Resolve_NullRow_ReturnsNotFound()
{
var result = DeploymentConfigEndpoints.Resolve(row: null, token: ValidToken, nowUtc: Now);
Assert.IsType<NotFound>(result);
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
}
[Fact]
public void Resolve_ExpiredRow_WithCorrectToken_ReturnsNotFound()
{
// Expired exactly at now: ExpiresAtUtc <= now is the boundary that purges.
var row = Row(expiresAtUtc: Now);
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
Assert.IsType<NotFound>(result);
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
}
[Fact]
public void Resolve_ExpiredRowInThePast_WithCorrectToken_ReturnsNotFound()
{
var row = Row(expiresAtUtc: Now.AddSeconds(-1));
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
Assert.IsType<NotFound>(result);
Assert.Equal(StatusCodes.Status404NotFound, StatusOf(result));
}
[Fact]
public void Resolve_ValidRow_WrongToken_ReturnsUnauthorized()
{
var row = Row();
var result = DeploymentConfigEndpoints.Resolve(row, token: "wrong-token", nowUtc: Now);
Assert.IsType<UnauthorizedHttpResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
}
[Fact]
public void Resolve_ValidRow_EmptyToken_ReturnsUnauthorized()
{
var row = Row();
var result = DeploymentConfigEndpoints.Resolve(row, token: string.Empty, nowUtc: Now);
Assert.IsType<UnauthorizedHttpResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
}
[Fact]
public void Resolve_ValidRow_NullToken_ReturnsUnauthorized()
{
var row = Row();
var result = DeploymentConfigEndpoints.Resolve(row, token: null!, nowUtc: Now);
Assert.IsType<UnauthorizedHttpResult>(result);
Assert.Equal(StatusCodes.Status401Unauthorized, StatusOf(result));
}
[Fact]
public void Resolve_ValidRow_CorrectToken_NotExpired_ReturnsConfigJson()
{
var row = Row();
var result = DeploymentConfigEndpoints.Resolve(row, token: ValidToken, nowUtc: Now);
var content = Assert.IsType<ContentHttpResult>(result);
Assert.Equal(ConfigJson, content.ResponseContent);
Assert.Equal("application/json", content.ContentType);
}
}