fix(alarms): refuse a keyed native ack with no Galaxy identity synchronously, from a cached key set (lmxopcua#765)
v2-ci / unit-tests (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Node-level settings for the cached set of API keys that have a Galaxy ack identity (lmxopcua#765), bound
|
||||
/// from <c>Drivers:Galaxy:AckIdentityCache</c>. The set lets the synchronous native-ack pre-check refuse a
|
||||
/// keyed session without an identity (<c>BadUserAccessDenied</c>) instead of returning Good and reverting.
|
||||
/// Overridable by environment variables <c>Drivers__Galaxy__AckIdentityCache__RefreshIntervalSeconds</c> /
|
||||
/// <c>…__StaleAfterSeconds</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyAckIdentityCacheOptions
|
||||
{
|
||||
/// <summary>The configuration section path.</summary>
|
||||
public const string SectionName = "Drivers:Galaxy:AckIdentityCache";
|
||||
|
||||
/// <summary>How often the set is re-read from the Secrets store, in seconds (1..3600). Default 30.</summary>
|
||||
public int RefreshIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How old the set may get, in seconds, before keyed acks fall back to the pre-#765 Good-then-revert path
|
||||
/// (fail open, e.g. while the store cannot be listed). 0 (the default) = three refresh intervals. When set,
|
||||
/// must be greater than <see cref="RefreshIntervalSeconds"/>.
|
||||
/// </summary>
|
||||
public int StaleAfterSeconds { get; set; }
|
||||
|
||||
/// <summary>Gets the refresh period.</summary>
|
||||
public TimeSpan RefreshInterval => TimeSpan.FromSeconds(RefreshIntervalSeconds);
|
||||
|
||||
/// <summary>Gets the staleness bound (explicit, or three refresh intervals).</summary>
|
||||
public TimeSpan StaleAfter => StaleAfterSeconds > 0
|
||||
? TimeSpan.FromSeconds(StaleAfterSeconds)
|
||||
: TimeSpan.FromSeconds(RefreshIntervalSeconds * 3L);
|
||||
}
|
||||
|
||||
/// <summary>Fail-fast startup validator for <see cref="GalaxyAckIdentityCacheOptions"/>.</summary>
|
||||
public sealed class GalaxyAckIdentityCacheOptionsValidator : OptionsValidatorBase<GalaxyAckIdentityCacheOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, GalaxyAckIdentityCacheOptions options)
|
||||
{
|
||||
builder.RequireThat(
|
||||
options.RefreshIntervalSeconds is >= 1 and <= 3600,
|
||||
$"{GalaxyAckIdentityCacheOptions.SectionName}:RefreshIntervalSeconds is {options.RefreshIntervalSeconds} — " +
|
||||
"expected 1..3600.");
|
||||
builder.RequireThat(
|
||||
options.StaleAfterSeconds == 0 || options.StaleAfterSeconds > options.RefreshIntervalSeconds,
|
||||
$"{GalaxyAckIdentityCacheOptions.SectionName}:StaleAfterSeconds is {options.StaleAfterSeconds} — expected 0 " +
|
||||
$"(three refresh intervals) or more than RefreshIntervalSeconds ({options.RefreshIntervalSeconds}); otherwise " +
|
||||
"the set would go stale between refreshes and never be used.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Composition-root registration of <see cref="GalaxyAckIdentityCacheOptions"/>.</summary>
|
||||
public static class GalaxyAckIdentityCacheRegistration
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds + validates <c>Drivers:Galaxy:AckIdentityCache</c>. A bad value fails host start. Without this call
|
||||
/// the driver factories use the compiled defaults.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The host configuration.</param>
|
||||
/// <returns>The same collection.</returns>
|
||||
public static IServiceCollection AddGalaxyAckIdentityCache(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddValidatedOptions<GalaxyAckIdentityCacheOptions, GalaxyAckIdentityCacheOptionsValidator>(
|
||||
configuration, GalaxyAckIdentityCacheOptions.SectionName);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Decorates the Secrets library's <see cref="ISecretCacheInvalidator"/> so a secret change also reaches the
|
||||
/// <see cref="GalaxyAckIdentityDirectory"/> (lmxopcua#765). The library has no change event; this seam is the
|
||||
/// one every in-process writer already calls — the AdminUI <c>/api-keys</c> ack-identity service, the
|
||||
/// <c>/admin/secrets</c> editor and delete modal, and the replication appliers (Akka / SQL Server).
|
||||
/// </summary>
|
||||
/// <param name="inner">The library's invalidator (the resolver cache).</param>
|
||||
/// <param name="directory">The node's directory, or null on a node that runs no drivers.</param>
|
||||
internal sealed class SecretChangeSignallingInvalidator(
|
||||
ISecretCacheInvalidator inner, GalaxyAckIdentityDirectory? directory) : ISecretCacheInvalidator
|
||||
{
|
||||
/// <summary>Gets the decorated invalidator (test seam).</summary>
|
||||
internal ISecretCacheInvalidator Inner => inner;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Invalidate(SecretName name)
|
||||
{
|
||||
inner.Invalidate(name);
|
||||
directory?.NotifySecretChanged(name);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
|
||||
@@ -67,6 +69,7 @@ public static class SecretsRegistration
|
||||
if (!IsReplicationEnabled(configuration))
|
||||
{
|
||||
services.AddZbSecrets(configuration, SecretsSectionPath);
|
||||
DecorateCacheInvalidator(services);
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -77,6 +80,27 @@ public static class SecretsRegistration
|
||||
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>()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,22 @@ public static class DriverFactoryBootstrap
|
||||
services.TryAddSingleton<RedundancyRoleView>();
|
||||
services.TryAddSingleton<IDriverPrimaryRoleSource>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
|
||||
// lmxopcua#765: the node-wide cached set of API keys with a Galaxy ack identity, read without I/O by every
|
||||
// Galaxy driver's synchronous ack pre-check. Listed from this node's Secrets store (optional here, so a
|
||||
// test graph without one gets a directory that always answers Unknown = fail open), refreshed by the
|
||||
// hosted loop below and early on identity-secret changes (SecretsRegistration decorates the invalidator).
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetService<IOptions<GalaxyAckIdentityCacheOptions>>()?.Value ?? new GalaxyAckIdentityCacheOptions();
|
||||
return new Driver.Galaxy.Runtime.GalaxyAckIdentityDirectory(
|
||||
sp.GetService<ISecretStore>(), options.RefreshInterval, options.StaleAfter,
|
||||
sp.GetService<TimeProvider>(),
|
||||
sp.GetService<ILoggerFactory>()?.CreateLogger<Driver.Galaxy.Runtime.GalaxyAckIdentityDirectory>());
|
||||
});
|
||||
services.TryAddSingleton<Driver.Galaxy.Runtime.IGalaxyAckIdentityDirectory>(
|
||||
sp => sp.GetRequiredService<Driver.Galaxy.Runtime.GalaxyAckIdentityDirectory>());
|
||||
services.AddHostedService<GalaxyAckIdentityRefreshService>();
|
||||
|
||||
services.AddSingleton<DriverFactoryRegistry>(sp =>
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
@@ -86,7 +102,7 @@ public static class DriverFactoryBootstrap
|
||||
// failures; without this it writes to NullLogger in production.
|
||||
ConfigureAbTransportLogging(sp);
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
|
||||
scriptRoot, scripting);
|
||||
sp.GetRequiredService<Driver.Galaxy.Runtime.IGalaxyAckIdentityDirectory>(), scriptRoot, scripting);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -215,6 +231,7 @@ public static class DriverFactoryBootstrap
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
Driver.AbCip.AbCipTransport abCipDefaultTransport,
|
||||
Driver.Galaxy.Runtime.IGalaxyAckIdentityDirectory galaxyAckIdentities,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null)
|
||||
{
|
||||
@@ -222,7 +239,7 @@ public static class DriverFactoryBootstrap
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory, galaxyAckIdentities);
|
||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory, primaryRoleSource);
|
||||
// Tier A (the Register default): fully managed — HttpClient + System.Xml.Linq, no native SDK,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the <see cref="GalaxyAckIdentityDirectory"/> refresh loop on driver nodes (lmxopcua#765): an initial
|
||||
/// refresh at start, then every <c>Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds</c>, and early when
|
||||
/// an identity secret changes or a refusal asks for one.
|
||||
/// </summary>
|
||||
/// <param name="directory">The node's directory.</param>
|
||||
public sealed class GalaxyAckIdentityRefreshService(GalaxyAckIdentityDirectory directory) : BackgroundService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken) => directory.RunAsync(stoppingToken);
|
||||
}
|
||||
@@ -194,6 +194,8 @@ builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddAbCipDriverDefaults(builder.Configuration);
|
||||
// lmxopcua#765: refresh/staleness of the cached API-key → Galaxy ack identity set (driver nodes consume it).
|
||||
builder.Services.AddGalaxyAckIdentityCache(builder.Configuration);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user