Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet/DependencyInjection/AkkaSecretsServiceCollectionExtensions.cs
T
Joseph Doherty c3b07d6766 fix(secrets): SqlServer/Akka extensions take contentRootPath; last explicit root wins (scadaproj#31)
The SQL-Server extensions called the 3-arg AddZbSecrets, and the store-path
validator captured the content root of whichever call registered it first.
In hub mode no registration order applied the pre-host under-content-root
guard: extension-first dropped the app's 4-arg root, core-first lost the
replicator (#30). That is the 0.6.0 store-path hole, reopened.

- AddZbSecretsSqlServerStore / AddZbSecretsSqlServerReplication /
  AddZbSecretsAkkaReplication gain a trailing optional contentRootPath,
  forwarded to the 4-arg AddZbSecrets
- the root now lives in one SecretsContentRoot instance holder per service
  collection; the last explicit root wins, null never erases one; the
  IHostEnvironment fallback is unchanged
- tests: ContentRootTests (hub mode, three compositions, artifact asserted
  before the throw, positive control), last-explicit-wins theory in
  SecretsStorePathRulesTests, Akka forwarding test
- code-reviews: Secrets-002 Resolved; README regenerated

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:00:06 -05:00

147 lines
8.1 KiB
C#

using Akka.Actor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replication;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
/// <summary>
/// Dependency-injection entry point for peer-to-peer secret replication over an Akka.NET cluster.
/// </summary>
public static class AkkaSecretsServiceCollectionExtensions
{
/// <summary>
/// Registers secret replication over the cluster: each node keeps its own local store, local
/// writes broadcast to peers, and a periodic manifest exchange repairs any divergence in both
/// directions.
/// </summary>
/// <remarks>
/// <para>
/// Choose this transport when a node must keep resolving secrets while <b>partitioned</b> from
/// the rest of the cluster — reads never leave the node, so an isolated site keeps running on its
/// last-known-good state and re-converges when the partition heals. If every node can always
/// reach a shared database, the SQL-Server package's shared-store mode is simpler and has no
/// distributed failure modes at all; prefer it unless partition tolerance is a stated requirement.
/// </para>
/// <para>
/// <b>Requirements.</b> The application must (1) register its <see cref="ActorSystem"/> in DI
/// before the first secret resolve, and (2) give every node the <b>same KEK</b> — rows carry
/// ciphertext only, so a node with a different master key fails closed on a <c>kek_id</c>
/// mismatch. Merging <see cref="AkkaSecretsReplication.SerializationConfig"/> into the actor
/// system's configuration is recommended.
/// </para>
/// <para>
/// <b>KEK rotation does not replicate.</b> A re-wrap deliberately leaves the revision untouched
/// so it stays invisible to last-writer-wins; run <c>rewrap-all</c> against every node's local
/// store.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="secretsSectionPath">Configuration section holding the core secrets options.</param>
/// <param name="replicationSectionPath">Configuration section holding the replication options.</param>
/// <param name="contentRootPath">
/// The application content root, forwarded to <c>AddZbSecrets</c> so the store-path guard can
/// reject a <c>Secrets:SqlitePath</c> inside it. Pass it from every pre-host composition (a
/// container with no <c>IHostEnvironment</c>, such as a <c>${secret:}</c> expander's), exactly as
/// for the 4-argument <c>AddZbSecrets</c>. The last explicitly supplied root wins across calls.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddZbSecretsAkkaReplication(
this IServiceCollection services,
IConfiguration config,
string secretsSectionPath = "Secrets",
string replicationSectionPath = "Secrets:Replication",
string? contentRootPath = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
ArgumentException.ThrowIfNullOrWhiteSpace(secretsSectionPath);
ArgumentException.ThrowIfNullOrWhiteSpace(replicationSectionPath);
IConfigurationSection section = config.GetSection(replicationSectionPath);
services.Configure<AkkaSecretsReplicationOptions>(section);
// Validate eagerly so a bad interval fails at startup, not on the first announce tick.
var eager = new AkkaSecretsReplicationOptions();
section.Bind(eager);
eager.Validate();
// The actor is created lazily on first use rather than at registration: the ActorSystem is
// typically registered by the application AFTER this call, and a cluster node may also not be
// ready to join at the moment the container is built.
//
// The invalidator MUST stay deferred (scadaproj#1): it is the resolver, the resolver reads
// ISecretStore, and ISecretStore is the ReplicatingSecretStore decorator registered below —
// resolving it here closes a singleton cycle the container cannot detect through factory
// lambdas, and every hosted process then deadlocks at startup inside the container's
// resolution locks. DeferredSecretCacheInvalidator waits until the first eviction, which
// can only happen after the graph has finished resolving.
services.TryAddSingleton<SecretReplicationActorProvider>(sp => new SecretReplicationActorProvider(
sp.GetRequiredService<ActorSystem>(),
sp.GetRequiredService<SqliteSecretStore>(),
new DeferredSecretCacheInvalidator(sp.GetService<ISecretCacheInvalidator>),
sp.GetRequiredService<IOptions<AkkaSecretsReplicationOptions>>().Value));
// The local store + migrator come from AddZbSecrets (SQLite by default).
services.AddZbSecrets(config, secretsSectionPath, contentRootPath);
// REPLACED after the core call rather than TryAdd-ed before it (scadaproj#30). AddZbSecrets
// does TryAddSingleton<ISecretReplicator, NoOpSecretReplicator>(); a TryAdd here only won when
// this package ran first, so an application that had already called AddZbSecrets got
// replication bound to the no-op sink — publishing nothing, no actor ever created, no error
// (the 0.2.0 shape, and still reachable from the application's side until 0.6.6).
services.RemoveAll<ISecretReplicator>();
services.AddSingleton<ISecretReplicator>(sp =>
new AkkaSecretReplicator(sp.GetRequiredService<SecretReplicationActorProvider>().ActorRef));
// Decorate the local store so every write publishes. Resolved from the concrete SQLite store,
// not ISecretStore — asking the container for ISecretStore here would resolve this very
// decorator and recurse forever. The core's ISecretStore forward is removed first so the
// decorator is the only registration in either call order.
services.RemoveAll<ISecretStore>();
services.AddSingleton<ISecretStore>(sp => new ReplicatingSecretStore(
sp.GetRequiredService<SqliteSecretStore>(),
sp.GetRequiredService<ISecretReplicator>(),
sp.GetRequiredService<ILogger<ReplicatingSecretStore>>()));
return services;
}
}
/// <summary>
/// Creates this node's <see cref="SecretReplicationActor"/> on first access and hands out its ref.
/// </summary>
/// <remarks>
/// The indirection exists because the actor must be created lazily — the application usually
/// registers its <see cref="ActorSystem"/> after <c>AddZbSecretsAkkaReplication</c>, so eagerly
/// resolving one at registration time would fail. Creation is guarded by
/// <see cref="Lazy{T}"/> with <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> so
/// concurrent first writes cannot spawn two actors on the same node.
/// </remarks>
/// <param name="system">The application's actor system.</param>
/// <param name="store">The node's local secret store — the undecorated one.</param>
/// <param name="cacheInvalidator">Resolver-cache seam.</param>
/// <param name="options">Replication options.</param>
public sealed class SecretReplicationActorProvider(
ActorSystem system,
ISecretStore store,
ISecretCacheInvalidator? cacheInvalidator,
AkkaSecretsReplicationOptions options)
{
private readonly Lazy<IActorRef> _actorRef = new(
() => system.ActorOf(
SecretReplicationActor.Props(store, cacheInvalidator, options.AnnounceInterval),
options.ActorName),
LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>This node's replication actor, created on first access.</summary>
public IActorRef ActorRef => _actorRef.Value;
}