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

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:
Joseph Doherty
2026-11-09 16:38:35 -05:00
parent c9288a56c0
commit 0702b31029
15 changed files with 1034 additions and 28 deletions
+17 -6
View File
@@ -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