0702b31029
v2-ci / unit-tests (push) Waiting to run
Item 3 of #765. A keyed (API-key) session whose key had no Galaxy ack identity got Good and then a revert, because whether the key has an identity lives in the Secrets store, which must not be read under the SDK lock. GalaxyAckIdentityDirectory (Driver.Galaxy) keeps a node-wide set of the key ids that have a galaxy-ack-user secret. It lists this node's Secrets store (metadata only, nothing decrypted) and is read lock-free. GalaxyDriver.GetAcknowledgeRefusal (IAlarmAcknowledgePreflight, published through NativeAlarmAckPreflightView as in #760) now refuses a keyed session whose key the directory reports Absent, when the driver has no fallback ack account. A refusal also asks for a refresh. Fails open: before the first refresh, once the snapshot is older than StaleAfter, and from a signalled identity-secret change until the next refresh, the lookup answers Unknown and the ack takes the old Good-then-revert path. Refresh: GalaxyAckIdentityRefreshService runs on driver nodes, every Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds (default 30; StaleAfterSeconds 0 = three intervals; validated at start). It also refreshes early on secret changes. The Secrets library has no change event, so SecretsRegistration decorates ISecretCacheInvalidator, which is called by AdminUI /api-keys, /admin/secrets and the replication appliers. API-key changes need no event: a revoked key is already denied before the pre-check. Tests: Galaxy.Tests GalaxyAckIdentityDirectoryTests (16) and Host.Tests GalaxyAckIdentityCacheWiringTests (9). Seven separate mutations each turn at least one test red: pre-check ignores the directory, factory drops it, no invalidator decoration, no staleness check, no change-pending check, no refresh on refusal, loop never wakes early. Docs: Galaxy.md, AlarmTracking.md, Configuration.md, .claude/rules/alarms.md. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
107 lines
5.5 KiB
C#
107 lines
5.5 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.DependencyInjection;
|
|
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.DependencyInjection;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
/// <summary>
|
|
/// Single registration point for the secrets subsystem on every OtOpcUa node, admin- and
|
|
/// driver-role alike. Exists as a named extension rather than an inline <c>AddZbSecrets</c> call
|
|
/// in <c>Program.cs</c> so the choice of <c>ISecretStore</c> implementation is covered by a DI
|
|
/// resolution test — <c>Program.cs</c> is top-level statements and cannot be exercised directly,
|
|
/// which is exactly how a "registered but never resolvable" wiring defect ships unnoticed.
|
|
/// </summary>
|
|
public static class SecretsRegistration
|
|
{
|
|
/// <summary>Configuration key gating cluster secret replication. Absent or false = off.</summary>
|
|
public const string ReplicationEnabledKey = "Secrets:Replication:Enabled";
|
|
|
|
/// <summary>Configuration section holding <c>AkkaSecretsReplicationOptions</c>.</summary>
|
|
public const string ReplicationSectionPath = "Secrets:Replication";
|
|
|
|
/// <summary>Configuration section holding the core secrets options.</summary>
|
|
public const string SecretsSectionPath = "Secrets";
|
|
|
|
/// <summary>
|
|
/// True when cluster secret replication is switched on in configuration. Defaults to false —
|
|
/// absent configuration must mean "off".
|
|
/// </summary>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <returns><c>true</c> when replication is enabled.</returns>
|
|
public static bool IsReplicationEnabled(IConfiguration configuration)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
return configuration.GetValue(ReplicationEnabledKey, defaultValue: false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the secrets subsystem. Replication is <b>opt-in</b>: unless
|
|
/// <see cref="ReplicationEnabledKey"/> is true this is exactly the plain local SQLite wiring
|
|
/// the host has always used.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The gate is deliberately default-deny. This call decides which <c>ISecretStore</c> the
|
|
/// container hands to <b>every</b> node, including driver-role nodes with no auth or
|
|
/// AdminUI surface, where a bad store surfaces as drivers failing to open sessions rather
|
|
/// than as a failing test. Enabling replication by default would change secret resolution
|
|
/// across a production fleet with nobody having asked for it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>AddZbSecretsAkkaReplication</c> calls <c>AddZbSecrets</c> internally, so the two
|
|
/// branches are alternatives, never additive — calling both would double-register.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="services">The service collection to add to.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
|
|
public static IServiceCollection AddOtOpcUaSecrets(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(services);
|
|
ArgumentNullException.ThrowIfNull(configuration);
|
|
|
|
if (!IsReplicationEnabled(configuration))
|
|
{
|
|
services.AddZbSecrets(configuration, SecretsSectionPath);
|
|
DecorateCacheInvalidator(services);
|
|
return services;
|
|
}
|
|
|
|
services.AddZbSecretsAkkaReplication(configuration, SecretsSectionPath, ReplicationSectionPath);
|
|
|
|
// Anti-entropy participation must not hinge on this node happening to touch a secret — see
|
|
// SecretReplicationStarter for why the library's lazy actor creation is insufficient here.
|
|
services.AddSingleton<SecretReplicationStarter>();
|
|
services.AddHostedService(sp => sp.GetRequiredService<SecretReplicationStarter>());
|
|
|
|
DecorateCacheInvalidator(services);
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps the library's <see cref="ISecretCacheInvalidator"/> registration in
|
|
/// <see cref="SecretChangeSignallingInvalidator"/>, so every in-process secret write and replicated apply
|
|
/// also tells the Galaxy ack identity directory (lmxopcua#765). Every consumer resolves the invalidator
|
|
/// through the interface (the replication appliers lazily), so replacing the descriptor reaches them all.
|
|
/// The directory is resolved optionally — admin-only nodes register none and get a pass-through.
|
|
/// </summary>
|
|
/// <param name="services">The service collection, after <c>AddZbSecrets</c>.</param>
|
|
internal static void DecorateCacheInvalidator(IServiceCollection services)
|
|
{
|
|
var existing = services.LastOrDefault(d => d.ServiceType == typeof(ISecretCacheInvalidator) && !d.IsKeyedService);
|
|
if (existing is null) return;
|
|
services.Remove(existing);
|
|
services.AddSingleton<ISecretCacheInvalidator>(sp => new SecretChangeSignallingInvalidator(
|
|
(ISecretCacheInvalidator)(existing.ImplementationInstance
|
|
?? existing.ImplementationFactory?.Invoke(sp)
|
|
?? ActivatorUtilities.CreateInstance(sp, existing.ImplementationType!)),
|
|
sp.GetService<GalaxyAckIdentityDirectory>()));
|
|
}
|
|
}
|