diff --git a/docs/security.md b/docs/security.md index 86ec9106..8c899ec6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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:///api/deployments \ + -H 'X-Api-Key: ' \ + -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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs new file mode 100644 index 00000000..dc969979 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs @@ -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; + +/// +/// 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 + the singleton proxy live), it forwards to +/// the same the "Deploy current +/// configuration" button calls. +/// +/// Auth is a single configured secret (Security:DeployApiKey) checked from the +/// X-Api-Key header in fixed time — deliberately orthogonal to the cookie-only web +/// auth so automation needs no LDAP login round-trip. The endpoint is AllowAnonymous +/// (so the global RequireAuthenticatedUser 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. +/// +/// +public static class DeployApiEndpoints +{ + /// Header carrying the deploy API key. + public const string ApiKeyHeader = "X-Api-Key"; + /// Configuration key holding the deploy API secret. + public const string ConfigKey = "Security:DeployApiKey"; + + /// Maps POST /api/deployments — the headless deploy trigger. No-op-safe to call + /// on any admin-role node; the handler self-disables (503) until Security:DeployApiKey is set. + /// The endpoint route builder. + /// Application configuration (read for the deploy API key). + /// The same for chaining. + 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; + } + + /// Fixed-time compare of the supplied key against the configured secret. False when + /// either is empty (so an unconfigured key never authorizes). + /// The caller-supplied key (from the X-Api-Key header). + /// The configured secret. + /// only when both are non-empty and equal. + 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)); + } + + /// Map a to its HTTP result: Accepted → 202 (with a + /// Location), NoChanges → 200, AnotherDeploymentInFlight → 409, Rejected → 422, anything else → 500. + /// The deployment outcome from the admin-operations singleton. + /// The corresponding HTTP result. + 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), + }; + + /// Optional request body. CreatedBy is recorded as the deployment's initiator + /// (audit); defaults to "rest-api" when absent. + /// Who/what initiated the deploy (for the audit trail). + public sealed record DeployRequest(string? CreatedBy); + + /// Response body for a deploy trigger. + /// The name. + /// The new deployment id when one was created; otherwise null. + /// The config revision hash, when known. + public sealed record DeployResponse(string Outcome, Guid? DeploymentId, string? RevisionHash); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index fafbb6d4..00c1622c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -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.MapOtOpcUaHubs(); + // Headless deploy trigger for CI/scripts (API-key gated; disabled until Security:DeployApiKey set). + app.MapOtOpcUaDeployApi(app.Configuration); } app.MapOtOpcUaHealth(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeployApiEndpointsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeployApiEndpointsTests.cs new file mode 100644 index 00000000..6d7c30a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Api/DeployApiEndpointsTests.cs @@ -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; + +/// Unit tests for the pure helpers behind the headless deploy REST endpoint — +/// (fixed-time API-key check) and +/// (outcome → HTTP status mapping). +public sealed class DeployApiEndpointsTests +{ + // ── IsAuthorized ──────────────────────────────────────────────────────────── + + /// Verifies the exact configured key authorizes. + [Fact] + public void IsAuthorized_true_for_matching_key() + => DeployApiEndpoints.IsAuthorized("s3cret", "s3cret").ShouldBeTrue(); + + /// Verifies a wrong key is rejected. + [Fact] + public void IsAuthorized_false_for_wrong_key() + => DeployApiEndpoints.IsAuthorized("nope", "s3cret").ShouldBeFalse(); + + /// Verifies a missing/empty provided key is rejected. + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsAuthorized_false_for_missing_provided_key(string? provided) + => DeployApiEndpoints.IsAuthorized(provided, "s3cret").ShouldBeFalse(); + + /// Verifies that when no key is configured nothing authorizes (endpoint stays closed). + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsAuthorized_false_when_no_key_configured(string? configured) + => DeployApiEndpoints.IsAuthorized("anything", configured).ShouldBeFalse(); + + // ── ToResult ──────────────────────────────────────────────────────────────── + + /// Verifies each deployment outcome maps to the right HTTP status. + [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)result).StatusCode.ShouldBe(expectedStatus); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiE2eTests.cs new file mode 100644 index 00000000..1bf690b2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiE2eTests.cs @@ -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; + +/// +/// 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. +/// +public sealed class DeployApiE2eTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// Verifies the deploy endpoint enforces the API key and accepts a correctly-keyed call. + [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(Ct); + payload.ShouldNotBeNull(); + payload!.Outcome.ShouldBe("Accepted"); + payload.DeploymentId.ShouldNotBeNull(); + } + + private static Task 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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index cd057db9..b0141d1c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -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"; + /// The deploy-API key both harness nodes are configured with (Security:DeployApiKey). + public const string HarnessDeployApiKey = "test-deploy-key"; /// Gets the shared database name for both cluster nodes. public string SharedDbName { get; } = $"two-node-cluster-{Guid.NewGuid():N}"; + /// Resolves node A's bound HTTP base address (Kestrel binds an ephemeral port), for + /// driving the REST surface over real HTTP in tests. + public string NodeABaseAddress => ResolveBaseAddress(NodeA); + + private static string ResolveBaseAddress(WebApplication app) + { + var feature = app.Services.GetRequiredService() + .Features.Get(); + return feature?.Addresses.FirstOrDefault() + ?? throw new InvalidOperationException("Node A has no bound HTTP address."); + } + /// Gets the harness mode configuration from environment variables. 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;