feat(deploy): token-gated internal deployment-config fetch endpoint
This commit is contained in:
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user