using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Secrets;
///
/// The outcome of one pass: the secret
/// names applied (inserted or updated in this node's own secret store), the names removed
/// (deleted because central tombstoned them and this node had previously mirrored them), and
/// a human-readable failure message per live secret that could not be applied and/or verified
/// on this node.
///
/// Names applied (inserted or updated) this pass.
/// Names deleted this pass because they were previously mirrored and are now tombstoned upstream.
/// One message per live secret that failed to apply and/or decrypt on this node — never includes secret values.
public sealed record MirrorResult(
IReadOnlyList Applied, IReadOnlyList Removed, IReadOnlyList Failed)
{
/// Whether this pass applied or removed anything (used to decide the summary log level).
public bool Changed => Applied.Count > 0 || Removed.Count > 0;
}
///
/// Per-node reconcile of the site-wide, LocalDb-replicated deployed_secrets table into
/// THIS node's own ZB.MOM.WW.Secrets store. Design §3.5 step 2.
///
///
///
/// Runs on EVERY site node — not a singleton — because deployed_secrets row content
/// (ciphertext) is site-wide (replicated by LocalDb), but which names THIS node has actually
/// applied into its own local secret store is node-local fact: mirrored_secrets is
/// deliberately NOT one of LocalDb's replicated tables (see
/// ). Each node reconciles for
/// itself, and — because the two nodes' secret stores are otherwise independent — each node
/// also independently verifies that the deployed ciphertext actually decrypts under ITS OWN
/// master key.
///
///
/// Apply semantics and the confirm-then-ledger fix (fix round 1, review Important #1).
/// applies last-writer-wins strictly on
/// (UpdatedUtc, Revision) — see
/// — and a row that is not
/// strictly newer than the local row is silently ignored, so this mirror never trusts the call
/// blindly: every apply is followed by an read-back, and
/// mirrored_secrets is ledgered only when the local row is confirmed LIVE and its
/// ciphertext (Ciphertext + WrappedDek) matches what was just shipped. Two
/// non-ledgered outcomes are possible, both reported as a
/// entry (never as the decrypt-failure message, which would misdiagnose either case) and never
/// overwritten a second way:
///
/// -
/// The local row is a tombstone (this node previously deleted it — e.g. the site
/// stopped referencing it, then referenced it again unchanged at central, so the shipped row's
/// UpdatedUtc is older than the tombstone's). Re-stamping is safe here: neither
/// UpdatedUtc nor Revision is bound into the AEAD associated data — confirmed
/// against ZB.MOM.WW.Secrets.Crypto.AesGcmEnvelopeCipher, whose body layer binds only
/// SecretName.Value and whose wrap layer binds only KekId — so the mirror
/// re-applies the exact shipped ciphertext with UpdatedUtc = max(now, tombstone.UpdatedUtc +
/// 1 tick) and Revision = tombstone.Revision + 1, which beats the tombstone under LWW
/// without touching anything the cipher verifies. If that confirmed re-apply still does not
/// read back live and matching (a concurrent conflict), the row is reported failed instead of
/// silently left broken.
///
/// -
/// The local row is LIVE with different ciphertext — an out-of-band secret created
/// directly on this node under the same name, stamped strictly newer than what central shipped.
/// It wins LWW legitimately. This mirror does NOT overwrite it, does NOT ledger it (so a later
/// central tombstone of the name can never delete someone's out-of-band secret via this
/// mirror), and does NOT run the decrypt-verify against it (verifying would trivially "succeed"
/// against the operator's own value and mask that the deployed revision never took effect).
///
///
/// Ledgering always uses the SHIPPED artifact's own revision (not any bumped local revision)
/// so a later pass's "is this ledgered at the shipped revision" check stays correct.
///
///
/// Per-row isolation (review Important #2). Each row's apply/verify body runs in its own
/// try/catch; an exception — an unrecognised ContentType from a newer central, a
/// per-row store fault, a malformed name — is caught, reported as a
/// entry naming the secret and the exception type/message (never a value), logged with the same
/// de-spam rule, and does not stop the rest of the pass. Only an exception attributable to the
/// caller's own cancellation (ct.IsCancellationRequested) propagates.
///
///
/// Cache invalidation (review Important #3). The concrete resolver
/// (DefaultSecretResolver) caches decrypted plaintext for a TTL and also implements
/// ; every ZB.MOM.WW.Secrets write path invalidates after a
/// write for exactly this reason. This mirror does the same: after every successful apply
/// (including a tombstone re-materialization) or delete, and before the verify step, it calls
/// (injected optionally — null if the
/// host never registered one) so neither the verify nor a script running immediately afterward
/// can observe a stale pre-rotation plaintext for up to the cache TTL.
///
///
/// Node-local store assumption (review Minor #5). This mirror assumes the site's
/// is node-local (plain local SQLite, or the gRPC-hub follower half,
/// both of which keep writes local). It is NOT safe to run against a site node whose store is
/// decorated by SQL-Server secrets REPLICATION (Secrets:Replication:Mode=SqlServer) — its
/// DeleteAsync publishes the tombstone to the shared hub, so a site merely un-referencing
/// a secret would delete it fleet-wide. Task 11 rejects that configuration on the Site role;
/// no code change is made here.
///
///
/// Resolve semantics and the resolver cache. The trial-resolve verify step goes through
/// , which the concrete resolver caches for a TTL
/// (ResolveCacheTtl, 30 s in the shipped implementation). Because every successful apply
/// or delete now invalidates that cache first (see above), the remaining exposure is narrow: a
/// KEK rotation on this node's OWN master key (not a secret rotation at central) landing inside
/// the cache window of an unrelated resolve. That residual case is still an acceptable false
/// negative for the "did the key change" question — the cache hit still proves this node once
/// decrypted a value with a key that produced that cache entry — and is not a bug in this
/// mirror.
///
///
/// Error-log de-spam. A secret that keeps failing (to apply OR to decrypt) would
/// otherwise log an Error line every reconcile pass forever. This mirror logs Error only the
/// FIRST time a given failure fingerprint (kind + revision + relevant detail, e.g. KekId for a
/// decrypt failure) occurs for a name on this node; identical repeats log at Debug instead. The
/// moment that same name applies/resolves successfully, the remembered fingerprint is cleared,
/// so a LATER failure — even one with an identical fingerprint — logs at Error again. This state
/// is in-memory only (per node process), which matches the ledger's own node-local scope.
///
///
public sealed class DeployedSecretsMirror : BackgroundService
{
private const string DeleteActor = "deployed-secrets-mirror";
private readonly SiteStorageService _storage;
private readonly ISecretStore _store;
private readonly ISecretResolver _resolver;
private readonly ISecretCacheInvalidator? _invalidator;
private readonly IOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
///
/// Per-secret-name record of the failure fingerprint this node has already logged at Error
/// (an apply-conflict or a decrypt failure — see the class remarks). Cleared for a name the
/// moment it applies/resolves successfully, so a later failure — even an identical one —
/// logs at Error again. In-memory only; not a durable ledger, and deliberately so (it exists
/// purely to de-spam this process's logs).
///
private readonly Dictionary _lastLoggedFailure = new(StringComparer.Ordinal);
/// Initializes a new instance of .
/// Site-local storage exposing deployed_secrets and the mirrored_secrets ledger.
/// This node's own secret store.
/// This node's own secret resolver, used to trial-decrypt each deployed secret.
///
/// Optional cache invalidator for (the concrete
/// DefaultSecretResolver implements this itself; null if the host never
/// registered one, in which case no invalidation is attempted).
///
/// Supplies .
/// Logger instance. Never logs secret values — names, revisions, and KEK ids only.
/// Clock driving the reconcile loop's and the tombstone-recovery stamp.
public DeployedSecretsMirror(
SiteStorageService storage,
ISecretStore store,
ISecretResolver resolver,
ISecretCacheInvalidator? invalidator,
IOptions options,
ILogger logger,
TimeProvider time)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_store = store ?? throw new ArgumentNullException(nameof(store));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_invalidator = invalidator;
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_time = time ?? throw new ArgumentNullException(nameof(time));
}
///
/// Runs one reconcile pass: applies every live deployed_secrets row into this
/// node's own secret store (confirmed by read-back before ledgering), deletes rows this
/// node previously mirrored that are now tombstoned upstream, and trial-decrypts every
/// live, confirmed-applied row to verify it resolves under this node's own master key.
/// Safe to call concurrently with itself (and with the hosted loop) — a single in-flight
/// pass is enforced internally.
///
/// A token to cancel the operation.
/// The pass's .
public async Task ReconcileAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
var rows = await _storage.GetDeployedSecretsAsync().ConfigureAwait(false);
var mirrored = (await _storage.GetMirroredSecretsAsync().ConfigureAwait(false))
.ToDictionary(m => m.Name, m => m.Revision, StringComparer.Ordinal);
List applied = [];
List removed = [];
List failed = [];
foreach (var row in rows)
{
try
{
if (row.IsRemoved)
{
await ProcessRemovedRowAsync(row, mirrored, removed, ct).ConfigureAwait(false);
}
else
{
await ProcessLiveRowAsync(row, mirrored, applied, failed, ct).ConfigureAwait(false);
}
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
ReportFailure(
row.Name,
$"error:{row.Secret.Revision}:{ex.GetType().Name}:{ex.Message}",
$"Secret '{row.Name}' failed to process on this node ({ex.GetType().Name}: {ex.Message})",
failed);
}
}
var result = new MirrorResult(applied, removed, failed);
if (result.Changed)
{
_logger.LogInformation(
"Deployed secrets mirror: {Applied} applied, {Removed} removed, {Failed} failed",
applied.Count, removed.Count, failed.Count);
}
else
{
_logger.LogDebug(
"Deployed secrets mirror in sync ({Count} secret(s), {FailedCount} failing)",
rows.Count(r => !r.IsRemoved), failed.Count);
}
return result;
}
finally
{
_gate.Release();
}
}
///
/// Handles a tombstoned deployed_secrets row: deletes it from this node's store, but
/// only if this node had previously ledgered it as mirrored — an out-of-band, same-named
/// local secret this mirror never applied is left alone.
///
private async Task ProcessRemovedRowAsync(
DeployedSecretRow row, Dictionary mirrored, List removed, CancellationToken ct)
{
if (!mirrored.ContainsKey(row.Name))
{
return;
}
await _store.DeleteAsync(new SecretName(row.Name), DeleteActor, ct).ConfigureAwait(false);
_invalidator?.Invalidate(new SecretName(row.Name));
await _storage.RemoveMirroredSecretAsync(row.Name).ConfigureAwait(false);
removed.Add(row.Name);
_lastLoggedFailure.Remove(row.Name);
_logger.LogInformation(
"Removed deployed secret {SecretName} (no longer referenced by this site)",
row.Name);
}
///
/// Handles a live deployed_secrets row: applies it (confirmed by read-back) when not
/// already ledgered at the shipped revision, then verifies it decrypts — unless the apply
/// outcome makes a verify meaningless (an out-of-band local secret won LWW), in which case a
/// single, distinct failure is reported instead.
///
private async Task ProcessLiveRowAsync(
DeployedSecretRow row,
Dictionary mirrored,
List applied,
List failed,
CancellationToken ct)
{
var name = new SecretName(row.Name);
var alreadyLedgered =
mirrored.TryGetValue(row.Name, out var mirroredRevision) && mirroredRevision == row.Secret.Revision;
if (!alreadyLedgered)
{
var outcome = await ApplyAndConfirmAsync(row, name, ct).ConfigureAwait(false);
switch (outcome)
{
case ApplyOutcome.Applied:
await _storage.SetMirroredSecretAsync(row.Name, row.Secret.Revision).ConfigureAwait(false);
_invalidator?.Invalidate(name);
applied.Add(row.Name);
_lastLoggedFailure.Remove(row.Name);
_logger.LogInformation(
"Applied deployed secret {SecretName} r{Revision} (kek {KekId})",
row.Name, row.Secret.Revision, row.Secret.KekId);
break;
case ApplyOutcome.LocalNewer:
ReportFailure(
row.Name,
$"outofband:{row.Secret.Revision}",
$"Secret '{row.Name}' exists on this node with a newer local value; " +
"the deployed revision was not applied",
failed);
return; // Do not verify — that would decrypt the operator's own value.
case ApplyOutcome.Unconfirmed:
ReportFailure(
row.Name,
$"unconfirmed:{row.Secret.Revision}",
$"Secret '{row.Name}' could not be confirmed applied on this node " +
"after a tombstone re-apply attempt",
failed);
return;
}
}
await VerifyAsync(row, failed, ct).ConfigureAwait(false);
}
/// The confirmed outcome of applying one shipped row into this node's store. See the class remarks.
private enum ApplyOutcome
{
/// The local row is now live and its ciphertext matches what was shipped.
Applied,
/// The local row is live with DIFFERENT ciphertext — an out-of-band secret that legitimately won LWW.
LocalNewer,
/// Neither of the above — a tombstone re-apply attempt did not read back as applied.
Unconfirmed,
}
///
/// Calls and confirms the outcome by reading
/// the row back — see the class remarks for why this mirror never trusts the call blindly.
///
private async Task ApplyAndConfirmAsync(DeployedSecretRow row, SecretName name, CancellationToken ct)
{
var shipped = ToStored(row.Secret);
await _store.ApplyReplicatedAsync(shipped, ct).ConfigureAwait(false);
var local = await _store.GetAsync(name, ct).ConfigureAwait(false);
if (local is not null && !local.IsDeleted && MatchesShippedCiphertext(local, shipped))
{
return ApplyOutcome.Applied;
}
if (local is not null && local.IsDeleted)
{
// Neither UpdatedUtc nor Revision is bound into the AEAD associated data (confirmed
// against AesGcmEnvelopeCipher — only SecretName and KekId are AAD), so re-stamping
// strictly past the tombstone is safe: it changes nothing the cipher verifies.
var nowUtc = _time.GetUtcNow();
var tombstoneUtcPlusOneTick = local.UpdatedUtc.AddTicks(1);
var bumpedUpdatedUtc = nowUtc > tombstoneUtcPlusOneTick ? nowUtc : tombstoneUtcPlusOneTick;
var bumped = shipped with { UpdatedUtc = bumpedUpdatedUtc, Revision = local.Revision + 1 };
await _store.ApplyReplicatedAsync(bumped, ct).ConfigureAwait(false);
var reconfirmed = await _store.GetAsync(name, ct).ConfigureAwait(false);
return reconfirmed is not null && !reconfirmed.IsDeleted && MatchesShippedCiphertext(reconfirmed, shipped)
? ApplyOutcome.Applied
: ApplyOutcome.Unconfirmed;
}
// local is live but its ciphertext differs from what was shipped: an out-of-band value
// with a strictly newer (UpdatedUtc, Revision) won LWW. (local is null only in a
// pathological concurrent-delete race; Unconfirmed covers that too.)
return local is null ? ApplyOutcome.Unconfirmed : ApplyOutcome.LocalNewer;
}
private static bool MatchesShippedCiphertext(StoredSecret local, StoredSecret shipped) =>
local.Ciphertext.AsSpan().SequenceEqual(shipped.Ciphertext)
&& local.WrappedDek.AsSpan().SequenceEqual(shipped.WrappedDek);
///
/// Trial-resolves 's secret through to
/// verify it decrypts on this node. On failure, appends a message to
/// and logs with the shared de-spam rule (see the class remarks).
///
private async Task VerifyAsync(DeployedSecretRow row, List failed, CancellationToken ct)
{
bool resolved;
try
{
resolved = await _resolver.GetAsync(new SecretName(row.Name), ct).ConfigureAwait(false) is not null;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
resolved = false;
}
if (resolved)
{
_lastLoggedFailure.Remove(row.Name);
return;
}
var message =
$"Secret '{row.Name}' (kek {row.Secret.KekId}) did not decrypt on this node — " +
"the node's master key differs from central's";
ReportFailure(row.Name, $"decrypt:{row.Secret.Revision}:{row.Secret.KekId}", message, failed);
}
///
/// Appends to and logs it — Error the
/// first time is seen for since it
/// last succeeded, Debug on every identical repeat.
///
private void ReportFailure(string name, string fingerprint, string message, List failed)
{
failed.Add(message);
if (_lastLoggedFailure.TryGetValue(name, out var previous) && previous == fingerprint)
{
_logger.LogDebug("{Failure}", message);
}
else
{
_lastLoggedFailure[name] = fingerprint;
_logger.LogError("{Failure}", message);
}
}
///
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_options.Value.DeployedSecretsMirrorInterval, _time);
do
{
try
{
await ReconcileAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// Covers every other failure, including an OperationCanceledException NOT
// attributable to this service's own shutdown token (e.g. a store/command
// timeout) — that must not silently end the loop (review Minor #4).
_logger.LogWarning(ex, "Deployed secrets mirror pass failed; retrying next interval.");
}
} while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
/// Maps a wire row back to the store's shape.
private static StoredSecret ToStored(SecretArtifact secret) => new()
{
Name = new SecretName(secret.Name),
Description = secret.Description,
ContentType = Enum.Parse(secret.ContentType, ignoreCase: false),
Ciphertext = secret.Ciphertext,
Nonce = secret.Nonce,
Tag = secret.Tag,
WrappedDek = secret.WrappedDek,
WrapNonce = secret.WrapNonce,
WrapTag = secret.WrapTag,
KekId = secret.KekId,
Revision = secret.Revision,
CreatedUtc = secret.CreatedUtc,
UpdatedUtc = secret.UpdatedUtc,
};
///
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
_gate.Dispose();
}
base.Dispose();
}
}