feat(artifacts): central ships each site's referenced secrets (ciphertext) and fails closed on a missing one

ArtifactDeploymentService now resolves each site's secret: references (via
the new SiteSecretsCollector, wrapping ISecretStore) into DeployArtifactsCommand.Secrets,
never plaintext. A referenced secret that is absent or tombstoned fails
that site's deploy before anything is sent, with a named error, while the
rest of the batch still deploys; ComputeCurrentArtifactHashesAsync instead
folds a MissingSecrets marker into the hash so such a site reads Out of
date. Central logs what it ships (name@revision) and errors on what's
missing, never material.

Also updates the two other direct ArtifactDeploymentService construction
sites (PendingUpdatesEvaluatorTests, CentralUI SitesPageTests) for the new
constructor parameter.
This commit is contained in:
Joseph Doherty
2026-09-28 18:07:17 -04:00
parent a57c36f504
commit 75b2a7244c
8 changed files with 508 additions and 22 deletions
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -40,6 +41,7 @@ public class ArtifactDeploymentService
private readonly DeploymentManagerOptions _options;
private readonly ILogger<ArtifactDeploymentService> _logger;
private readonly ArtifactRevisionHashService _hashService;
private readonly SiteSecretsCollector _secretsCollector;
/// <summary>
/// Initializes a new instance of the ArtifactDeploymentService.
@@ -60,8 +62,13 @@ public class ArtifactDeploymentService
/// <param name="logger">Logger instance.</param>
/// <param name="hashService">
/// Computes the deterministic revision hash of a per-site artifact set, stamped on the
/// <see cref="Site"/> row after a successful deploy. Last on the signature so the
/// addition stays positionally additive for existing call sites.
/// <see cref="Site"/> row after a successful deploy. Second-to-last on the signature so
/// the addition stayed positionally additive for existing call sites at the time it was
/// introduced.
/// </param>
/// <param name="secretsCollector">
/// Resolves a site's referenced secrets (ciphertext rows) against the central secret
/// store (design §3.1/§3.3). Last on the signature for the same reason.
/// </param>
public ArtifactDeploymentService(
ISiteRepository siteRepo,
@@ -73,7 +80,8 @@ public class ArtifactDeploymentService
IAuditService auditService,
IOptions<DeploymentManagerOptions> options,
ILogger<ArtifactDeploymentService> logger,
ArtifactRevisionHashService hashService)
ArtifactRevisionHashService hashService,
SiteSecretsCollector secretsCollector)
{
_siteRepo = siteRepo;
_deploymentRepo = deploymentRepo;
@@ -87,6 +95,7 @@ public class ArtifactDeploymentService
_options = options.Value;
_logger = logger;
_hashService = hashService;
_secretsCollector = secretsCollector;
}
/// <summary>
@@ -101,7 +110,12 @@ public class ArtifactDeploymentService
/// UI summary, and persisted record correlate. When <c>null</c> a fresh id is minted (used by
/// single-site retries).
/// </param>
/// <returns>A deployment artifacts command for the site.</returns>
/// <returns>
/// The deployment artifacts command for the site, and any secret references its data
/// connections make that could not be resolved at central (absent or tombstoned). A
/// non-empty <c>Missing</c> list means the command must NOT be sent to the site —
/// see <see cref="DeployCoreAsync"/>/<see cref="RetryForSiteAsync"/>.
/// </returns>
/// <remarks>
/// This convenience overload runs the global artifact queries
/// for a single site (used by <see cref="RetryForSiteAsync"/>). The multi-site
@@ -109,7 +123,7 @@ public class ArtifactDeploymentService
/// per-site loop and calls the prefetched-globals overload to avoid the N+1
/// re-query of every system-wide artifact set per site.
/// </remarks>
public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
public async Task<(DeployArtifactsCommand Command, IReadOnlyList<SecretReference> Missing)> BuildDeployArtifactsCommandAsync(
int siteId,
CancellationToken cancellationToken = default,
string? deploymentId = null)
@@ -121,7 +135,8 @@ public class ArtifactDeploymentService
/// <summary>
/// Builds a per-site <see cref="DeployArtifactsCommand"/> using a previously-fetched
/// snapshot of the global artifact sets (shared scripts, external systems + methods,
/// DB connections). Only the per-site data-connection query runs here.
/// DB connections). Only the per-site data-connection query — and the per-site secret
/// resolution it drives — runs here.
/// </summary>
/// <remarks>
/// Separating the global fetch from the per-site build lets
@@ -134,8 +149,15 @@ public class ArtifactDeploymentService
/// delivery is central-only and no notification artifact or SMTP credential is
/// ever distributed to a site. The fields remain on the contract only for
/// additive compatibility.
///
/// The command's <c>Secrets</c>/<c>MissingSecrets</c> fields (design §3.1/§3.3) are
/// populated from <see cref="SiteSecretsCollector.CollectAsync"/> regardless of whether
/// any reference is missing — <c>MissingSecrets</c> is CENTRAL-ONLY (never mapped to the
/// wire) and exists solely so <see cref="ArtifactRevisionHashService"/> can fold a
/// distinguishable marker into the hash even for a command the caller ultimately refuses
/// to send (see the returned <c>Missing</c> tuple element).
/// </remarks>
private async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
private async Task<(DeployArtifactsCommand Command, IReadOnlyList<SecretReference> Missing)> BuildDeployArtifactsCommandAsync(
int siteId,
GlobalArtifactSnapshot globals,
CancellationToken cancellationToken,
@@ -147,7 +169,9 @@ public class ArtifactDeploymentService
var dataConnectionArtifacts = dataConnections.Select(dc =>
new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).ToList();
return new DeployArtifactsCommand(
var collected = await _secretsCollector.CollectAsync(dataConnectionArtifacts, cancellationToken);
var command = new DeployArtifactsCommand(
deploymentId ?? Guid.NewGuid().ToString("N"),
globals.SharedScripts,
globals.ExternalSystems,
@@ -157,9 +181,23 @@ public class ArtifactDeploymentService
dataConnectionArtifacts,
// SMTP config (incl. credentials) is central-only — never shipped to sites.
SmtpConfigurations: null,
DateTimeOffset.UtcNow);
DateTimeOffset.UtcNow,
Secrets: collected.Secrets.Count > 0 ? collected.Secrets : null,
MissingSecrets: collected.Missing.Count > 0 ? collected.Missing.Select(m => m.Name).ToList() : null);
return (command, collected.Missing);
}
/// <summary>
/// Formats the fail-closed message for one or more secret references a site's data
/// connections make that could not be resolved at central (design §3.3). Never includes
/// secret material — only names and the referencing connection names.
/// </summary>
private static string BuildMissingSecretsMessage(IReadOnlyList<SecretReference> missing) =>
string.Join("; ", missing.Select(m =>
$"Secret '{m.Name}' referenced by data connection(s) {string.Join(", ", m.ReferencedBy)} " +
"is not in the central secret store"));
/// <summary>
/// Fetches the system-wide artifact sets that are identical across every site —
/// shared scripts, external systems (with their methods serialized in), and
@@ -297,7 +335,11 @@ public class ArtifactDeploymentService
// Every per-site command carries the SAME logical
// deploymentId, so the per-site commands, audit log, persisted record,
// and UI summary all reference one id instead of N+1 unrelated GUIDs.
var siteCommands = new Dictionary<int, DeployArtifactsCommand>();
//
// Each entry also carries the site's unresolved secret references (design §3.3):
// a non-empty Missing list means this site's command must never reach
// _communicationService — see the fan-out loop below.
var siteCommands = new Dictionary<int, (DeployArtifactsCommand Command, IReadOnlyList<SecretReference> Missing)>();
foreach (var site in sites)
{
siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(
@@ -327,7 +369,28 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite);
var command = siteCommands[site.Id];
var (command, missing) = siteCommands[site.Id];
// Fail this site closed BEFORE anything is sent (design §3.3): a referenced
// secret that is absent or tombstoned at central must not let the rest of the
// artifact set ship, or the site would read *Current* while a connection
// cannot resolve its key. Other sites in the batch are unaffected.
if (missing.Count > 0)
{
var missingMessage = BuildMissingSecretsMessage(missing);
_logger.LogError(
"Cannot deploy artifacts to site {SiteId} ({SiteName}): {Error}",
site.SiteIdentifier, site.Name, missingMessage);
return new SiteArtifactResult(site.SiteIdentifier, site.Name, false, missingMessage);
}
if (command.Secrets is { Count: > 0 })
{
_logger.LogInformation(
"Deploying {Count} secret(s) to site {SiteId}: {Secrets}",
command.Secrets.Count, site.SiteIdentifier,
string.Join(", ", command.Secrets.Select(s => $"{s.Name}@r{s.Revision}")));
}
_logger.LogInformation(
"Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}",
@@ -405,7 +468,7 @@ public class ArtifactDeploymentService
if (successfulSites.Count > 0)
{
await StampArtifactRevisionAsync(
successfulSites.Select(site => (site, siteCommands[site.Id])).ToList(),
successfulSites.Select(site => (site, siteCommands[site.Id].Command)).ToList(),
cancellationToken);
}
@@ -442,7 +505,26 @@ public class ArtifactDeploymentService
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite);
var command = await BuildDeployArtifactsCommandAsync(siteDbId, cts.Token);
var (command, missing) = await BuildDeployArtifactsCommandAsync(siteDbId, cts.Token);
// Same fail-closed rule as the sweep path (design §3.3): a retry must not ship a
// site an artifact set whose secret references still don't resolve.
if (missing.Count > 0)
{
var missingMessage = BuildMissingSecretsMessage(missing);
_logger.LogError(
"Cannot deploy artifacts to site {SiteId}: {Error}", siteIdentifier, missingMessage);
return Result<SiteArtifactResult>.Failure(missingMessage);
}
if (command.Secrets is { Count: > 0 })
{
_logger.LogInformation(
"Deploying {Count} secret(s) to site {SiteId}: {Secrets}",
command.Secrets.Count, siteIdentifier,
string.Join(", ", command.Secrets.Select(s => $"{s.Name}@r{s.Revision}")));
}
var response = await _communicationService.DeployArtifactsAsync(siteIdentifier, command, cts.Token);
var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage);
@@ -512,7 +594,11 @@ public class ArtifactDeploymentService
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
foreach (var site in sites)
{
var command = await BuildDeployArtifactsCommandAsync(
// The Missing tuple element is deliberately unused here: ComputeHash reads
// MissingSecrets straight off the command (design §3.4), so an unresolved
// reference still folds a distinguishable marker into the hash — the site reads
// *Out of date* instead of this call failing the whole batch.
var (command, _) = await BuildDeployArtifactsCommandAsync(
site.Id, globals, cancellationToken, deploymentId: "current");
hashes[site.Id] = _hashService.ComputeHash(command);
}
@@ -59,6 +59,17 @@ public static class ServiceCollectionExtensions
services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>();
// Resolves a site's referenced secrets (ciphertext rows) against the central
// ISecretStore for the artifact deploy (design §3.1/§3.3). Singleton — it holds no
// state and the store it wraps is itself registered as a singleton
// (SecretsRegistration.AddScadaBridgeSecrets). Depends on ISecretStore, which the
// Central composition root always registers (Program.cs calls AddScadaBridgeSecrets
// before AddDeploymentManager's registrations are resolved) — a composition that
// builds ArtifactDeploymentService without ISecretStore registered is a wiring bug,
// not a supported configuration, so this deliberately does NOT fall back to a
// null/no-op store.
services.AddSingleton<SiteSecretsCollector>();
// Deterministic hash of a per-site artifact set, stamped on the Site row after a
// successful artifact deploy and recomputed to answer "are this site's artifacts
// current?". Singleton — it holds no state and touches no DbContext.
@@ -0,0 +1,68 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Secrets;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Result of <see cref="SiteSecretsCollector.CollectAsync"/>: the resolved ciphertext rows for a
/// site's referenced secrets, and the references that could not be resolved (absent or
/// tombstoned) at central.
/// </summary>
public sealed record SiteSecretsCollection(
IReadOnlyList<SecretArtifact> Secrets,
IReadOnlyList<SecretReference> Missing);
/// <summary>
/// Central half of design §3.1/§3.3: derives which secrets a site's data connections reference
/// (via <see cref="SecretReferenceScanner"/>) and resolves each one against the central
/// <see cref="ISecretStore"/>, verbatim, as its ciphertext <see cref="StoredSecret"/> row. Never
/// decrypts and never sees plaintext — the store's <see cref="ISecretStore.GetAsync"/> already
/// returns only the encrypted representation.
/// </summary>
public sealed class SiteSecretsCollector(ISecretStore store)
{
/// <summary>
/// Scans <paramref name="connections"/> for <c>secret:&lt;name&gt;</c> references and
/// resolves each distinct name against the central secret store.
/// </summary>
/// <param name="connections">The site's data-connection artifacts to scan.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// The resolved rows (mapped field-for-field from <see cref="StoredSecret"/>) and the
/// references that were absent or tombstoned in the store.
/// </returns>
public async Task<SiteSecretsCollection> CollectAsync(
IEnumerable<DataConnectionArtifact> connections, CancellationToken ct)
{
var secrets = new List<SecretArtifact>();
var missing = new List<SecretReference>();
foreach (var reference in SecretReferenceScanner.Scan(connections))
{
var row = await store.GetAsync(new SecretName(reference.Name), ct);
if (row is null || row.IsDeleted)
{
missing.Add(reference);
continue;
}
secrets.Add(new SecretArtifact(
row.Name.Value,
row.ContentType.ToString(),
row.Ciphertext,
row.Nonce,
row.Tag,
row.WrappedDek,
row.WrapNonce,
row.WrapTag,
row.KekId,
row.Revision,
row.CreatedUtc,
row.UpdatedUtc,
row.Description));
}
return new SiteSecretsCollection(secrets, missing);
}
}
@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="ZB.MOM.WW.Configuration" />
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup>
<ItemGroup>