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
@@ -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);
}
}