diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 66c736f9..6c1eafe3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -369,6 +369,10 @@ try // Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit // / AuditExport role sets. 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("/hubs/debug-stream"); // Compile and register all Inbound API method scripts at startup diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DeploymentConfigEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DeploymentConfigEndpoints.cs new file mode 100644 index 00000000..da99de40 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DeploymentConfigEndpoints.cs @@ -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; + +/// +/// 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"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs new file mode 100644 index 00000000..8bde3493 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DeploymentConfigEndpointsTests.cs @@ -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; + +/// +/// Unit tests for the pure decision resolver behind the token-gated internal +/// deployment-config fetch endpoint (). +/// +/// +/// This endpoint is machine-to-machine: a site presents a per-deployment fetch +/// token in the X-Deployment-Token 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: +/// +/// unknown / superseded (null row) → 404 NotFound +/// expired row → 404 NotFound (does not leak existence to a wrong token) +/// missing / wrong token → 401 Unauthorized +/// valid row + correct token + not expired → 200 with the staged JSON +/// +/// +/// +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(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(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(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(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(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(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(result); + Assert.Equal(ConfigJson, content.ResponseContent); + Assert.Equal("application/json", content.ContentType); + } +}