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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user