feat(transport): production-boundary signing advisory, secret-key proof, runbook (#110)

Owner decisions on #110: one signing key per environment; TrustedKeys stay hand-edited;
Signing:ProductionBoundary (new, default off) logs a startup warning and reports a Degraded
bundle-signing-policy /health/ready entry while RequireSignedBundles is off; the private key
is read via ${secret:} (proved end to end, never logged); cluster pull decided not to be
built; differential deletions stay deferred. Adds docs/operations/bundle-signing-runbook.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:50:18 -05:00
parent a4806576af
commit 559e02f5c8
11 changed files with 421 additions and 10 deletions
@@ -0,0 +1,43 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Signing;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// /health/ready entry (ScadaBridge#110): Degraded while a cluster marked
/// <c>Transport:Signing:ProductionBoundary</c> still accepts unsigned bundles. Degraded maps
/// to HTTP 200 in the shared health tier, so it is visible on the Overview dashboard without
/// taking the node out of rotation. Exposes only booleans — never key material.
/// </summary>
public sealed class BundleSigningPolicyHealthCheck : IHealthCheck
{
private readonly IOptionsMonitor<TransportOptions> _options;
/// <summary>Initializes the check.</summary>
/// <param name="options">Transport options.</param>
public BundleSigningPolicyHealthCheck(IOptionsMonitor<TransportOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
var signing = _options.CurrentValue.Signing;
var data = new Dictionary<string, object>(StringComparer.Ordinal)
{
["productionBoundary"] = signing.ProductionBoundary,
["requireSignedBundles"] = signing.RequireSignedBundles,
["signingEnabled"] = signing.Enabled,
["trustedKeys"] = signing.TrustedKeys.Count,
};
var advisory = BundleSigningPolicyAdvisory.Evaluate(signing);
return Task.FromResult(advisory is null
? HealthCheckResult.Healthy("Bundle signing policy OK.", data)
: HealthCheckResult.Degraded(advisory, data: data));
}
}
@@ -568,6 +568,12 @@ try
"required-singletons",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
// ScadaBridge#110: Degraded (HTTP 200) while a Transport:Signing:ProductionBoundary
// cluster still accepts unsigned bundles.
.AddTypeActivatedCheck<BundleSigningPolicyHealthCheck>(
"bundle-signing-policy",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
// Oldest-Up-member check (review 01 [High]), unscoped: every central member competes for
// the one active slot. Since ZB.MOM.WW.Health 0.3.0 this IS the shared check — it selects
// by age, so the host-local OldestNodeActiveHealthCheck that existed only to avoid the old
@@ -71,6 +71,9 @@ public static class ServiceCollectionExtensions
// T-007: periodic eviction sweep so abandoned sessions clear without
// needing a fresh Get() to trigger lazy eviction.
services.AddHostedService<BundleSessionEvictionService>();
// ScadaBridge#110: warn at startup when a production-boundary cluster does not
// require signed bundles.
services.AddHostedService<BundleSigningPolicyStartupWarning>();
// SemanticValidator is a stateless utility used by ApplyAsync; use
// TryAdd so a host that already calls AddTemplateEngine() (which
// registers the same type as Transient) wins. Either registration
@@ -0,0 +1,61 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Transport.Signing;
/// <summary>
/// ScadaBridge#110: the production-boundary signing advisory. An environment marked
/// <c>ScadaBridge:Transport:Signing:ProductionBoundary</c> is expected to run with
/// <c>RequireSignedBundles</c> on; this is the single place that decides whether that
/// expectation is unmet, shared by the startup warning and the readiness health check.
/// </summary>
public static class BundleSigningPolicyAdvisory
{
/// <summary>
/// Evaluates the signing options.
/// </summary>
/// <param name="signing">The bound signing options.</param>
/// <returns>The advisory text when a production-boundary cluster accepts unsigned bundles; otherwise <c>null</c>.</returns>
public static string? Evaluate(TransportSigningOptions signing)
{
ArgumentNullException.ThrowIfNull(signing);
if (!signing.ProductionBoundary || signing.RequireSignedBundles)
{
return null;
}
return "ScadaBridge:Transport:Signing:ProductionBoundary is true but RequireSignedBundles is false: "
+ "this production-boundary cluster will import unsigned bundles and bundles signed by untrusted keys. "
+ "Turn on RequireSignedBundles once every exporting environment signs and its public key is in TrustedKeys.";
}
}
/// <summary>
/// ScadaBridge#110: logs the <see cref="BundleSigningPolicyAdvisory"/> once at startup.
/// </summary>
internal sealed class BundleSigningPolicyStartupWarning : IHostedService
{
private readonly IOptions<TransportOptions> _options;
private readonly ILogger<BundleSigningPolicyStartupWarning> _logger;
public BundleSigningPolicyStartupWarning(
IOptions<TransportOptions> options, ILogger<BundleSigningPolicyStartupWarning> logger)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task StartAsync(CancellationToken cancellationToken)
{
var advisory = BundleSigningPolicyAdvisory.Evaluate(_options.Value.Signing);
if (advisory is not null)
{
_logger.LogWarning("{Advisory}", advisory);
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -102,6 +102,17 @@ public sealed class TransportSigningOptions
/// </summary>
public bool RequireSignedBundles { get; set; }
/// <summary>
/// Owner decision (ScadaBridge#110): marks this cluster as sitting on the production
/// boundary — an environment that imports bundles from other environments. While it is
/// <c>true</c> and <see cref="RequireSignedBundles"/> is <c>false</c> the node logs a
/// startup WARNING and the central <c>bundle-signing-policy</c> readiness check reports
/// Degraded. It changes no import behaviour: <see cref="RequireSignedBundles"/> stays
/// default-off in code, and is flipped per environment once every exporting cluster signs.
/// Default <c>false</c>.
/// </summary>
public bool ProductionBoundary { get; set; }
/// <summary>
/// Public keys this cluster trusts to sign bundles. Each entry's key id is the
/// <c>sha256:</c> fingerprint of its SubjectPublicKeyInfo, so no id needs to be