Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/SiteSecretsCollector.cs
T

109 lines
4.9 KiB
C#

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, the references that could not be resolved (absent or
/// tombstoned) at central, and — when the central secret store itself could not be read — the
/// error that stopped resolution.
/// </summary>
/// <param name="Secrets">The resolved rows.</param>
/// <param name="Missing">References that are absent or tombstoned in the store.</param>
/// <param name="StoreError">
/// <see langword="null"/> when every reference was looked up. Otherwise the store read failed
/// (e.g. the shared SQL Server secret store is unreachable), resolution stopped
/// at the first failure, and <see cref="Secrets"/>/<see cref="Missing"/> are INCOMPLETE — the
/// caller must treat this site as undeployable and its current hash as unknown. The message is
/// the exception type and message only; it never contains secret material.
/// </param>
public sealed record SiteSecretsCollection(
IReadOnlyList<SecretArtifact> Secrets,
IReadOnlyList<SecretReference> Missing,
string? StoreError = null);
/// <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>
/// <param name="lookupCache">
/// Optional per-sweep memo of store lookups, keyed by secret name (a <see langword="null"/>
/// value records "absent"). A multi-site caller passes ONE dictionary for the whole sweep so
/// a secret referenced by N sites is read from the store once, not N times. Only successful
/// lookups are recorded; a store read failure is never cached. Not thread-safe — the
/// callers build their per-site commands serially.
/// </param>
/// <returns>
/// The resolved rows (mapped field-for-field from <see cref="StoredSecret"/>) and the
/// references that were absent or tombstoned in the store. A store read failure is
/// returned as <see cref="SiteSecretsCollection.StoreError"/> rather than thrown, so one
/// site's lookup failing never aborts a whole multi-site sweep; only
/// cancellation of <paramref name="ct"/> propagates. A site whose connections reference no
/// secret never touches the store at all.
/// </returns>
public async Task<SiteSecretsCollection> CollectAsync(
IEnumerable<DataConnectionArtifact> connections, CancellationToken ct,
IDictionary<string, StoredSecret?>? lookupCache = null)
{
var secrets = new List<SecretArtifact>();
var missing = new List<SecretReference>();
foreach (var reference in SecretReferenceScanner.Scan(connections))
{
StoredSecret? row;
try
{
if (lookupCache is null || !lookupCache.TryGetValue(reference.Name, out row))
{
row = await store.GetAsync(new SecretName(reference.Name), ct);
if (lookupCache is not null)
lookupCache[reference.Name] = row;
}
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
return new SiteSecretsCollection(
secrets, missing,
$"Central secret store could not be read while resolving '{reference.Name}' " +
$"({ex.GetType().Name}: {ex.Message})");
}
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);
}
}