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; /// /// Result of : 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. /// /// The resolved rows. /// References that are absent or tombstoned in the store. /// /// 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 / 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. /// public sealed record SiteSecretsCollection( IReadOnlyList Secrets, IReadOnlyList Missing, string? StoreError = null); /// /// Central half of design §3.1/§3.3: derives which secrets a site's data connections reference /// (via ) and resolves each one against the central /// , verbatim, as its ciphertext row. Never /// decrypts and never sees plaintext — the store's already /// returns only the encrypted representation. /// public sealed class SiteSecretsCollector(ISecretStore store) { /// /// Scans for secret:<name> references and /// resolves each distinct name against the central secret store. /// /// The site's data-connection artifacts to scan. /// A token to cancel the operation. /// /// Optional per-sweep memo of store lookups, keyed by secret name (a /// 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. /// /// /// The resolved rows (mapped field-for-field from ) and the /// references that were absent or tombstoned in the store. A store read failure is /// returned as rather than thrown, so one /// site's lookup failing never aborts a whole multi-site sweep; only /// cancellation of propagates. A site whose connections reference no /// secret never touches the store at all. /// public async Task CollectAsync( IEnumerable connections, CancellationToken ct, IDictionary? lookupCache = null) { var secrets = new List(); var missing = new List(); 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); } }