feat(admin): headless POST /api/deployments REST endpoint (API-key gated)
v2-ci / build (push) Failing after 50s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 50s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
A thin gateway over the admin-operations cluster singleton so CI/scripts can trigger a deployment without the Blazor button. Forwards to the same IAdminOperationsClient. StartDeploymentAsync; mounted on admin-role nodes. Auth is a fixed-time X-Api-Key check against Security:DeployApiKey (orthogonal to the cookie-only web auth); AllowAnonymous so the auth fallback doesn't 401 it, self-disabling (503) until the key is set. Outcome->status: 202/200/409/422. Unit tests for the key check + outcome mapping; HTTP E2E (real auth + real deploy via the 2-node harness). Documented in docs/security.md.
This commit is contained in:
@@ -268,6 +268,21 @@ In v2 the authentication + authorization stack is wired centrally by `AddOtOpcUa
|
||||
|
||||
Admin reads `LdapGroupRoleMapping` rows from the Config DB (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs`) — the same pattern as the data-plane `NodeAcl` but scoped to Admin roles + (optionally) one cluster for multi-site fleets (a system-wide row, `IsSystemWide = true`, stacks additively with cluster-scoped rows). The `RoleGrants.razor` page lets `Administrator`s edit these mappings without leaving the UI.
|
||||
|
||||
### Headless deploy API (`POST /api/deployments`)
|
||||
|
||||
For CI / scripts that need to trigger a deployment without driving the Blazor "Deploy current configuration" button, admin-role nodes expose `POST /api/deployments` (`DeployApiEndpoints`, `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs`). It forwards to the same `IAdminOperationsClient.StartDeploymentAsync` the button calls.
|
||||
|
||||
Auth is a **single configured secret** checked from the `X-Api-Key` header in fixed time — deliberately orthogonal to the cookie-only web auth (`OPC UA Authentication` above) so automation needs no LDAP login round-trip. The endpoint is `AllowAnonymous` so the `FallbackPolicy` doesn't 401 it, and enforces the key itself. **It self-disables (503) until `Security:DeployApiKey` is set**, so it is never open by default.
|
||||
|
||||
```bash
|
||||
curl -X POST https://<admin-host>/api/deployments \
|
||||
-H 'X-Api-Key: <Security:DeployApiKey>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"createdBy":"ci-bot"}'
|
||||
```
|
||||
|
||||
Responses: `202 Accepted` (`{ outcome, deploymentId, revisionHash }`) when a deployment was sealed, `200` for `NoChanges`, `409` when another deployment is in flight, `422` when rejected, `401` for a missing/wrong key, `503` when unconfigured. Set the secret via `Security:DeployApiKey` (env `Security__DeployApiKey`) on admin nodes only; treat it like any deploy credential (rotate, keep out of source).
|
||||
|
||||
---
|
||||
|
||||
## OTOPCUA0001 Analyzer — Compile-Time Guard
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Headless REST gateway over the admin-operations cluster singleton, for CI / scripts that
|
||||
/// need to trigger a deployment without driving the Blazor AdminUI. Mounted on admin-role nodes
|
||||
/// only (where <see cref="IAdminOperationsClient"/> + the singleton proxy live), it forwards to
|
||||
/// the same <see cref="IAdminOperationsClient.StartDeploymentAsync"/> the "Deploy current
|
||||
/// configuration" button calls.
|
||||
/// <para>
|
||||
/// Auth is a single configured secret (<c>Security:DeployApiKey</c>) checked from the
|
||||
/// <c>X-Api-Key</c> header in fixed time — deliberately orthogonal to the cookie-only web
|
||||
/// auth so automation needs no LDAP login round-trip. The endpoint is <c>AllowAnonymous</c>
|
||||
/// (so the global <c>RequireAuthenticatedUser</c> fallback doesn't 401 it) and enforces the
|
||||
/// key itself; when the key is unconfigured the endpoint is disabled (503), so it is never
|
||||
/// open.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class DeployApiEndpoints
|
||||
{
|
||||
/// <summary>Header carrying the deploy API key.</summary>
|
||||
public const string ApiKeyHeader = "X-Api-Key";
|
||||
/// <summary>Configuration key holding the deploy API secret.</summary>
|
||||
public const string ConfigKey = "Security:DeployApiKey";
|
||||
|
||||
/// <summary>Maps <c>POST /api/deployments</c> — the headless deploy trigger. No-op-safe to call
|
||||
/// on any admin-role node; the handler self-disables (503) until <c>Security:DeployApiKey</c> is set.</summary>
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
/// <param name="config">Application configuration (read for the deploy API key).</param>
|
||||
/// <returns>The same <paramref name="app"/> for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaDeployApi(this IEndpointRouteBuilder app, IConfiguration config)
|
||||
{
|
||||
var configuredKey = config[ConfigKey];
|
||||
|
||||
app.MapPost("/api/deployments", async (
|
||||
HttpContext http,
|
||||
DeployRequest? body,
|
||||
IAdminOperationsClient adminOps,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuredKey))
|
||||
return Results.Problem(
|
||||
"Deploy API is disabled. Set Security:DeployApiKey to enable it.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim();
|
||||
var result = await adminOps.StartDeploymentAsync(createdBy, ct);
|
||||
return ToResult(result);
|
||||
})
|
||||
.AllowAnonymous() // gated by the API key, not the cookie auth fallback
|
||||
.DisableAntiforgery() // machine endpoint, not a browser form post
|
||||
.WithName("StartDeployment");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>Fixed-time compare of the supplied key against the configured secret. False when
|
||||
/// either is empty (so an unconfigured key never authorizes).</summary>
|
||||
/// <param name="provided">The caller-supplied key (from the <c>X-Api-Key</c> header).</param>
|
||||
/// <param name="configuredKey">The configured secret.</param>
|
||||
/// <returns><see langword="true"/> only when both are non-empty and equal.</returns>
|
||||
public static bool IsAuthorized(string? provided, string? configuredKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuredKey) || string.IsNullOrEmpty(provided)) return false;
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(configuredKey));
|
||||
}
|
||||
|
||||
/// <summary>Map a <see cref="StartDeploymentResult"/> to its HTTP result: Accepted → 202 (with a
|
||||
/// Location), NoChanges → 200, AnotherDeploymentInFlight → 409, Rejected → 422, anything else → 500.</summary>
|
||||
/// <param name="r">The deployment outcome from the admin-operations singleton.</param>
|
||||
/// <returns>The corresponding HTTP result.</returns>
|
||||
public static IResult ToResult(StartDeploymentResult r) => r.Outcome switch
|
||||
{
|
||||
StartDeploymentOutcome.Accepted => Results.Accepted(
|
||||
$"/api/deployments/{r.DeploymentId?.Value}",
|
||||
new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)),
|
||||
StartDeploymentOutcome.NoChanges => Results.Ok(
|
||||
new DeployResponse(r.Outcome.ToString(), r.DeploymentId?.Value, r.RevisionHash?.Value)),
|
||||
StartDeploymentOutcome.AnotherDeploymentInFlight => Results.Conflict(
|
||||
new DeployResponse(r.Outcome.ToString(), DeploymentId: null, RevisionHash: null)),
|
||||
StartDeploymentOutcome.Rejected => Results.Problem(
|
||||
r.Message ?? "Deployment rejected.", statusCode: StatusCodes.Status422UnprocessableEntity),
|
||||
_ => Results.Problem($"Unexpected deployment outcome: {r.Outcome}",
|
||||
statusCode: StatusCodes.Status500InternalServerError),
|
||||
};
|
||||
|
||||
/// <summary>Optional request body. <c>CreatedBy</c> is recorded as the deployment's initiator
|
||||
/// (audit); defaults to <c>"rest-api"</c> when absent.</summary>
|
||||
/// <param name="CreatedBy">Who/what initiated the deploy (for the audit trail).</param>
|
||||
public sealed record DeployRequest(string? CreatedBy);
|
||||
|
||||
/// <summary>Response body for a deploy trigger.</summary>
|
||||
/// <param name="Outcome">The <see cref="StartDeploymentOutcome"/> name.</param>
|
||||
/// <param name="DeploymentId">The new deployment id when one was created; otherwise null.</param>
|
||||
/// <param name="RevisionHash">The config revision hash, when known.</param>
|
||||
public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -183,6 +184,8 @@ if (hasAdmin)
|
||||
app.MapOtOpcUaAuth();
|
||||
app.MapAdminUI<App>();
|
||||
app.MapOtOpcUaHubs();
|
||||
// Headless deploy trigger for CI/scripts (API-key gated; disabled until Security:DeployApiKey set).
|
||||
app.MapOtOpcUaDeployApi(app.Configuration);
|
||||
}
|
||||
|
||||
app.MapOtOpcUaHealth();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Api;
|
||||
|
||||
/// <summary>Unit tests for the pure helpers behind the headless deploy REST endpoint —
|
||||
/// <see cref="DeployApiEndpoints.IsAuthorized"/> (fixed-time API-key check) and
|
||||
/// <see cref="DeployApiEndpoints.ToResult"/> (outcome → HTTP status mapping).</summary>
|
||||
public sealed class DeployApiEndpointsTests
|
||||
{
|
||||
// ── IsAuthorized ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies the exact configured key authorizes.</summary>
|
||||
[Fact]
|
||||
public void IsAuthorized_true_for_matching_key()
|
||||
=> DeployApiEndpoints.IsAuthorized("s3cret", "s3cret").ShouldBeTrue();
|
||||
|
||||
/// <summary>Verifies a wrong key is rejected.</summary>
|
||||
[Fact]
|
||||
public void IsAuthorized_false_for_wrong_key()
|
||||
=> DeployApiEndpoints.IsAuthorized("nope", "s3cret").ShouldBeFalse();
|
||||
|
||||
/// <summary>Verifies a missing/empty provided key is rejected.</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void IsAuthorized_false_for_missing_provided_key(string? provided)
|
||||
=> DeployApiEndpoints.IsAuthorized(provided, "s3cret").ShouldBeFalse();
|
||||
|
||||
/// <summary>Verifies that when no key is configured nothing authorizes (endpoint stays closed).</summary>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void IsAuthorized_false_when_no_key_configured(string? configured)
|
||||
=> DeployApiEndpoints.IsAuthorized("anything", configured).ShouldBeFalse();
|
||||
|
||||
// ── ToResult ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies each deployment outcome maps to the right HTTP status.</summary>
|
||||
[Theory]
|
||||
[InlineData(StartDeploymentOutcome.Accepted, StatusCodes.Status202Accepted)]
|
||||
[InlineData(StartDeploymentOutcome.NoChanges, StatusCodes.Status200OK)]
|
||||
[InlineData(StartDeploymentOutcome.AnotherDeploymentInFlight, StatusCodes.Status409Conflict)]
|
||||
[InlineData(StartDeploymentOutcome.Rejected, StatusCodes.Status422UnprocessableEntity)]
|
||||
public void ToResult_maps_outcome_to_status(StartDeploymentOutcome outcome, int expectedStatus)
|
||||
{
|
||||
var result = DeployApiEndpoints.ToResult(
|
||||
new StartDeploymentResult(outcome, DeploymentId: null, RevisionHash: null,
|
||||
Message: "x", CorrelationId: CorrelationId.NewId()));
|
||||
|
||||
result.ShouldBeAssignableTo<IStatusCodeHttpResult>();
|
||||
((IStatusCodeHttpResult)result).StatusCode.ShouldBe(expectedStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test of the headless deploy REST endpoint over real HTTP against an in-process admin
|
||||
/// node: an unauthenticated/wrong-key POST is rejected, and a correctly-keyed POST triggers a real
|
||||
/// deployment through the same AdminOperations singleton the AdminUI button uses.
|
||||
/// </summary>
|
||||
public sealed class DeployApiE2eTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>Verifies the deploy endpoint enforces the API key and accepts a correctly-keyed call.</summary>
|
||||
[Fact]
|
||||
public async Task Deploy_endpoint_enforces_api_key_and_triggers_a_deployment()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
using var http = new HttpClient { BaseAddress = new Uri(harness.NodeABaseAddress) };
|
||||
|
||||
// No key → 401
|
||||
var noKey = await http.PostAsJsonAsync("/api/deployments", new { createdBy = "ci" }, Ct);
|
||||
noKey.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Wrong key → 401
|
||||
(await SendWithKeyAsync(http, "wrong-key")).StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
|
||||
// Correct key → 202 Accepted (an empty config still composes + seals an empty deployment)
|
||||
var accepted = await SendWithKeyAsync(http, TwoNodeClusterHarness.HarnessDeployApiKey);
|
||||
accepted.StatusCode.ShouldBe(HttpStatusCode.Accepted);
|
||||
|
||||
var payload = await accepted.Content.ReadFromJsonAsync<DeployApiEndpoints.DeployResponse>(Ct);
|
||||
payload.ShouldNotBeNull();
|
||||
payload!.Outcome.ShouldBe("Accepted");
|
||||
payload.DeploymentId.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> SendWithKeyAsync(HttpClient http, string key)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments")
|
||||
{
|
||||
Content = JsonContent.Create(new { createdBy = "ci-bot" }),
|
||||
};
|
||||
req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, key);
|
||||
return http.SendAsync(req, Ct);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
@@ -48,9 +49,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
{
|
||||
public const string TestRoles = "admin,driver";
|
||||
/// <summary>The deploy-API key both harness nodes are configured with (Security:DeployApiKey).</summary>
|
||||
public const string HarnessDeployApiKey = "test-deploy-key";
|
||||
/// <summary>Gets the shared database name for both cluster nodes.</summary>
|
||||
public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}";
|
||||
|
||||
/// <summary>Resolves node A's bound HTTP base address (Kestrel binds an ephemeral port), for
|
||||
/// driving the REST surface over real HTTP in tests.</summary>
|
||||
public string NodeABaseAddress => ResolveBaseAddress(NodeA);
|
||||
|
||||
private static string ResolveBaseAddress(WebApplication app)
|
||||
{
|
||||
var feature = app.Services.GetRequiredService<Microsoft.AspNetCore.Hosting.Server.IServer>()
|
||||
.Features.Get<Microsoft.AspNetCore.Hosting.Server.Features.IServerAddressesFeature>();
|
||||
return feature?.Addresses.FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Node A has no bound HTTP address.");
|
||||
}
|
||||
|
||||
/// <summary>Gets the harness mode configuration from environment variables.</summary>
|
||||
public HarnessMode Mode { get; } = HarnessMode.FromEnvironment();
|
||||
private string? _sqlDbName;
|
||||
@@ -173,6 +188,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
["Cluster:Roles:1"] = "driver",
|
||||
["Security:Jwt:SigningKey"] = "two-node-harness-test-signing-key-with-enough-bytes-for-hs256",
|
||||
["Security:Jwt:Issuer"] = "otopcua-test",
|
||||
["Security:DeployApiKey"] = HarnessDeployApiKey,
|
||||
["Security:Jwt:Audience"] = "otopcua-test",
|
||||
};
|
||||
|
||||
@@ -227,6 +243,7 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
app.MapOtOpcUaAuth();
|
||||
app.MapOtOpcUaHubs();
|
||||
app.MapOtOpcUaHealth();
|
||||
app.MapOtOpcUaDeployApi(app.Configuration);
|
||||
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
|
||||
Reference in New Issue
Block a user