diff --git a/.claude/rules/alarms.md b/.claude/rules/alarms.md
index d3dbec29..97504353 100644
--- a/.claude/rules/alarms.md
+++ b/.claude/rules/alarms.md
@@ -32,10 +32,14 @@ SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that fie
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
**A native ack the driver refuses or fails is not left Acknowledged (#760).** Two layers: (1) a refusal the
-driver can decide without I/O (`IAlarmAcknowledgePreflight` — Galaxy: anonymous session and no fallback ack
-account) fails the Part 9 call `BadUserAccessDenied` via `NativeAlarmAckPreflight`, read lock-free from the
+driver can decide without I/O (`IAlarmAcknowledgePreflight` — Galaxy, no fallback ack account: an anonymous session, or a keyed
+session whose key `GalaxyAckIdentityDirectory` reports Absent, #765) fails the Part 9 call `BadUserAccessDenied` via `NativeAlarmAckPreflight`, read lock-free from the
`NativeAlarmAckPreflightView` snapshot `DriverHostActor` publishes; never do I/O (e.g. a Secrets lookup) in a
-pre-check — it runs under the SDK `Lock`. (2) anything else (keyed session without an identity, MXAccess
+pre-check — it runs under the SDK `Lock`; that is why the keyed case reads a cached, periodically refreshed key set
+(`Drivers:Galaxy:AckIdentityCache`), which must answer Unknown (= let it through) whenever it is not certain — no
+refresh yet, stale, or an identity-secret change signalled through the decorated `ISecretCacheInvalidator` and not
+yet re-listed. Only an identity written straight into the store by the `secret` CLI (no in-process signal) can be
+refused until the next refresh, which the refusal itself requests. (2) anything else (a keyed session the cache cannot vouch for, MXAccess
non-zero `hresult`, driver still connecting/reconnecting) comes back as `DriverInstanceActor.AlarmAckFailed` — or, for a
disabled/unspawned/stubbed driver, is decided in the host itself (#765) — and `DriverHostActor` re-projects the condition unacknowledged — only while the ack is still pending in
`NativeAlarmProjector` (any driver transition since then wins). A host that came up **Stale** cannot route any
diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index 5d629ac0..11bb3b9a 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -468,8 +468,10 @@ from the scripted `AlarmCommandRouter`:
not yet spawned) or is a stub on this node is reverted the same way by
the host itself (#765). Only an ack still pending in `NativeAlarmProjector` is
reverted: a driver transition projected since then is authoritative. A
- refusal the driver can decide without I/O (Galaxy: an anonymous session
- and no fallback ack account) is instead returned synchronously as
+ refusal the driver can decide without I/O (Galaxy, with no fallback ack
+ account: an anonymous session, or a keyed session whose key the cached
+ `GalaxyAckIdentityDirectory` knows has no ack identity — #765) is instead
+ returned synchronously as
`BadUserAccessDenied` by the node manager's `NativeAlarmAckPreflight`
before anything is applied — as is every native ack while the driver host
is Stale (configuration database unreachable at boot), when no ack can be
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 90505323..b708fd53 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -536,6 +536,21 @@ deploy that recreates the driver), not on `ReinitializeAsync`. The init log line
`transport=Libplctag (node default)` when the pin is what selected the stack. There is **no AbLegacy
counterpart**: an AbLegacy driver with no key always runs libplctag (Native is opt-in per driver).
+### `Drivers:Galaxy:AckIdentityCache` (cached API-key → Galaxy ack identity set, #765)
+
+- **Purpose:** the node-wide set of API key ids that have a Galaxy ack identity (a `galaxy-ack-user` secret),
+ which the Galaxy driver's synchronous ack pre-check reads to refuse a keyed session without one
+ (`BadUserAccessDenied`) instead of returning Good and reverting. See
+ [`drivers/Galaxy.md` § Alarm acknowledge credential](drivers/Galaxy.md#alarm-acknowledge-credential).
+- **Options class:** `GalaxyAckIdentityCacheOptions` (`SectionName = "Drivers:Galaxy:AckIdentityCache"`) —
+ `src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyAckIdentityCacheOptions.cs`; bound and validated
+ in `Program.cs`. The directory and its refresh loop exist on driver-role nodes only.
+
+| Key | Type | Default | Meaning |
+|---|---|---|---|
+| `RefreshIntervalSeconds` | int | `30` | How often the Secrets store is re-listed (1..3600). An in-process identity change or a refusal also triggers an early refresh. |
+| `StaleAfterSeconds` | int | `0` | Snapshot age after which keyed acks fall back to Good-then-revert (fail open). `0` = three refresh intervals; otherwise must exceed `RefreshIntervalSeconds`. A bad value fails host start. |
+
---
## Environment variables
diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md
index d3c90cc6..3cc61771 100644
--- a/docs/drivers/Galaxy.md
+++ b/docs/drivers/Galaxy.md
@@ -108,12 +108,23 @@ Galaxy password, so `GalaxyAckCredentialResolver` picks the account per acknowle
3. **Neither** → the driver throws `DriverOperationRefusedException`; **nothing is sent to the
gateway**. The refusal is excluded from the resilience breaker (it says nothing about gateway
health). How the client learns of it (#760):
- - **Synchronously** when the driver can tell without I/O — an *anonymous* session on a driver with
- no fallback account: the Part 9 `Acknowledge` call itself fails `BadUserAccessDenied` with the
- reason, and nothing is applied (`IAlarmAcknowledgePreflight`, published per condition by
- `DriverHostActor` into `NativeAlarmAckPreflightView`).
- - **By revert** otherwise — a keyed session whose key has no identity (that lives in the Secrets
- store, which is not read under the SDK lock), or MXAccess rejecting the account (a non-zero
+ - **Synchronously** when the driver can tell without I/O, on a driver with no fallback account —
+ an *anonymous* session (#760), or a *keyed* session whose key has no identity (#765): the Part 9
+ `Acknowledge` call itself fails `BadUserAccessDenied` with the reason, and nothing is applied
+ (`IAlarmAcknowledgePreflight`, published per condition by `DriverHostActor` into
+ `NativeAlarmAckPreflightView`). The keyed case reads `GalaxyAckIdentityDirectory`, a node-wide
+ cached set of the key ids that have a `galaxy-ack-user` secret, listed from this node's Secrets
+ store (metadata only — nothing decrypted) every `Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds`
+ (default 30) by `GalaxyAckIdentityRefreshService`, and early whenever an identity secret changes
+ in-process (the Secrets cache invalidator is decorated, so AdminUI `/api-keys`, `/admin/secrets` and
+ replication all signal it) or a refusal asks for one. ⚠️ It **fails open**: before the first refresh,
+ once the snapshot is older than `StaleAfterSeconds` (default three intervals — e.g. the store cannot
+ be listed), and from a signalled identity change until the refresh that follows it, every keyed ack
+ takes the revert path below instead of being refused. An identity written straight into the store
+ by the `secret` CLI is seen at the next refresh; the refusal itself requests one, so a retry a moment
+ later goes through.
+ - **By revert** otherwise — a keyed session the directory cannot vouch for (see above), a key whose
+ identity is broken (a user with no password secret), or MXAccess rejecting the account (a non-zero
native `hresult`, e.g. a wrong password, now thrown as a refusal): the call returned `Good`, then
`DriverHostActor` re-projects the condition **unacknowledged** (a fresh condition event,
`Retain=true`) and logs the reason. A driver transition projected after the ack wins over a late
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index bc377e6a..72e5b4a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -119,6 +119,9 @@ public sealed class GalaxyDriver
// lmxopcua#749: resolves the Galaxy account each acknowledge authenticates as (API key identity →
// fallback account → refused). Built in the ctor — it needs only options + the secret resolver.
private readonly IGalaxyAckCredentialResolver _ackCredentialResolver;
+ // lmxopcua#765: the cached key → has-Galaxy-identity set the synchronous ack pre-check reads (no I/O).
+ // Null = no directory wired, so a keyed session is never pre-refused (the pre-#765 behaviour).
+ private readonly IGalaxyAckIdentityDirectory? _ackIdentityDirectory;
private IGalaxyAlarmFeed? _alarmFeed;
private readonly Lock _alarmHandlersLock = new();
private readonly Lock _alarmFeedLock = new();
@@ -262,15 +265,21 @@ public sealed class GalaxyDriver
/// The Galaxy driver configuration options.
/// Resolves the secret: arm of Gateway.ApiKeySecretRef.
/// Optional logger instance for diagnostics.
+ ///
+ /// Optional cached set of API keys with a Galaxy ack identity (lmxopcua#765), consulted by the synchronous
+ /// ack pre-check. Null = keyed sessions are never pre-refused.
+ ///
public GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
ISecretResolver secretResolver,
- ILogger? logger = null)
+ ILogger? logger = null,
+ IGalaxyAckIdentityDirectory? ackIdentityDirectory = null)
: this(driverInstanceId, options,
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
alarmAcknowledger: null, alarmFeed: null, logger,
- secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
+ secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)),
+ ackIdentityDirectory: ackIdentityDirectory)
{
}
@@ -307,6 +316,7 @@ public sealed class GalaxyDriver
/// Optional ack-credential seam (lmxopcua#749). Defaults to
/// over and .
///
+ /// Optional cached key → Galaxy-identity set (lmxopcua#765).
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -320,8 +330,10 @@ public sealed class GalaxyDriver
ISecretResolver? secretResolver = null,
IGalaxyDeployWatchSource? deployWatchSource = null,
IGalaxyPlatformSource? platformSource = null,
- IGalaxyAckCredentialResolver? ackCredentialResolver = null)
+ IGalaxyAckCredentialResolver? ackCredentialResolver = null,
+ IGalaxyAckIdentityDirectory? ackIdentityDirectory = null)
{
+ _ackIdentityDirectory = ackIdentityDirectory;
_deployWatchSource = deployWatchSource;
_platformSource = platformSource;
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
@@ -1968,17 +1980,32 @@ public sealed class GalaxyDriver
///
///
- /// Only the case decidable without I/O (lmxopcua#760): an anonymous session (no API key, so no key-mapped
- /// Galaxy identity can exist) on a driver with no AlarmAck.FallbackUser. A keyed session is let
- /// through — whether the key has an identity lives in the Secrets store, which must not be read under the
- /// SDK lock; still refuses it, and the host reverts the condition.
+ /// Only cases decidable without I/O, on a driver with no AlarmAck.FallbackUser: an anonymous session
+ /// (lmxopcua#760 — no API key, so no key-mapped Galaxy identity can exist), and a keyed session whose key the
+ /// reports
+ /// (lmxopcua#765 — a cached, periodically refreshed view of the Secrets store, because the store itself must
+ /// not be read under the SDK lock). Anything the directory does not know for certain (no directory, no
+ /// refresh yet, stale, a change pending) is let through: still refuses it,
+ /// and the host reverts the condition. A refusal also asks the directory to refresh, so a retry after an
+ /// identity was set out of band (the secret CLI) is not refused for a whole refresh interval.
///
public string? GetAcknowledgeRefusal(string? apiKeyId)
{
- if (!string.IsNullOrWhiteSpace(apiKeyId) || (_options.AlarmAck?.HasFallback ?? false)) return null;
- return $"the session is anonymous (no API key) and Galaxy driver '{_driverInstanceId}' has no " +
- "AlarmAck.FallbackUser configured, so the acknowledge cannot be sent to Galaxy. Connect with an API key " +
- "that has a Galaxy ack identity (AdminUI /api-keys) or configure the driver's fallback ack account.";
+ if (_options.AlarmAck?.HasFallback ?? false) return null;
+ if (string.IsNullOrWhiteSpace(apiKeyId))
+ {
+ return $"the session is anonymous (no API key) and Galaxy driver '{_driverInstanceId}' has no " +
+ "AlarmAck.FallbackUser configured, so the acknowledge cannot be sent to Galaxy. Connect with an API key " +
+ "that has a Galaxy ack identity (AdminUI /api-keys) or configure the driver's fallback ack account.";
+ }
+
+ if (_ackIdentityDirectory is not { } directory
+ || directory.GetPresence(apiKeyId) != GalaxyAckIdentityPresence.Absent) return null;
+
+ directory.RequestRefresh();
+ return $"API key '{apiKeyId.Trim()}' has no Galaxy ack identity and Galaxy driver '{_driverInstanceId}' has no " +
+ "AlarmAck.FallbackUser configured, so the acknowledge cannot be sent to Galaxy. Map a Galaxy ack identity " +
+ "to the key (AdminUI /api-keys) or configure the driver's fallback ack account.";
}
private static string PrincipalOf(AlarmAcknowledgeRequest ack) =>
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
index d1cdd98d..dbe8f9b6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -31,14 +32,20 @@ public static class GalaxyDriverFactoryExtensions
/// each instance's Gateway.ApiKeySecretRef.
///
/// The optional logger factory for creating drivers.
+ ///
+ /// Optional node-wide cached set of API keys with a Galaxy ack identity (lmxopcua#765), shared by every
+ /// Galaxy driver instance for the synchronous ack pre-check. Null = keyed sessions are never pre-refused.
+ ///
public static void Register(
DriverFactoryRegistry registry,
ISecretResolver secretResolver,
- ILoggerFactory? loggerFactory = null)
+ ILoggerFactory? loggerFactory = null,
+ IGalaxyAckIdentityDirectory? ackIdentityDirectory = null)
{
ArgumentNullException.ThrowIfNull(registry);
ArgumentNullException.ThrowIfNull(secretResolver);
- registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
+ registry.Register(DriverTypeName,
+ (id, json) => CreateInstance(id, json, loggerFactory, secretResolver, ackIdentityDirectory));
}
///
@@ -58,9 +65,11 @@ public static class GalaxyDriverFactoryExtensions
/// The driver configuration in JSON format.
/// The optional logger factory for creating drivers.
/// The secret resolver for the driver's secret: API-key arm.
+ /// Optional cached key → Galaxy-identity set (lmxopcua#765).
/// The constructed Galaxy driver instance.
public static GalaxyDriver CreateInstance(
- string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
+ string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver,
+ IGalaxyAckIdentityDirectory? ackIdentityDirectory = null)
{
ArgumentNullException.ThrowIfNull(secretResolver);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -108,7 +117,8 @@ public static class GalaxyDriverFactoryExtensions
FallbackPasswordSecretRef: NullIfBlank(dto.AlarmAck?.FallbackPasswordSecretRef)),
};
- return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger());
+ return new GalaxyDriver(
+ driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger(), ackIdentityDirectory);
}
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckIdentityDirectory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckIdentityDirectory.cs
new file mode 100644
index 00000000..878ff07a
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckIdentityDirectory.cs
@@ -0,0 +1,216 @@
+using System.Collections.Frozen;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+/// What the knows about one API key's Galaxy ack identity.
+public enum GalaxyAckIdentityPresence
+{
+ ///
+ /// Not known right now — no successful refresh yet, the last one is stale, a change is pending, or the key
+ /// id is not one the directory can look up. Callers must treat it as "may have one" (fail open).
+ ///
+ Unknown = 0,
+
+ /// The key has a Galaxy ack user secret (it may still be refused at ack time, e.g. no password).
+ Present,
+
+ /// The key has no Galaxy ack user secret as of a fresh refresh.
+ Absent,
+}
+
+///
+/// Lock-free answer to "does this API key have a Galaxy ack identity?" (lmxopcua#765), for the synchronous
+/// native-ack pre-check (), which
+/// runs under the OPC UA SDK lock and must not read the Secrets store.
+///
+public interface IGalaxyAckIdentityDirectory
+{
+ /// Looks the key up in the cached snapshot. No I/O, never throws.
+ /// The session's API key id.
+ /// The key's presence, or when not known.
+ GalaxyAckIdentityPresence GetPresence(string? apiKeyId);
+
+ /// Asks for a refresh soon (coalesced; never blocks). Used after a refusal so a retry sees a new identity.
+ void RequestRefresh();
+}
+
+///
+/// Production : a periodically refreshed set of the API key ids that
+/// have a Galaxy ack user secret () in this node's
+/// Secrets store — the same store reads at ack time.
+///
+///
+///
+/// Built from metadata only — nothing is decrypted, no password is
+/// read. A refresh runs every refreshInterval (), and early on
+/// / .
+///
+///
+/// ⚠️ Fails OPEN: anything not certain answers , which
+/// lets the ack through to today's path (the driver resolves at ack time and the host reverts a refusal).
+/// That covers no refresh yet, a store with nothing to list, a snapshot older than staleAfter
+/// (e.g. the store has been failing), and a change to an identity secret signalled since the snapshot
+/// was taken — so an identity just set through AdminUI or arriving by replication is never refused in
+/// the window before the next refresh. A change the process is not told about (the secret CLI
+/// writing the store directly) is picked up by the next timed refresh, or the refresh a refusal kicks.
+///
+///
+public sealed class GalaxyAckIdentityDirectory : IGalaxyAckIdentityDirectory, IDisposable
+{
+ private const string UserSuffix = "/galaxy-ack-user";
+
+ private readonly ISecretStore? _store;
+ private readonly TimeProvider _time;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _wake = new(0, 1);
+
+ // Bumped on every signalled change to an identity secret; a snapshot taken under an older value is not trusted.
+ private long _changeGeneration;
+ private volatile Snapshot? _snapshot;
+
+ /// Initializes a new instance of the class.
+ /// This node's Secrets store; null = nothing to list, so every lookup is Unknown.
+ /// How often refreshes.
+ /// How old a snapshot may get before lookups answer Unknown.
+ /// Clock for snapshot age; defaults to the system clock.
+ /// Logger; never receives a secret value.
+ public GalaxyAckIdentityDirectory(
+ ISecretStore? store, TimeSpan refreshInterval, TimeSpan staleAfter,
+ TimeProvider? timeProvider = null, ILogger? logger = null)
+ {
+ if (refreshInterval <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(refreshInterval), refreshInterval, "Must be positive.");
+ if (staleAfter <= TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(staleAfter), staleAfter, "Must be positive.");
+ _store = store;
+ RefreshInterval = refreshInterval;
+ StaleAfter = staleAfter;
+ _time = timeProvider ?? TimeProvider.System;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ /// Gets the refresh period of .
+ public TimeSpan RefreshInterval { get; }
+
+ /// Gets the snapshot age after which every lookup answers Unknown.
+ public TimeSpan StaleAfter { get; }
+
+ ///
+ public GalaxyAckIdentityPresence GetPresence(string? apiKeyId)
+ {
+ var snapshot = _snapshot;
+ if (snapshot is null) return GalaxyAckIdentityPresence.Unknown;
+ if (snapshot.Generation != Interlocked.Read(ref _changeGeneration)) return GalaxyAckIdentityPresence.Unknown;
+ if (_time.GetUtcNow() - snapshot.RefreshedUtc > StaleAfter) return GalaxyAckIdentityPresence.Unknown;
+ if (TryNormalize(apiKeyId) is not { } keyId) return GalaxyAckIdentityPresence.Unknown;
+ return snapshot.KeyIds.Contains(keyId) ? GalaxyAckIdentityPresence.Present : GalaxyAckIdentityPresence.Absent;
+ }
+
+ ///
+ public void RequestRefresh()
+ {
+ try
+ {
+ _wake.Release();
+ }
+ catch (SemaphoreFullException)
+ {
+ // A refresh is already requested — coalesced.
+ }
+ catch (ObjectDisposedException)
+ {
+ // Shutting down.
+ }
+ }
+
+ ///
+ /// Tells the directory a secret changed (wired from the Secrets cache-invalidation seam, which AdminUI
+ /// writes, the Secrets UI and replication all call). A Galaxy ack identity secret marks the current
+ /// snapshot untrusted until the next refresh, and requests one; any other name is ignored.
+ ///
+ /// The changed secret.
+ public void NotifySecretChanged(SecretName name)
+ {
+ if (!name.Value.StartsWith(GalaxyAckIdentitySecrets.Prefix, StringComparison.Ordinal)) return;
+ Interlocked.Increment(ref _changeGeneration);
+ RequestRefresh();
+ }
+
+ /// Lists the store once and replaces the snapshot. Never throws except on cancellation.
+ /// Cancels the listing.
+ /// True when the snapshot was replaced; false on a failure (the old snapshot keeps ageing).
+ public async Task RefreshAsync(CancellationToken cancellationToken)
+ {
+ if (_store is null) return false;
+ // Read BEFORE listing: a change signalled while the listing runs leaves this snapshot untrusted.
+ var generation = Interlocked.Read(ref _changeGeneration);
+ try
+ {
+ var rows = await _store.ListAsync(includeDeleted: false, cancellationToken).ConfigureAwait(false);
+ var keyIds = new HashSet(StringComparer.Ordinal);
+ foreach (var row in rows)
+ {
+ if (row.IsDeleted) continue;
+ var name = row.Name.Value;
+ if (!name.StartsWith(GalaxyAckIdentitySecrets.Prefix, StringComparison.Ordinal)
+ || !name.EndsWith(UserSuffix, StringComparison.Ordinal)
+ || name.Length <= GalaxyAckIdentitySecrets.Prefix.Length + UserSuffix.Length) continue;
+ if (TryNormalize(name[GalaxyAckIdentitySecrets.Prefix.Length..^UserSuffix.Length]) is { } keyId)
+ keyIds.Add(keyId);
+ }
+
+ _snapshot = new Snapshot(keyIds.ToFrozenSet(StringComparer.Ordinal), _time.GetUtcNow(), generation);
+ return true;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(
+ "Galaxy ack identity directory: listing the Secrets store failed ({ExceptionType}); native acks from " +
+ "keyed sessions fall back to the ack-time check once the snapshot is older than {StaleAfter}",
+ ex.GetType().Name, StaleAfter);
+ return false;
+ }
+ }
+
+ /// Refreshes now, then every or sooner when asked, until cancelled.
+ /// Stops the loop.
+ /// A task that completes when cancelled.
+ public async Task RunAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await RefreshAsync(cancellationToken).ConfigureAwait(false);
+ await _wake.WaitAsync(RefreshInterval, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Normal shutdown.
+ }
+ }
+
+ ///
+ public void Dispose() => _wake.Dispose();
+
+ // Same shape GalaxyAckIdentitySecrets accepts (trimmed, lower-case ASCII letters/digits); anything else is
+ // not a key id the resolver could look up, so the directory does not claim to know about it.
+ private static string? TryNormalize(string? apiKeyId)
+ {
+ if (string.IsNullOrWhiteSpace(apiKeyId)) return null;
+ var id = apiKeyId.Trim().ToLowerInvariant();
+ foreach (var c in id)
+ {
+ if (!char.IsAsciiLetterOrDigit(c)) return null;
+ }
+
+ return id;
+ }
+
+ private sealed record Snapshot(FrozenSet KeyIds, DateTimeOffset RefreshedUtc, long Generation);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyAckIdentityCacheOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyAckIdentityCacheOptions.cs
new file mode 100644
index 00000000..9f12f2c9
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/GalaxyAckIdentityCacheOptions.cs
@@ -0,0 +1,72 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using ZB.MOM.WW.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Node-level settings for the cached set of API keys that have a Galaxy ack identity (lmxopcua#765), bound
+/// from Drivers:Galaxy:AckIdentityCache. The set lets the synchronous native-ack pre-check refuse a
+/// keyed session without an identity (BadUserAccessDenied) instead of returning Good and reverting.
+/// Overridable by environment variables Drivers__Galaxy__AckIdentityCache__RefreshIntervalSeconds /
+/// …__StaleAfterSeconds.
+///
+public sealed class GalaxyAckIdentityCacheOptions
+{
+ /// The configuration section path.
+ public const string SectionName = "Drivers:Galaxy:AckIdentityCache";
+
+ /// How often the set is re-read from the Secrets store, in seconds (1..3600). Default 30.
+ public int RefreshIntervalSeconds { get; set; } = 30;
+
+ ///
+ /// 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 .
+ ///
+ public int StaleAfterSeconds { get; set; }
+
+ /// Gets the refresh period.
+ public TimeSpan RefreshInterval => TimeSpan.FromSeconds(RefreshIntervalSeconds);
+
+ /// Gets the staleness bound (explicit, or three refresh intervals).
+ public TimeSpan StaleAfter => StaleAfterSeconds > 0
+ ? TimeSpan.FromSeconds(StaleAfterSeconds)
+ : TimeSpan.FromSeconds(RefreshIntervalSeconds * 3L);
+}
+
+/// Fail-fast startup validator for .
+public sealed class GalaxyAckIdentityCacheOptionsValidator : OptionsValidatorBase
+{
+ ///
+ 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.");
+ }
+}
+
+/// Composition-root registration of .
+public static class GalaxyAckIdentityCacheRegistration
+{
+ ///
+ /// Binds + validates Drivers:Galaxy:AckIdentityCache. A bad value fails host start. Without this call
+ /// the driver factories use the compiled defaults.
+ ///
+ /// The service collection.
+ /// The host configuration.
+ /// The same collection.
+ public static IServiceCollection AddGalaxyAckIdentityCache(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddValidatedOptions(
+ configuration, GalaxyAckIdentityCacheOptions.SectionName);
+ return services;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretChangeSignallingInvalidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretChangeSignallingInvalidator.cs
new file mode 100644
index 00000000..b1b8d28e
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretChangeSignallingInvalidator.cs
@@ -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;
+
+///
+/// Decorates the Secrets library's so a secret change also reaches the
+/// (lmxopcua#765). The library has no change event; this seam is the
+/// one every in-process writer already calls — the AdminUI /api-keys ack-identity service, the
+/// /admin/secrets editor and delete modal, and the replication appliers (Akka / SQL Server).
+///
+/// The library's invalidator (the resolver cache).
+/// The node's directory, or null on a node that runs no drivers.
+internal sealed class SecretChangeSignallingInvalidator(
+ ISecretCacheInvalidator inner, GalaxyAckIdentityDirectory? directory) : ISecretCacheInvalidator
+{
+ /// Gets the decorated invalidator (test seam).
+ internal ISecretCacheInvalidator Inner => inner;
+
+ ///
+ public void Invalidate(SecretName name)
+ {
+ inner.Invalidate(name);
+ directory?.NotifySecretChanged(name);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs
index c1cadc33..4065f43a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs
@@ -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();
services.AddHostedService(sp => sp.GetRequiredService());
+ DecorateCacheInvalidator(services);
return services;
}
+
+ ///
+ /// Wraps the library's registration in
+ /// , 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.
+ ///
+ /// The service collection, after AddZbSecrets.
+ 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(sp => new SecretChangeSignallingInvalidator(
+ (ISecretCacheInvalidator)(existing.ImplementationInstance
+ ?? existing.ImplementationFactory?.Invoke(sp)
+ ?? ActivatorUtilities.CreateInstance(sp, existing.ImplementationType!)),
+ sp.GetService()));
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
index 5ad5a462..792f0253 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs
@@ -61,6 +61,22 @@ public static class DriverFactoryBootstrap
services.TryAddSingleton();
services.TryAddSingleton(sp => sp.GetRequiredService());
+ // 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>()?.Value ?? new GalaxyAckIdentityCacheOptions();
+ return new Driver.Galaxy.Runtime.GalaxyAckIdentityDirectory(
+ sp.GetService(), options.RefreshInterval, options.StaleAfter,
+ sp.GetService(),
+ sp.GetService()?.CreateLogger());
+ });
+ services.TryAddSingleton(
+ sp => sp.GetRequiredService());
+ services.AddHostedService();
+
services.AddSingleton(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(), scriptRoot, scripting);
return registry;
});
services.AddSingleton(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,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/GalaxyAckIdentityRefreshService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/GalaxyAckIdentityRefreshService.cs
new file mode 100644
index 00000000..8c262623
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/GalaxyAckIdentityRefreshService.cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.Hosting;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
+
+///
+/// Runs the refresh loop on driver nodes (lmxopcua#765): an initial
+/// refresh at start, then every Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds, and early when
+/// an identity secret changes or a refusal asks for one.
+///
+/// The node's directory.
+public sealed class GalaxyAckIdentityRefreshService(GalaxyAckIdentityDirectory directory) : BackgroundService
+{
+ ///
+ protected override Task ExecuteAsync(CancellationToken stoppingToken) => directory.RunAsync(stoppingToken);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 1238af06..01c98d84 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -194,6 +194,8 @@ builder.Services.AddValidatedOptions(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)
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckIdentityDirectoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckIdentityDirectoryTests.cs
new file mode 100644
index 00000000..80eaeb96
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyAckIdentityDirectoryTests.cs
@@ -0,0 +1,356 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
+
+///
+/// lmxopcua#765 item 3 — a keyed session whose API key has no Galaxy ack identity is refused synchronously
+/// (BadUserAccessDenied via )
+/// from a cached, periodically refreshed set read without I/O, and everything the set is not certain about
+/// fails open to the Good-then-revert path.
+///
+public sealed class GalaxyAckIdentityDirectoryTests
+{
+ private const string WithIdentity = "0a1b2c3d4e5f6071";
+ private const string WithoutIdentity = "ffeeddccbbaa0099";
+ private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(90);
+
+ // ------------------------------------------------------------------ driver pre-check
+
+ [Fact]
+ public async Task Keyed_session_without_an_identity_is_refused_synchronously()
+ {
+ var (directory, _, _) = await RefreshedDirectoryAsync();
+ using var driver = NewDriver(directory);
+
+ var refusal = driver.GetAcknowledgeRefusal(WithoutIdentity);
+
+ refusal.ShouldNotBeNull();
+ refusal.ShouldContain(WithoutIdentity);
+ refusal.ShouldContain("no Galaxy ack identity");
+ refusal.ShouldContain("drv");
+ }
+
+ [Fact]
+ public async Task Keyed_session_with_an_identity_proceeds()
+ {
+ var (directory, _, _) = await RefreshedDirectoryAsync();
+ using var driver = NewDriver(directory);
+
+ driver.GetAcknowledgeRefusal(WithIdentity).ShouldBeNull();
+ driver.GetAcknowledgeRefusal(WithIdentity.ToUpperInvariant()).ShouldBeNull("key ids are case-insensitive");
+ }
+
+ [Fact]
+ public async Task A_fallback_account_lets_a_keyed_session_without_an_identity_through()
+ {
+ var (directory, _, _) = await RefreshedDirectoryAsync();
+ using var driver = NewDriver(directory, Opts() with
+ {
+ AlarmAck = new GalaxyAlarmAckOptions("ack-svc", "PLANT", "secret:otopcua/galaxy/ack-fallback"),
+ });
+
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Without_a_directory_a_keyed_session_is_never_pre_refused()
+ {
+ using var driver = NewDriver(directory: null);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldBeNull();
+ driver.GetAcknowledgeRefusal(apiKeyId: null).ShouldNotBeNull("the #760 anonymous refusal is unchanged");
+ }
+
+ [Fact]
+ public async Task A_refusal_asks_for_a_refresh_so_a_retry_sees_an_identity_set_out_of_band()
+ {
+ var (directory, store, _) = await RefreshedDirectoryAsync(TimeSpan.FromHours(1));
+ using var driver = NewDriver(directory);
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var loop = directory.RunAsync(cts.Token);
+ await WaitForAsync(() => store.ListCalls >= 2, cts.Token); // RunAsync's own initial refresh
+
+ // Set by the `secret` CLI: nothing in this process is told.
+ store.AddIdentity(WithoutIdentity);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull();
+
+ // The refusal kicked a refresh well inside the one-hour interval.
+ await WaitForAsync(() => driver.GetAcknowledgeRefusal(WithoutIdentity) is null, cts.Token);
+
+ await cts.CancelAsync();
+ await loop;
+ }
+
+ // ------------------------------------------------------------------ fail open
+
+ [Fact]
+ public void Before_the_first_refresh_every_key_is_Unknown_and_proceeds()
+ {
+ using var directory = new GalaxyAckIdentityDirectory(new FakeSecretStore(), Interval, StaleAfter);
+ using var driver = NewDriver(directory);
+
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task A_stale_snapshot_is_Unknown_and_proceeds()
+ {
+ var (directory, store, clock) = await RefreshedDirectoryAsync();
+ using var driver = NewDriver(directory);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull("positive control: fresh snapshot refuses");
+
+ // The store starts failing: the old snapshot is kept, and ages out.
+ store.Fail = true;
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeFalse();
+ clock.Advance(StaleAfter - TimeSpan.FromSeconds(1));
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
+
+ clock.Advance(TimeSpan.FromSeconds(2));
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ directory.GetPresence(WithIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task A_signalled_identity_change_makes_the_snapshot_Unknown_until_the_next_refresh()
+ {
+ var (directory, store, _) = await RefreshedDirectoryAsync();
+ using var driver = NewDriver(directory);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull("positive control");
+
+ // AdminUI sets the identity: the write invalidates the secret, the directory is told.
+ store.AddIdentity(WithoutIdentity);
+ directory.NotifySecretChanged(new SecretName(GalaxyAckIdentitySecrets.UserSecretName(WithoutIdentity)));
+
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ driver.GetAcknowledgeRefusal(WithoutIdentity).ShouldBeNull("never refuse in the window before the refresh");
+
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Present);
+ }
+
+ [Fact]
+ public async Task A_change_signalled_during_a_refresh_leaves_that_snapshot_untrusted()
+ {
+ var (directory, store, _) = await RefreshedDirectoryAsync();
+ store.OnList = () => directory.NotifySecretChanged(
+ new SecretName(GalaxyAckIdentitySecrets.PasswordSecretName(WithoutIdentity)));
+
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ }
+
+ [Fact]
+ public async Task An_unrelated_secret_change_does_not_untrust_the_snapshot()
+ {
+ var (directory, _, _) = await RefreshedDirectoryAsync();
+ directory.NotifySecretChanged(new SecretName("otopcua/galaxy/ack-fallback"));
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
+ }
+
+ [Fact]
+ public async Task A_directory_without_a_store_is_always_Unknown()
+ {
+ using var directory = new GalaxyAckIdentityDirectory(store: null, Interval, StaleAfter);
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeFalse();
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ }
+
+ [Fact]
+ public async Task A_key_id_the_resolver_could_not_look_up_is_Unknown()
+ {
+ var (directory, _, _) = await RefreshedDirectoryAsync();
+ directory.GetPresence("../etc").ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ directory.GetPresence(" ").ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ directory.GetPresence(null).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ }
+
+ // ------------------------------------------------------------------ refresh
+
+ [Fact]
+ public async Task Refresh_picks_up_added_and_removed_identities()
+ {
+ var (directory, store, _) = await RefreshedDirectoryAsync();
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
+
+ store.AddIdentity(WithoutIdentity);
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent, "a cache, until refreshed");
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Present);
+
+ store.RemoveIdentity(WithIdentity, softDelete: true);
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+ directory.GetPresence(WithIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent, "a deleted user secret is no identity");
+ }
+
+ [Fact]
+ public async Task Only_user_secrets_count_as_an_identity()
+ {
+ var store = new FakeSecretStore();
+ store.Add(GalaxyAckIdentitySecrets.PasswordSecretName(WithoutIdentity)); // password without user
+ store.Add("otopcua/galaxy/ack-fallback");
+ using var directory = new GalaxyAckIdentityDirectory(store, Interval, StaleAfter);
+
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
+ }
+
+ [Fact]
+ public async Task The_loop_refreshes_on_its_interval()
+ {
+ var store = new FakeSecretStore();
+ using var directory = new GalaxyAckIdentityDirectory(store, TimeSpan.FromMilliseconds(50), TimeSpan.FromMinutes(1));
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var loop = directory.RunAsync(cts.Token);
+
+ await WaitForAsync(() => directory.GetPresence(WithIdentity) == GalaxyAckIdentityPresence.Absent, cts.Token);
+ store.AddIdentity(WithIdentity);
+ await WaitForAsync(() => directory.GetPresence(WithIdentity) == GalaxyAckIdentityPresence.Present, cts.Token);
+
+ await cts.CancelAsync();
+ await loop; // completes cleanly on cancellation
+ }
+
+ [Fact]
+ public async Task The_loop_refreshes_early_on_a_signalled_change()
+ {
+ var store = new FakeSecretStore();
+ using var directory = new GalaxyAckIdentityDirectory(store, TimeSpan.FromHours(1), TimeSpan.FromHours(3));
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ var loop = directory.RunAsync(cts.Token);
+ await WaitForAsync(() => directory.GetPresence(WithIdentity) == GalaxyAckIdentityPresence.Absent, cts.Token);
+
+ store.AddIdentity(WithIdentity);
+ directory.NotifySecretChanged(new SecretName(GalaxyAckIdentitySecrets.UserSecretName(WithIdentity)));
+
+ await WaitForAsync(() => directory.GetPresence(WithIdentity) == GalaxyAckIdentityPresence.Present, cts.Token);
+ await cts.CancelAsync();
+ await loop;
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private static async Task<(GalaxyAckIdentityDirectory Directory, FakeSecretStore Store, ManualClock Clock)>
+ RefreshedDirectoryAsync(TimeSpan? interval = null)
+ {
+ var store = new FakeSecretStore();
+ store.AddIdentity(WithIdentity);
+ var clock = new ManualClock();
+ var directory = new GalaxyAckIdentityDirectory(
+ store, interval ?? Interval, interval is { } i ? i * 3 : StaleAfter, clock);
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+ return (directory, store, clock);
+ }
+
+ private static async Task WaitForAsync(Func condition, CancellationToken ct)
+ {
+ while (!condition())
+ {
+ ct.ThrowIfCancellationRequested();
+ await Task.Delay(10, ct);
+ }
+ }
+
+ private static GalaxyDriverOptions Opts() => new(
+ new GalaxyGatewayOptions("https://mxgw.test:5001", "dev:key"),
+ new GalaxyMxAccessOptions("OtOpcUa-A"),
+ new GalaxyRepositoryOptions(),
+ new GalaxyReconnectOptions());
+
+ private static GalaxyDriver NewDriver(IGalaxyAckIdentityDirectory? directory, GalaxyDriverOptions? options = null) =>
+ new("drv", options ?? Opts(), hierarchySource: null, ackIdentityDirectory: directory);
+
+ private sealed class ManualClock : TimeProvider
+ {
+ private DateTimeOffset _now = new(2026, 11, 9, 12, 0, 0, TimeSpan.Zero);
+
+ public void Advance(TimeSpan by) => _now += by;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+ }
+
+ /// Metadata-only store: is all the directory may call.
+ private sealed class FakeSecretStore : ISecretStore
+ {
+ private readonly Dictionary _rows = new(StringComparer.Ordinal); // name → deleted
+ private readonly Lock _lock = new();
+ private int _listCalls;
+
+ public bool Fail { get; set; }
+
+ public Action? OnList { get; set; }
+
+ public int ListCalls => Volatile.Read(ref _listCalls);
+
+ public void Add(string name)
+ {
+ lock (_lock) _rows[name] = false;
+ }
+
+ public void AddIdentity(string keyId)
+ {
+ Add(GalaxyAckIdentitySecrets.PasswordSecretName(keyId));
+ Add(GalaxyAckIdentitySecrets.UserSecretName(keyId));
+ }
+
+ public void RemoveIdentity(string keyId, bool softDelete)
+ {
+ lock (_lock)
+ {
+ foreach (var name in new[]
+ {
+ GalaxyAckIdentitySecrets.UserSecretName(keyId), GalaxyAckIdentitySecrets.PasswordSecretName(keyId),
+ })
+ {
+ if (softDelete) _rows[name] = true;
+ else _rows.Remove(name);
+ }
+ }
+ }
+
+ public Task> ListAsync(bool includeDeleted, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _listCalls);
+ OnList?.Invoke();
+ if (Fail) throw new InvalidOperationException("store unavailable");
+ lock (_lock)
+ {
+ // Deleted rows are returned regardless, to prove the directory filters them itself.
+ IReadOnlyList rows = _rows.Select(r => new SecretMetadata
+ {
+ Name = new SecretName(r.Key),
+ ContentType = SecretContentType.Text,
+ KekId = "k",
+ Revision = 1,
+ IsDeleted = r.Value,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ }).ToList();
+ return Task.FromResult(rows);
+ }
+ }
+
+ public Task GetAsync(SecretName name, CancellationToken ct) => throw Unexpected();
+
+ public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw Unexpected();
+
+ public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw Unexpected();
+
+ public Task> GetManifestAsync(CancellationToken ct) => throw Unexpected();
+
+ public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw Unexpected();
+
+ public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
+ throw Unexpected();
+
+ // The directory must read metadata only — never a row (that would decrypt-adjacent I/O per key).
+ private static InvalidOperationException Unexpected() => new("the directory may only list metadata");
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/GalaxyAckIdentityCacheWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/GalaxyAckIdentityCacheWiringTests.cs
new file mode 100644
index 00000000..68eb4364
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/GalaxyAckIdentityCacheWiringTests.cs
@@ -0,0 +1,208 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+using ZB.MOM.WW.OtOpcUa.Host.Drivers;
+using ZB.MOM.WW.Secrets;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
+
+///
+/// lmxopcua#765 item 3 — the host wiring of the cached API-key → Galaxy ack identity set: the driver registry
+/// hands every Galaxy driver the node's directory (so the synchronous pre-check can refuse a keyed session
+/// without an identity), a refresh loop runs on driver nodes, secret writes signal it through the decorated
+/// invalidator, and the refresh interval is configurable.
+///
+[Trait("Category", "Unit")]
+public sealed class GalaxyAckIdentityCacheWiringTests
+{
+ private const string WithIdentity = "0a1b2c3d4e5f6071";
+ private const string WithoutIdentity = "ffeeddccbbaa0099";
+
+ private const string GalaxyConfig = """
+ {
+ "Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "dev:key" },
+ "MxAccess": { "ClientName": "OtOpcUa-A" }
+ }
+ """;
+
+ [Fact]
+ public async Task A_registry_built_Galaxy_driver_refuses_a_keyed_session_without_an_identity()
+ {
+ var store = new ListOnlySecretStore(GalaxyAckIdentitySecrets.UserSecretName(WithIdentity));
+ using var sp = DriverServices(store).BuildServiceProvider();
+ var directory = sp.GetRequiredService();
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+
+ var driver = sp.GetRequiredService()
+ .TryGet(GalaxyDriverFactoryExtensions.DriverTypeName)!.Invoke("galaxy-1", GalaxyConfig);
+ var preflight = driver.ShouldBeAssignableTo()!;
+
+ preflight.GetAcknowledgeRefusal(WithoutIdentity).ShouldNotBeNull();
+ preflight.GetAcknowledgeRefusal(WithIdentity).ShouldBeNull();
+ (driver as IDisposable)?.Dispose();
+ }
+
+ [Fact]
+ public void Driver_nodes_run_the_refresh_loop_and_share_one_directory()
+ {
+ var services = DriverServices(new ListOnlySecretStore());
+ services.AddOtOpcUaDriverFactories(); // a fused admin,driver node may reach it twice
+ // By descriptor: resolving every IHostedService would need the resilience publisher's whole graph.
+ services.Count(d => d.ServiceType == typeof(IHostedService)
+ && d.ImplementationType == typeof(GalaxyAckIdentityRefreshService)).ShouldBe(1);
+ using var sp = services.BuildServiceProvider();
+ sp.GetRequiredService()
+ .ShouldBeSameAs(sp.GetRequiredService());
+ }
+
+ [Fact]
+ public void The_refresh_interval_and_staleness_are_configurable()
+ {
+ var services = DriverServices(new ListOnlySecretStore());
+ services.AddGalaxyAckIdentityCache(Config(new()
+ {
+ ["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = "7",
+ ["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = "50",
+ }));
+ using var sp = services.BuildServiceProvider();
+
+ var directory = sp.GetRequiredService();
+ directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(7));
+ directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(50));
+ }
+
+ [Fact]
+ public void Defaults_are_30s_and_three_intervals()
+ {
+ using var sp = DriverServices(new ListOnlySecretStore()).BuildServiceProvider();
+ var directory = sp.GetRequiredService();
+ directory.RefreshInterval.ShouldBe(TimeSpan.FromSeconds(30));
+ directory.StaleAfter.ShouldBe(TimeSpan.FromSeconds(90));
+ }
+
+ [Theory]
+ [InlineData("0", "0")]
+ [InlineData("3601", "0")]
+ [InlineData("30", "30")]
+ public void Bad_cache_settings_fail_validation(string interval, string staleAfter)
+ {
+ var services = new ServiceCollection();
+ services.AddGalaxyAckIdentityCache(Config(new()
+ {
+ ["Drivers:Galaxy:AckIdentityCache:RefreshIntervalSeconds"] = interval,
+ ["Drivers:Galaxy:AckIdentityCache:StaleAfterSeconds"] = staleAfter,
+ }));
+ using var sp = services.BuildServiceProvider();
+
+ Should.Throw(() => sp.GetRequiredService>().Value);
+ }
+
+ [Fact]
+ public async Task AddOtOpcUaSecrets_decorates_the_invalidator_so_an_identity_write_untrusts_the_directory()
+ {
+ // Registered before AddOtOpcUaSecrets, so the library's TryAdd keeps this store for the directory's listing.
+ var services = DriverServices(new ListOnlySecretStore());
+ services.AddLogging();
+ services.AddOtOpcUaSecrets(Config(new()
+ {
+ // Resolving the invalidator never opens the store; the path only has to pass validation.
+ ["Secrets:SqlitePath"] = Path.Combine(Path.GetTempPath(), $"otopcua-secrets-{Guid.NewGuid():N}.db"),
+ ["Secrets:MasterKey:Source"] = "Environment",
+ ["Secrets:MasterKey:EnvVarName"] = "ZB_SECRETS_MASTER_KEY",
+ ["Secrets:RunMigrationsOnStartup"] = "false",
+ }));
+ using var sp = services.BuildServiceProvider();
+
+ var invalidator = sp.GetRequiredService();
+ var decorated = invalidator.ShouldBeOfType();
+ decorated.Inner.ShouldBeOfType();
+ decorated.Inner.ShouldBeSameAs(sp.GetRequiredService(), "the resolver cache is still invalidated");
+
+ var directory = sp.GetRequiredService();
+ (await directory.RefreshAsync(CancellationToken.None)).ShouldBeTrue();
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Absent);
+
+ // What ApiKeyGalaxyAckIdentityService.SetAsync does after writing the user secret.
+ invalidator.Invalidate(new SecretName(GalaxyAckIdentitySecrets.UserSecretName(WithoutIdentity)));
+
+ directory.GetPresence(WithoutIdentity).ShouldBe(GalaxyAckIdentityPresence.Unknown);
+ }
+
+ [Fact]
+ public void Without_a_directory_the_decorated_invalidator_is_a_pass_through()
+ {
+ var inner = new RecordingInvalidator();
+ var services = new ServiceCollection();
+ services.AddSingleton(_ => inner);
+ SecretsRegistration.DecorateCacheInvalidator(services);
+ using var sp = services.BuildServiceProvider();
+
+ var name = new SecretName("otopcua/apikeys/abc/galaxy-ack-user");
+ sp.GetRequiredService().Invalidate(name);
+
+ inner.Names.ShouldBe([name]);
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private static IServiceCollection DriverServices(ISecretStore store)
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(new NullResolver());
+ services.AddSingleton(store);
+ services.AddOtOpcUaDriverFactories();
+ return services;
+ }
+
+ private static IConfiguration Config(Dictionary values) =>
+ new ConfigurationBuilder().AddInMemoryCollection(values).Build();
+
+ private sealed class NullResolver : ISecretResolver
+ {
+ public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null);
+ }
+
+ private sealed class RecordingInvalidator : ISecretCacheInvalidator
+ {
+ public List Names { get; } = [];
+
+ public void Invalidate(SecretName name) => Names.Add(name);
+ }
+
+ private sealed class ListOnlySecretStore(params string[] names) : ISecretStore
+ {
+ public Task> ListAsync(bool includeDeleted, CancellationToken ct) =>
+ Task.FromResult>(names.Select(n => new SecretMetadata
+ {
+ Name = new SecretName(n),
+ ContentType = SecretContentType.Text,
+ KekId = "k",
+ Revision = 1,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ }).ToList());
+
+ public Task GetAsync(SecretName name, CancellationToken ct) => throw new NotSupportedException();
+
+ public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
+
+ public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => throw new NotSupportedException();
+
+ public Task> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
+
+ public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
+
+ public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
+ throw new NotSupportedException();
+ }
+}