Compare commits

...

44 Commits

Author SHA1 Message Date
Joseph Doherty 02228b2717 Merge branch 'main' into feat/ackmsg-as-operator
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m7s
ci / java (pull_request) Successful in 2m18s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Failing after 4m47s
ci / windows-x86 (pull_request) Successful in 2m40s
ci / portable (pull_request) Failing after 4m43s
ci / windows-x86 (push) Successful in 5m38s
Picks up the System.Security.Cryptography.Pkcs 10.0.13 pin (NU1903).

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:28:45 -04:00
Joseph Doherty fb20997dc9 Merge branch 'fix/pkcs-cve-pin' into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m47s
ci / java (push) Successful in 2m6s
ci / portable (push) Successful in 8m57s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:24:10 -04:00
Joseph Doherty 463748e395 fix(worker): classify ack interop failures at the COM call; ignore late completions
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m24s
ci / java (push) Successful in 2m32s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m38s
ci / portable (push) Failing after 2m52s
ci / portable (pull_request) Failing after 2m53s
ci / windows-x86 (pull_request) Successful in 8m8s
Address review of the #164-#166 fixes:

- The HRESULT check could not tell interop from programming errors
  (a managed ArgumentException already defaults to 0x80070057). Wrap each
  MXAccess call (AuthenticateUser, AddItem, Advise, Write) in a narrow
  filter that converts an interop failure to a private marker exception,
  and catch only that marker, so exceptions from the writer's own code
  (argument checks, caching, the pump) propagate.
- A late OnWriteComplete for a dropped handle recreated the cache entry
  that DropItem removed. The writer now records completions only for item
  handles it holds on its live registration.
- The item-handle-reuse probe now asserts that handles within one
  registration strictly increase, so a rerun is a regression check.

Refs #164, #165, #166

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:18:02 -04:00
Joseph Doherty bd82036d5f docs(worker): record live item-handle-reuse probe for ack writer
ci / windows-x86 (push) Successful in 1m29s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m47s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Failing after 3m1s
ci / portable (pull_request) Failing after 1m46s
ci / windows-x86 (pull_request) Successful in 1m24s
ci / java (pull_request) Successful in 2m36s
Add the Skip-gated ItemHandleReuseProbeTests probe and its windev result
(2026-10-20): within one MXAccess registration, item handles increase
monotonically and are never reused across remove/re-add, so the ack
writer's Unconfirmed drop-and-re-add mitigation holds and no per-handle
generation counter is needed. Numbering restarts only on a new
registration, which teardown already isolates. Document the guarantee at
the Dirty re-add and in AlarmProbeFindings.md.

Fixes #166

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:13:31 -04:00
Joseph Doherty f13950e453 fix(worker): forget write-completion entries for dropped ack item handles
Add MxAccessWriteCompletionCache.Remove and RemoveServer and call them
from AlarmAckWriter when it drops an item (dirty re-add, LRU eviction,
failed AddItem/Advise/Write) and when it tears down its registration, so
the cache no longer keeps one entry per dropped handle for the life of
the worker.

Fixes #165

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:11:37 -04:00
Joseph Doherty 219611c821 fix(worker): narrow AlarmAckWriter ArgumentException interop filter
Resolve the operator user, password and comment before the interop try
blocks, and match ArgumentException as an MXAccess failure only when it
carries E_INVALIDARG and is not ArgumentNullException or
ArgumentOutOfRangeException. A programming error inside the try blocks
now propagates instead of being reported as an authentication or write
failure.

Fixes #164

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:11:37 -04:00
Joseph Doherty a09fa0a4d6 fix: pin System.Security.Cryptography.Pkcs 10.0.13 (NU1903)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m23s
ci / java (push) Successful in 2m19s
ci / portable (push) Successful in 10m44s
Microsoft.Data.SqlClient 6.0.2 pulls System.Security.Cryptography.Pkcs 9.0.4,
which carries GHSA-846f-f764-76mx (high) and GHSA-2fmv-rv2c-59x7 (moderate).
With TreatWarningsAsErrors the NU1902/NU1903 audit warnings failed restore.
10.0.13 patches both.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 07:07:59 -04:00
Joseph Doherty 3e988ede88 docs: record gateway-level live verification of AckMsg-as-operator acks
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / nightly-windev (pull_request) Has been skipped
ci / java (push) Successful in 2m3s
ci / portable (push) Failing after 2m19s
ci / windows-x86 (pull_request) Successful in 1m19s
ci / java (pull_request) Successful in 2m2s
ci / portable (pull_request) Failing after 2m17s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 20:07:36 -04:00
Joseph Doherty e7f56622c1 fix: address final ackmsg-as-operator review findings
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m0s
ci / windows-x86 (push) Successful in 2m11s
ci / portable (push) Failing after 2m21s
- AlarmAckWriter: classify AuthenticateUser and AddItem/Advise/Write
  failures by HRESULT for any interop exception (ArgumentException for
  E_INVALIDARG, UnauthorizedAccessException, COMException); server-gone
  HRESULTs at AuthenticateUser tear down the registration and report
  WriteRejected.
- Bound the ack item cache with a 256-entry LRU (eviction best-effort
  UnAdvise+RemoveItem).
- After an Unconfirmed ack, drop and re-add the item before the next
  write so a late OnWriteComplete on the old handle is not credited.
- Pass StaRuntime.PumpPendingMessages to the ack writer through a
  pump-aware alarm handler factory so long completion waits refresh the
  STA watchdog heartbeat.
- Rust CLI: --operator is required for acknowledge-alarm.
- Docs: full ack status table, credential-oracle trade-off, upgrade note.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:58:24 -04:00
Joseph Doherty 1998ee3e20 docs: address Task 12 review (supersede stale ack steps, outcome rows)
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m10s
ci / windows-x86 (push) Successful in 2m11s
ci / portable (push) Failing after 2m18s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:43 -04:00
Joseph Doherty 027cc7393d fix(python-client): redact operator password from AcknowledgeAlarmReply errors
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:02 -04:00
Joseph Doherty 7b81cb7f72 feat(python-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:45:02 -04:00
Joseph Doherty 0143591858 docs(dotnet-client): keep XML docs with their CLI members
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:56 -04:00
Joseph Doherty e8ace39418 feat(dotnet-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:56 -04:00
Joseph Doherty 9907a97cb0 fix(java-client): redact operator password on async acknowledge-alarm failures
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 84e04b6552 feat(java-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 417a47bdb5 feat(rust-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty cfe9f817a3 feat(go-client): operator credentials on acknowledge-alarm
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:32 -04:00
Joseph Doherty 9409488008 docs: alarm acknowledgement writes AckMsg as the authenticated operator
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:44:21 -04:00
Joseph Doherty 2858e42e80 feat(gateway): forward operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:40:23 -04:00
Joseph Doherty d6db0ff40d feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:55 -04:00
Joseph Doherty 0d4b2aea2d refactor(worker): alarm consumers resolve the AckMsg item instead of acking
IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:49 -04:00
Joseph Doherty 8199553734 fix(worker): handle AckMsg add/advise/write COM failures and server death
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m55s
ci / java (push) Failing after 2m2s
ci / portable (push) Failing after 2m17s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:25:43 -04:00
Joseph Doherty 7900df3dce fix(worker): disambiguate MxStatusCategory in AlarmAckWriter
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m25s
ci / portable (push) Failing after 2m26s
ci / windows-x86 (push) Successful in 2m27s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:22:27 -04:00
Joseph Doherty 76db822df3 feat(worker): acknowledge alarms via authenticated AckMsg write
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 56s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:21:55 -04:00
Joseph Doherty 64dc1215fd feat(worker): attribute alarm ack transitions from wnwrap ACK fields
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m7s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:18:24 -04:00
Joseph Doherty a94b502ce6 feat(contracts): carry operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:16:39 -04:00
Joseph Doherty bf4b9c6c09 docs(plan): AckMsg-as-operator alarm acknowledgement plan
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:15:20 -04:00
Joseph Doherty 91bbdbf154 Merge branch 'docs/managed-alarm-ack-live-result' into main
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m14s
ci / windows-x86 (push) Successful in 2m23s
ci / portable (push) Failing after 2m32s
2026-10-18 15:15:58 -04:00
Joseph Doherty 8c095b9a56 Record the managed GUID alarm-ack live result
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 2m18s
ci / portable (push) Failing after 2m39s
The 2026-10-18 live test on the dev rig showed the opt-in managed ack path
cannot work as built: AlarmClient.RegisterConsumer returns -65 alongside the
worker's wnwrap reader (the worker then silently falls back to wnwrap), the
managed client does not recognise the wnwrap GUID even standalone, and the
gateway's GUID arm sends empty identity fields. Replace the docs' claim that
the path works with the measured results and tell operators to leave
MXGATEWAY_USE_MANAGED_ALARM_ACK unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:11:11 -04:00
Joseph Doherty 6c82de55c6 Merge pull request 'Add opt-in managed GUID alarm-ack path' (#160) from feat/managed-guid-alarm-ack into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m59s
ci / portable (push) Failing after 2m21s
ci / java (push) Successful in 2m7s
2026-10-18 13:12:12 -04:00
Joseph Doherty 6f4c7c3bf1 Keep subtag acks and alarm reads safe on the managed ack path
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (pull_request) Successful in 2m0s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m16s
ci / java (push) Successful in 2m19s
ci / portable (push) Failing after 2m31s
ci / portable (pull_request) Failing after 2m36s
ci / windows-x86 (push) Successful in 3m11s
Review fixes for the opt-in managed GUID ack:

- Subtag-mode GUID acks (forced subtag, or failover on the standby) stay on
  the consumer; their GUIDs are synthetic and only the subtag consumer can
  resolve them. Forced-subtag sessions no longer build a managed client.
- A failed managed Initialize/Register/Subscribe drops back to the wnwrap
  ack path instead of failing the alarm subscription.
- Drop stale crefs to a nonexistent Unverified member in
  WnalNativeAckConsumer.
- Unit tests for routing, fallback, and disposal; document
  MXGATEWAY_USE_MANAGED_ALARM_ACK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:11:01 -04:00
Joseph Doherty 6affbd57ea feat(worker): add opt-in managed GUID alarm-ack path
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Failing after 2m23s
ci / nightly-windev (pull_request) Has been skipped
ci / windows-x86 (pull_request) Successful in 1m18s
ci / java (pull_request) Successful in 2m3s
ci / portable (pull_request) Failing after 2m17s
Adds a managed acknowledge path over AVEVA's modern alarm client so GUID
acks carry full operator identity (domain + full name) and work on builds
where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with
MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise).

- AaManagedAlarmAckConsumer: reflection wrapper over
  aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/
  Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the
  DLL can't load, so alarm reads never break.
- IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and
  routes GUID acks to it (subscribing + disposing it alongside the reader).
- AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession
  wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites.
- WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer
  (WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck
  layout). List acquisition is intentionally not hand-rolled: the alarm-record
  layout and selection semantics are provider-defined and not recoverable from
  alarmlst.dll, so inferring them risks acking the wrong alarm.
- AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real
  (non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge
  transition / acked snapshot), not merely rc=0.

By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in
static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 12:42:07 -04:00
dohertj2 6fc423f608 Merge pull request 'Retain fake worker fault diagnostics in test fixtures' (#155) from fix/fake-worker-fault-diagnostics into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m3s
ci / portable (push) Successful in 8m23s
2026-10-13 10:46:14 -04:00
Joseph Doherty 83aa76c33d Retain fake worker fault diagnostics in test fixtures
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m19s
ci / windows-x86 (pull_request) Successful in 1m20s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m23s
ci / portable (push) Successful in 9m10s
ci / portable (pull_request) Successful in 9m2s
2026-10-13 10:35:28 -04:00
dohertj2 d779daf6af Merge pull request 'Make framed worker and readiness test gates deterministic' (#153) from fix/framed-worker-test-gates into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m22s
ci / java (push) Successful in 2m2s
ci / portable (push) Successful in 8m26s
2026-10-13 09:59:45 -04:00
Joseph Doherty 42c120aa83 Preserve framed reads across fake worker emission gates
ci / windows-x86 (push) Successful in 1m20s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m17s
ci / windows-x86 (pull_request) Successful in 1m25s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m27s
ci / portable (push) Successful in 9m43s
ci / portable (pull_request) Successful in 9m47s
2026-10-13 09:42:09 -04:00
dohertj2 19920b13da Merge pull request 'Allow redundant must_use only in generated Rust service traits' (#152) from fix/generated-rust-must-use into main
ci / windows-x86 (push) Successful in 1m25s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m15s
ci / portable (push) Successful in 9m6s
2026-10-13 09:37:53 -04:00
Joseph Doherty a99070005f Fix Rust 1.99 lint for generated service traits
ci / windows-x86 (push) Successful in 1m27s
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m45s
ci / java (pull_request) Successful in 2m55s
ci / windows-x86 (pull_request) Successful in 1m20s
ci / nightly-windev (pull_request) Has been skipped
ci / portable (push) Successful in 10m40s
ci / portable (pull_request) Successful in 10m18s
2026-10-13 09:24:07 -04:00
Joseph Doherty f40409a688 docs: strip task-tracking IDs from code comments and fill missing XML docs
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00
Joseph Doherty f2e0861709 docs: note that psbridge and winrm remote access to the wonder boxes are disabled (2026-09-11)
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 3m34s
ci / portable (push) Successful in 11m28s
Claude-Session: https://claude.ai/code/session_01PpXxRCFznFqE65ay6wAJwe
2026-09-11 09:05:35 -04:00
Joseph Doherty 4f931e4b7e chore(deps): bump ZB.MOM.WW.Auth 0.2.1->0.3.0
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m20s
ci / portable (push) Successful in 10m43s
Bumps ZB.MOM.WW.Auth.Abstractions, .ApiKeys, .AspNetCore, and .Ldap in
ZB.MOM.WW.MxGateway.Server.csproj and ZB.MOM.WW.MxGateway.IntegrationTests.csproj.
0.3.0 is additive (new IApiKeyStoreMigrator, SqliteAuthStoreMigrator implements it,
old ctors retained) — no source changes needed. Restore, Release build (0 warnings/
0 errors), and dotnet test on the NonWindows solution are unchanged versus the
pre-bump baseline: 1198 passed, 0 failed, 19 skipped (live-only tests, opt-in via
env vars).

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 10:57:10 -04:00
Joseph Doherty d16ae4e267 docs(claude): note the umbrella index's short-row convention for cross-repo propagation
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m34s
ci / portable (push) Successful in 9m14s
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 10:47:48 -04:00
Joseph Doherty 74ba889dea docs: record cycle-3 client release
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m21s
ci / java (push) Successful in 2m29s
ci / portable (push) Successful in 9m46s
2026-08-25 12:47:54 -04:00
205 changed files with 9018 additions and 2490 deletions
+13 -2
View File
@@ -49,7 +49,18 @@ mxaccessgw changes here — remote/push status, the `.proto` contracts (`mxacces
`mxaccess_worker.proto`, `galaxy_repository.proto`), the two-process architecture, shared-lib
consumption, or per-project commands — update the **MxAccessGateway entry in
[`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md)** in the same change so the umbrella index
never drifts from this repo. (Mirrors the same rule in the peer repos.)
never drifts from this repo. (Mirrors the same rule in the peer repos.) **Keep the index row to
a few lines** — since 2026-09-03 the umbrella keeps only short summary rows and parks long
per-project narrative in `../scadaproj/.claude/skills/sister-project-history/<project>.md`
(create `mxaccessgateway.md` there if this gateway ever needs one) and shared-library history
in `../scadaproj/<lib>/CLAUDE.md`.
> ⛔ **Wonder box remote access (2026-09-11): no `psbridge`, no `winrm`.** Both skills are retired/disabled —
> the WinRM remote-exec channel set off the security software on the wonder boxes (vd03/vq03/vq04). Do not
> run remote commands (service control, publish/swap, PowerShell) on those boxes and do not stand up a
> replacement channel until the user clears one. Still allowed: UNC/SMB file copies and logging directly
> into the SQL Servers. Deploy steps that need a command on the box are blocked; see the umbrella
> [`../scadaproj/CLAUDE.md`](../scadaproj/CLAUDE.md) for the current hold.
## Build, Test, Run
@@ -115,7 +126,7 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `docs/DesignDecisions.md` (Session-Resilience Epic Scope, session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` credentials, and `AcknowledgeAlarm` `operator_password` must never reach logs. Value logging is opt-in and redacted.
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
@@ -112,7 +112,7 @@ The continuation runs serially in the isolated `.worktrees/c3-p2-followups` work
| K — client alarm parity | CLI-47/48/49/50 | `gpt-5.6-terra` high | Done | All five client suites green |
| L — live watch-list/proto wave | watch-list refresh follow-up + WRK-32 proto comment | `gpt-5.6-sol` high | Done | Codegen clean; portable alarm/contract tests green; windev Worker.Tests green |
| M — Windows gateway CI | TST-35 | `gpt-5.6-terra` medium | Done | windev `live` mode proves Worker, gateway, full-slnx, and live smoke tiers |
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | In progress | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
| N — integration and release | publish five clients + Contracts | `gpt-5.6-sol` high | Done | Full local/windev gates; merged `main`; registry coordinates and Go tag verified |
### Continuation tasklist
@@ -220,12 +220,12 @@ in 53 s; and the live-MXAccess smoke passed **8/8** in 1m46s. TST-35 commit:
- [x] Run `git diff --check`, codegen, client-version, README, NonWindows build, and full gateway suite gates.
- [x] Run all five client suites on the final candidate.
- [ ] Run final windev `live` gate at the exact candidate SHA.
- [ ] Merge/push the verified continuation to `main`; wait for green CI.
- [ ] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
- [ ] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
- [ ] Create/push `clients/go/v0.2.1` at the exact release commit.
- [ ] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
- [x] Run final windev `live` gate at the exact candidate SHA.
- [x] Merge/push the verified continuation to `main`; wait for green CI.
- [x] Re-query all package coordinates and Go tag immediately before publish; abort on collision.
- [x] Publish .NET Client + Contracts 0.2.1, Python 0.2.1, Rust 0.2.1, Java 0.2.2.
- [x] Create/push `clients/go/v0.2.1` at the exact release commit.
- [x] Verify every registry coordinate and Go module/tag, update `docs/ClientPackaging.md`, and record immutable release evidence here.
Batch-N portable verification (2026-08-25): `git diff --check`, codegen freshness,
client-version freshness, and the generated README check all passed without changing
@@ -236,3 +236,24 @@ green (respectively **134 passed / 1 live skip**, all Go packages built/tested w
clean `gofmt`, **169 passed / 1 opt-in TLS skip** from a clean virtual environment,
**108 passed** plus clean `cargo fmt`/Clippy, and **132 passed / 0 failed** with the
generated Java tree unchanged).
Batch-N integration/release verification (2026-08-25): `main` was integrated and
released at exact commit `5776021970cbbe59813ba57579abe00d9fc61a46`; Gitea CI
run **783** concluded success. Windev `live` mode at that SHA built the x86/net48
Worker with **0 warnings / 0 errors**, passed Worker.Tests **529 passed / 11
opt-in skips / 0 failed**, built the full Windows solution with **0 warnings / 0
errors**, passed the Windows gateway suite **1193/1193**, and passed the live
MXAccess smoke **8/8**. One preceding full-suite attempt hit a transient fake-worker
disconnect in the reconnect/replay test; the test then passed **50/50** isolated
macOS runs and **20/20** isolated windev runs before the clean full-gate pass.
Immediately before publication, all five package coordinates returned HTTP 404
and `clients/go/v0.2.1` was absent locally/remotely. The published coordinates are
NuGet `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, PyPI
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, Cargo
`zb-mom-ww-mxgateway-client` **0.2.1**, and Maven
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. Each exact Gitea
package API coordinate returned HTTP 200 after publication. Annotated Go tag
`clients/go/v0.2.1` resolves locally, remotely, and through `go list -m` to
`5776021970cbbe59813ba57579abe00d9fc61a46`.
+10 -3
View File
@@ -143,8 +143,10 @@ the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm
reference, optional comment, ack target). All three accept a cancellation
keyed by the same monitor), and `AcknowledgeAlarmAsync` (acknowledges as
the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and
writes the comment to the alarm's `AckMsg`, so the Historian records the
operator). All three accept a cancellation
token and pass through the `MxGateway:Alarms` configuration on the
server — when alarms are disabled, the gateway returns an empty list / empty
stream rather than failing.
@@ -291,10 +293,15 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --se
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id <id> --max-events 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 4 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json
```
`acknowledge-alarm` reads the operator password from `--operator-password` or the
environment variable named by `--operator-password-env` (default
`MXGATEWAY_OPERATOR_PASSWORD`); the password is never echoed and is redacted from
any error output. Prefer the environment variable to keep it off the command line.
`smoke` opens a session, registers a client, adds one item, advises it,
optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
@@ -185,6 +185,7 @@ public static class MxGatewayClientCli
exception.Message,
apiKey,
TryResolveVerifyUserPassword(arguments),
ResolveOperatorPassword(arguments),
arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json"))
@@ -347,13 +348,19 @@ public static class MxGatewayClientCli
/// <summary>
/// Canonical CLI credential environment variable, shared by every official
/// client CLI (CLI-45) so one exported variable drives the same operator
/// client CLI so one exported variable drives the same operator
/// workflow in all five languages.
/// </summary>
private const string DefaultVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_PASSWORD";
/// <summary>
/// Pre-CLI-45 environment variable, still honoured as a deprecated fallback
/// Default environment variable holding the operator password for
/// <c>acknowledge-alarm</c> when <c>--operator-password-env</c> is not given.
/// </summary>
private const string OperatorPasswordEnvironmentName = "MXGATEWAY_OPERATOR_PASSWORD";
/// <summary>
/// Legacy environment variable, still honoured as a deprecated fallback
/// for one release so existing scripts keep working.
/// </summary>
private const string LegacyVerifyPasswordEnvironmentName = "MXGATEWAY_VERIFY_USER_PASSWORD";
@@ -379,14 +386,38 @@ public static class MxGatewayClientCli
}
/// <summary>
/// Resolves the effective MXAccess verify-user credential in the CLI-45
/// Resolves the <c>acknowledge-alarm</c> operator password: <c>--operator-password</c>,
/// then the environment variable named by <c>--operator-password-env</c>
/// (default <c>MXGATEWAY_OPERATOR_PASSWORD</c>). An empty value is treated as
/// absent. The value is never echoed; it is also fed to the error redactor.
/// </summary>
private static string? ResolveOperatorPassword(CliArguments arguments)
{
string? password = arguments.GetOptional("operator-password");
if (!string.IsNullOrEmpty(password))
{
return password;
}
string environmentName = arguments.GetOptional("operator-password-env") ?? string.Empty;
if (string.IsNullOrEmpty(environmentName))
{
environmentName = OperatorPasswordEnvironmentName;
}
password = Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrEmpty(password) ? null : password;
}
/// <summary>
/// Resolves the effective MXAccess verify-user credential in this
/// order: <c>--password</c>, the deprecated <c>--verify-user-password</c>
/// alias, the environment variable named by <c>--password-env</c> (default
/// <c>MXGATEWAY_VERIFY_PASSWORD</c>), then the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>. An empty value from any source is
/// treated as absent. The credential is never echoed; this resolver exists so
/// the error-redaction catch block can strip it from any surfaced error
/// (CLI-04), mirroring <see cref="TryResolveApiKey" />.
/// the error-redaction catch block can strip it from any surfaced error,
/// mirroring <see cref="TryResolveApiKey" />.
/// </summary>
private static string? TryResolveVerifyUserPassword(CliArguments arguments)
{
@@ -416,7 +447,7 @@ public static class MxGatewayClientCli
/// Resolves the verify-user credential for <c>authenticate-user</c>, throwing
/// a redaction-safe error when no source yields a non-empty value. Failing
/// fast keeps a misconfigured environment from becoming a real MXAccess
/// authentication attempt with an empty credential (CLI-45); the thrown
/// authentication attempt with an empty credential; the thrown
/// message names only the option/env var, never the value.
/// </summary>
private static string ResolveVerifyUserPassword(CliArguments arguments)
@@ -758,9 +789,9 @@ public static class MxGatewayClientCli
{
// The credential is resolved from --password or its env var (default
// MXGATEWAY_VERIFY_PASSWORD) and is never echoed; a missing or empty value
// fails fast before the invoke rather than reaching the wire (CLI-45).
// fails fast before the invoke rather than reaching the wire.
// On any surfaced error the RunCoreAsync catch block routes
// it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
// it through MxGatewayCliSecretRedactor so it cannot reach stderr.
return InvokeAndWriteAsync(
arguments,
client,
@@ -1425,7 +1456,7 @@ public static class MxGatewayClientCli
else if (gatewayEvent.ReplayGap is { } replayGap)
{
// Render the ReplayGap sentinel as the typed cross-CLI row instead of the raw
// sentinel MxEvent (NEXT-02, mirroring the Go/Python/Rust CLIs).
// sentinel MxEvent, mirroring the Go/Python/Rust CLIs.
output.WriteLine(FormatReplayGapRow(replayGap));
}
else
@@ -1535,6 +1566,8 @@ public static class MxGatewayClientCli
AlarmFullReference = arguments.GetRequired("reference"),
Comment = arguments.GetOptional("comment") ?? string.Empty,
OperatorUser = arguments.GetOptional("operator") ?? string.Empty,
OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty,
OperatorPassword = ResolveOperatorPassword(arguments) ?? string.Empty,
};
return WriteReplyAsync(
@@ -1849,7 +1882,7 @@ public static class MxGatewayClientCli
}
/// <summary>
/// Formats the typed ReplayGap row shared by the CLIs (NEXT-02). Hand-built so the
/// Formats the typed ReplayGap row shared by the CLIs. Hand-built so the
/// cursors are JSON numbers like the Go/Python/Rust rows, not the protobuf JSON
/// formatter's quoted uint64 strings.
/// </summary>
@@ -2465,7 +2498,7 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>] [--tag-start <n>] [--tag-prefix <s>] [--tag-attribute <s>] [--timeout-ms <n>] [--client-name <name>]");
writer.WriteLine("mxgw-dotnet stream-events --session-id <id> [--max-events <n>] [--json]");
writer.WriteLine("mxgw-dotnet stream-alarms [--filter-prefix <ref>] [--max-events <n>] [--json] [--jsonl]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> [--comment <text>] [--operator <user>] [--json]");
writer.WriteLine("mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]");
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
@@ -36,6 +36,8 @@ public sealed class MxGatewayClientAlarmsTests
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "investigating",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
OperatorDomain = "CORP",
});
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
@@ -45,9 +47,35 @@ public sealed class MxGatewayClientAlarmsTests
Assert.Equal("Tank01.Level.HiHi", call.Request.AlarmFullReference);
Assert.Equal("investigating", call.Request.Comment);
Assert.Equal("alice", call.Request.OperatorUser);
Assert.Equal("pw-secret", call.Request.OperatorPassword);
Assert.Equal("CORP", call.Request.OperatorDomain);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
/// <summary>AcknowledgeAlarmAsync redacts the operator password from a failing call's message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcknowledgeAlarmAsync_FailureMessage_DoesNotContainOperatorPassword()
{
FakeGatewayTransport transport = CreateTransport();
// The real transport maps RpcException to MxGatewayException using the status
// detail; the fake passes exceptions verbatim, so enqueue the mapped shape.
transport.AcknowledgeAlarmExceptions.Enqueue(new MxGatewayException("boom pw-secret"));
await using MxGatewayClient client = CreateClient(transport);
MxGatewayException ex = await Assert.ThrowsAsync<MxGatewayException>(
() => client.AcknowledgeAlarmAsync(new AcknowledgeAlarmRequest
{
AlarmFullReference = "Tank01.Level.HiHi",
OperatorUser = "alice",
OperatorPassword = "pw-secret",
}));
Assert.DoesNotContain("pw-secret", ex.Message);
Assert.Contains("boom", ex.Message);
}
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -134,6 +134,7 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
{
@@ -178,6 +179,7 @@ public sealed class MxGatewayClientCliTests
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
{
@@ -218,9 +220,10 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential
/// Verifies that a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
{
@@ -248,7 +251,7 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>
/// CLI-45: <c>--password</c> is the primary credential flag, matching the other
/// <c>--password</c> is the primary credential flag, matching the other
/// four CLIs. The credential reaches the wire but never stdout/stderr.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
@@ -290,10 +293,12 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>
/// CLI-45: the credential is read from the environment variable named by
/// the credential is read from the environment variable named by
/// <c>--password-env</c>, whose default is the canonical
/// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
/// </summary>
/// <param name="passwordEnvArgument">The value passed to <c>--password-env</c>, or null to use the default.</param>
/// <param name="environmentName">The environment variable that holds the credential.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
@@ -340,7 +345,7 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>
/// CLI-45: the pre-rename names stay usable for one release — the deprecated
/// the pre-rename names stay usable for one release — the deprecated
/// <c>--verify-user-password</c> flag and the deprecated
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
/// </summary>
@@ -413,7 +418,7 @@ public sealed class MxGatewayClientCliTests
}
/// <summary>
/// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
/// a missing or empty credential fails fast before the invoke — the CLI
/// never sends a fabricated empty password to the wire. The error names the flag
/// and the environment variable, never a value.
/// </summary>
@@ -463,6 +468,9 @@ public sealed class MxGatewayClientCliTests
private readonly string _name;
private readonly string? _original;
/// <summary>Sets the environment variable and remembers its original value.</summary>
/// <param name="name">The environment variable name.</param>
/// <param name="value">The temporary value, or null to clear it.</param>
public EnvironmentVariableScope(string name, string? value)
{
_name = name;
@@ -470,6 +478,7 @@ public sealed class MxGatewayClientCliTests
Environment.SetEnvironmentVariable(name, value);
}
/// <summary>Restores the original environment variable value.</summary>
public void Dispose()
{
Environment.SetEnvironmentVariable(_name, _original);
@@ -588,7 +597,7 @@ public sealed class MxGatewayClientCliTests
/// <summary>
/// Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
/// numeric cursors under a replayGap key — instead of the raw sentinel MxEvent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -754,6 +763,7 @@ public sealed class MxGatewayClientCliTests
[Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{
Environment.SetEnvironmentVariable("MXGW_TEST_OPPW", "op-pw-secret");
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
@@ -777,6 +787,10 @@ public sealed class MxGatewayClientCliTests
"ack from cli",
"--operator",
"operator1",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json",
],
output,
@@ -788,6 +802,9 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
Assert.Equal("ack from cli", request.Comment);
Assert.Equal("operator1", request.OperatorUser);
Assert.Equal("CORP", request.OperatorDomain);
Assert.Equal("op-pw-secret", request.OperatorPassword);
Assert.DoesNotContain("op-pw-secret", output.ToString());
Assert.Contains("ack-fixture", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
@@ -228,6 +228,7 @@ public sealed class MxGatewayClientSessionTests
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
/// events pass through unchanged with IsReplayGap false.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
{
@@ -430,6 +431,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnregisterAsync_BuildsUnregisterCommand()
{
@@ -451,6 +453,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
{
@@ -473,6 +476,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
{
@@ -498,6 +502,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
{
@@ -520,6 +525,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
{
@@ -545,6 +551,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
{
@@ -568,6 +575,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
{
@@ -598,6 +606,7 @@ public sealed class MxGatewayClientSessionTests
/// natively. The client must surface that scripted failure (as an
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
{
@@ -621,6 +630,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WriteSecured2Async_BuildsWriteSecured2Command()
{
@@ -647,6 +657,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
{
@@ -675,6 +686,7 @@ public sealed class MxGatewayClientSessionTests
/// must never contain the credential — the error path is built only from
/// reply-derived diagnostics, not the request payload.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
{
@@ -698,6 +710,7 @@ public sealed class MxGatewayClientSessionTests
}
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// </summary>
public sealed class MxGatewaySecretRedactionTests
{
/// <summary>Test: Redact replaces every occurrence of secret.</summary>
[Fact]
public void Redact_ReplacesEveryOccurrenceOfSecret()
{
@@ -19,6 +20,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal("pw=<redacted> retry pw=<redacted> again <redacted>", result);
}
/// <summary>Test: Redact scrubs both secrets when one is substring of the other.</summary>
[Fact]
public void Redact_ScrubsBothSecretsWhenOneIsSubstringOfTheOther()
{
@@ -33,6 +35,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.DoesNotContain("secret", result, StringComparison.Ordinal);
}
/// <summary>Test: Redact with null secrets array returns message unchanged.</summary>
[Fact]
public void Redact_WithNullSecretsArray_ReturnsMessageUnchanged()
{
@@ -43,6 +46,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result);
}
/// <summary>Test: Redact with empty secrets array returns message unchanged.</summary>
[Fact]
public void Redact_WithEmptySecretsArray_ReturnsMessageUnchanged()
{
@@ -53,6 +57,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result);
}
/// <summary>Test: Redact ignores whitespace only secret.</summary>
[Fact]
public void Redact_IgnoresWhitespaceOnlySecret()
{
@@ -64,6 +69,7 @@ public sealed class MxGatewaySecretRedactionTests
Assert.Equal(message, result);
}
/// <summary>Test: Redacted preserves concrete subtype and does not chain secret bearing original.</summary>
[Fact]
public void Redacted_PreservesConcreteSubtypeAndDoesNotChainSecretBearingOriginal()
{
@@ -4,15 +4,16 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Tests for the credential-scrub (CLI-40) and malformed-reply (CLI-41) contracts on the
/// Tests for the credential-scrub and malformed-reply contracts on the
/// credential and id-returning session helpers, driven from shared behavior fixtures.
/// </summary>
public sealed class MxGatewaySessionReplyContractTests
{
/// <summary>
/// CLI-40: when MXAccess echoes the submitted credential back in its failure diagnostic,
/// Verifies that when MXAccess echoes the submitted credential back in its failure diagnostic,
/// the surfaced exception message must scrub it to the library redaction marker.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateUserAsync_RedactsEchoedCredentialInFailureMessage()
{
@@ -33,13 +34,14 @@ public sealed class MxGatewaySessionReplyContractTests
}
/// <summary>
/// CLI-40: the redacted exception must not leak the echoed credential through any structured
/// Verifies that the redacted exception must not leak the echoed credential through any structured
/// accessor either — <see cref="MxAccessException.Reply"/> (protocol message, diagnostic
/// message, and each MXSTATUS_PROXY diagnostic text) and <see cref="MxGatewayException.Statuses"/>
/// all carry the server-echoed credential verbatim before the fix. Both the OK+negative-HRESULT
/// and the MXACCESS_FAILURE reply route to <see cref="MxAccessException"/>, so both must scrub.
/// </summary>
/// <param name="fixture">The echoed-credential reply fixture to drive.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("authenticate-user.echoed-credential.reply.json")]
[InlineData("authenticate-user.echoed-credential-mxaccess-failure.reply.json")]
@@ -71,9 +73,10 @@ public sealed class MxGatewaySessionReplyContractTests
}
/// <summary>
/// CLI-41: an OK reply that carries neither the typed AuthenticateUser payload nor an
/// Verifies that an OK reply that carries neither the typed AuthenticateUser payload nor an
/// int32 return_value is a malformed reply, surfaced as a typed exception rather than an NRE.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateUserAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
{
@@ -87,9 +90,10 @@ public sealed class MxGatewaySessionReplyContractTests
}
/// <summary>
/// CLI-41: an OK reply that omits the typed payload but carries an int32 return_value
/// Verifies that an OK reply that omits the typed payload but carries an int32 return_value
/// resolves to that return value.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuthenticateUserAsync_ReturnValueOnly_ResolvesReturnValue()
{
@@ -104,9 +108,10 @@ public sealed class MxGatewaySessionReplyContractTests
}
/// <summary>
/// CLI-41: the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
/// Verifies that the AddBufferedItem fallback shares the malformed-reply contract — an OK reply
/// with neither a typed item handle nor an int32 return_value throws the typed exception.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddBufferedItemAsync_MissingPayloadAndReturnValue_ThrowsMalformedReply()
{
@@ -32,6 +32,9 @@ public sealed class MxStatusProxyExtensionsTests
}
/// <summary>Verifies that the raw success member never overrides the authoritative category.</summary>
/// <param name="category">The status category under test.</param>
/// <param name="success">The raw success member, which must not influence the result.</param>
/// <param name="expected">The expected <c>IsSuccess</c> result.</param>
[Theory]
[InlineData(MxStatusCategory.Ok, 0, true)]
[InlineData(MxStatusCategory.Ok, 1, true)]
@@ -96,7 +96,8 @@ internal sealed class WireFakeGatewayServer : IAsyncDisposable
DefaultCallTimeout = TimeSpan.FromSeconds(30),
});
/// <inheritdoc />
/// <summary>Stops and disposes the underlying web application.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _app.StopAsync().ConfigureAwait(false);
@@ -183,24 +183,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
}
/// <summary>
/// Acknowledges an active MXAccess alarm condition through the gateway. The
/// gateway authenticates the request against the API key's <c>invoke:alarm-ack</c>
/// scope and forwards the acknowledge to the worker's MXAccess session;
/// the resulting <see cref="MxStatusProxy"/> is returned in the reply.
/// Acknowledges an alarm as the operator. The gateway authenticates
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
/// comment to the alarm's AckMsg attribute, so the alarm history records
/// the operator. The password is redacted from any surfaced error.
/// </summary>
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
/// <param name="request">The acknowledge request: alarm reference, comment, operator credentials.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken);
try
{
return await ExecuteSafeUnaryAsync(
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
cancellationToken).ConfigureAwait(false);
}
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
{
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
}
}
/// <summary>
@@ -854,6 +854,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnregisterAsync(
int serverHandle,
CancellationToken cancellationToken = default)
@@ -888,6 +889,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AdviseSupervisoryAsync(
int serverHandle,
int itemHandle,
@@ -985,6 +987,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task SetBufferedUpdateIntervalAsync(
int serverHandle,
int updateIntervalMilliseconds,
@@ -1125,6 +1128,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteSecuredAsync(
int serverHandle,
int itemHandle,
@@ -1204,6 +1208,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteSecured2Async(
int serverHandle,
int itemHandle,
+6 -2
View File
@@ -140,7 +140,11 @@ sentinel typed and observable; it never synthesizes or swallows it.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
`Client.AcknowledgeAlarm` to ack an alarm by full reference. Acks carry the
operator's Galaxy credentials (`OperatorUser` required, `OperatorPassword`,
optional `OperatorDomain`) so the gateway authenticates as the operator and the
Historian records that operator; an empty password is allowed, and the password
is scrubbed from any returned error. The streaming
call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
@@ -424,7 +428,7 @@ Every subcommand wired into the CLI. All accept the common flags
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
| `acknowledge-alarm` | Acknowledge an alarm reference. |
| `acknowledge-alarm` | Acknowledge an alarm as an operator (`-operator`, `-operator-domain`, `-operator-password-env`, default `MXGATEWAY_OPERATOR_PASSWORD`). |
| `smoke` | End-to-end smoke workflow against one item. |
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
| `galaxy-last-deploy` | Print the most recent deploy event. |
+23 -1
View File
@@ -1119,6 +1119,10 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
}
}
// defaultOperatorPasswordEnv is the CLI environment variable holding the
// operator password for acknowledge-alarm.
const defaultOperatorPasswordEnv = "MXGATEWAY_OPERATOR_PASSWORD"
func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("acknowledge-alarm", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -1126,7 +1130,12 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
jsonOutput := flags.Bool("json", false, "write JSON output")
reference := flags.String("reference", "", "full alarm reference to acknowledge")
comment := flags.String("comment", "", "operator acknowledge comment")
operator := flags.String("operator", "", "operator user performing the acknowledge")
operator := flags.String("operator", "", "operator user performing the acknowledge (required)")
operatorDomain := flags.String("operator-domain", "", "operator Galaxy domain (optional)")
// Prefer the environment variable so the credential stays out of shell
// history and the process table; an empty password is allowed.
operatorPassword := flags.String("operator-password", "", "operator password (prefer -operator-password-env)")
operatorPasswordEnv := flags.String("operator-password-env", defaultOperatorPasswordEnv, "environment variable containing the operator password")
if err := flags.Parse(args); err != nil {
return err
@@ -1134,6 +1143,17 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
if *reference == "" {
return errors.New("reference is required")
}
if *operator == "" {
return errors.New("operator is required")
}
resolvedOperatorPassword := *operatorPassword
if resolvedOperatorPassword == "" {
envName := *operatorPasswordEnv
if envName == "" {
envName = defaultOperatorPasswordEnv
}
resolvedOperatorPassword = os.Getenv(envName)
}
client, options, err := dialForCommand(ctx, common)
if err != nil {
@@ -1145,6 +1165,8 @@ func runAcknowledgeAlarm(ctx context.Context, args []string, stdout, stderr io.W
AlarmFullReference: *reference,
Comment: *comment,
OperatorUser: *operator,
OperatorDomain: *operatorDomain,
OperatorPassword: resolvedOperatorPassword,
})
if err != nil {
return err
@@ -3583,10 +3583,13 @@ func (x *AlarmFailoverConfig) GetFailbackStableProbes() int32 {
return 0
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
type AcknowledgeAlarmCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
@@ -3596,6 +3599,10 @@ type AcknowledgeAlarmCommand struct {
OperatorNode string `protobuf:"bytes,4,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
OperatorDomain string `protobuf:"bytes,5,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
OperatorFullName string `protobuf:"bytes,6,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
OperatorPassword string `protobuf:"bytes,7,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3672,6 +3679,13 @@ func (x *AcknowledgeAlarmCommand) GetOperatorFullName() string {
return ""
}
func (x *AcknowledgeAlarmCommand) GetOperatorPassword() string {
if x != nil {
return x.OperatorPassword
}
return ""
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
// the snapshot to alarms whose alarm_full_reference starts with the
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix).
@@ -3721,9 +3735,7 @@ func (x *QueryActiveAlarmsCommand) GetAlarmFilterPrefix() string {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
type AcknowledgeAlarmByNameCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
@@ -3739,6 +3751,10 @@ type AcknowledgeAlarmByNameCommand struct {
OperatorNode string `protobuf:"bytes,6,opt,name=operator_node,json=operatorNode,proto3" json:"operator_node,omitempty"`
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
OperatorFullName string `protobuf:"bytes,8,opt,name=operator_full_name,json=operatorFullName,proto3" json:"operator_full_name,omitempty"`
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
OperatorPassword string `protobuf:"bytes,9,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3829,6 +3845,13 @@ func (x *AcknowledgeAlarmByNameCommand) GetOperatorFullName() string {
return ""
}
func (x *AcknowledgeAlarmByNameCommand) GetOperatorPassword() string {
if x != nil {
return x.OperatorPassword
}
return ""
}
type UnsubscribeBulkCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"`
@@ -6127,11 +6150,11 @@ func (x *DrainEventsReply) GetEvents() []*MxEvent {
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// AckMsg write was applied or sent but unconfirmed; otherwise the
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// this payload exists so GatewayAlarmMonitor
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
type AcknowledgeAlarmReplyPayload struct {
@@ -7217,9 +7240,18 @@ type AcknowledgeAlarmRequest struct {
Comment string `protobuf:"bytes,4,opt,name=comment,proto3" json:"comment,omitempty"`
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
OperatorUser string `protobuf:"bytes,5,opt,name=operator_user,json=operatorUser,proto3" json:"operator_user,omitempty"`
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
OperatorPassword string `protobuf:"bytes,6,opt,name=operator_password,json=operatorPassword,proto3" json:"operator_password,omitempty"`
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
OperatorDomain string `protobuf:"bytes,7,opt,name=operator_domain,json=operatorDomain,proto3" json:"operator_domain,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AcknowledgeAlarmRequest) Reset() {
@@ -7280,14 +7312,27 @@ func (x *AcknowledgeAlarmRequest) GetOperatorUser() string {
return ""
}
func (x *AcknowledgeAlarmRequest) GetOperatorPassword() string {
if x != nil {
return x.OperatorPassword
}
return ""
}
func (x *AcknowledgeAlarmRequest) GetOperatorDomain() string {
if x != nil {
return x.OperatorDomain
}
return ""
}
type AcknowledgeAlarmReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"`
ProtocolStatus *ProtocolStatus `protobuf:"bytes,3,opt,name=protocol_status,json=protocolStatus,proto3" json:"protocol_status,omitempty"`
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
Hresult *int32 `protobuf:"varint,4,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"`
@@ -8992,7 +9037,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\x13AlarmFailoverConfig\x12B\n" +
"\x1dconsecutive_failure_threshold\x18\x01 \x01(\x05R\x1bconsecutiveFailureThreshold\x12E\n" +
"\x1ffailback_probe_interval_seconds\x18\x02 \x01(\x05R\x1cfailbackProbeIntervalSeconds\x124\n" +
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xf3\x01\n" +
"\x16failback_stable_probes\x18\x03 \x01(\x05R\x14failbackStableProbes\"\xa0\x02\n" +
"\x17AcknowledgeAlarmCommand\x12\x1d\n" +
"\n" +
"alarm_guid\x18\x01 \x01(\tR\talarmGuid\x12\x18\n" +
@@ -9000,9 +9045,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\roperator_user\x18\x03 \x01(\tR\foperatorUser\x12#\n" +
"\roperator_node\x18\x04 \x01(\tR\foperatorNode\x12'\n" +
"\x0foperator_domain\x18\x05 \x01(\tR\x0eoperatorDomain\x12,\n" +
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\"J\n" +
"\x12operator_full_name\x18\x06 \x01(\tR\x10operatorFullName\x12+\n" +
"\x11operator_password\x18\a \x01(\tR\x10operatorPassword\"J\n" +
"\x18QueryActiveAlarmsCommand\x12.\n" +
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xbd\x02\n" +
"\x13alarm_filter_prefix\x18\x01 \x01(\tR\x11alarmFilterPrefix\"\xea\x02\n" +
"\x1dAcknowledgeAlarmByNameCommand\x12\x1d\n" +
"\n" +
"alarm_name\x18\x01 \x01(\tR\talarmName\x12#\n" +
@@ -9013,7 +9059,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12#\n" +
"\roperator_node\x18\x06 \x01(\tR\foperatorNode\x12'\n" +
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomain\x12,\n" +
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\"`\n" +
"\x12operator_full_name\x18\b \x01(\tR\x10operatorFullName\x12+\n" +
"\x11operator_password\x18\t \x01(\tR\x10operatorPassword\"`\n" +
"\x16UnsubscribeBulkCommand\x12#\n" +
"\rserver_handle\x18\x01 \x01(\x05R\fserverHandle\x12!\n" +
"\fitem_handles\x18\x02 \x03(\x05R\vitemHandles\"v\n" +
@@ -9262,12 +9309,14 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"limitValue\x12\x1a\n" +
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\x126\n" +
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xd0\x01\n" +
"\x17from_truncated_snapshot\x18\x10 \x01(\bR\x15fromTruncatedSnapshot\"\xa6\x02\n" +
"\x17AcknowledgeAlarmRequest\x122\n" +
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
"\acomment\x18\x04 \x01(\tR\acomment\x12#\n" +
"\roperator_user\x18\x05 \x01(\tR\foperatorUserJ\x04\b\x01\x10\x02R\n" +
"\roperator_user\x18\x05 \x01(\tR\foperatorUser\x12+\n" +
"\x11operator_password\x18\x06 \x01(\tR\x10operatorPassword\x12'\n" +
"\x0foperator_domain\x18\a \x01(\tR\x0eoperatorDomainJ\x04\b\x01\x10\x02R\n" +
"session_id\"\xb4\x02\n" +
"\x15AcknowledgeAlarmReply\x12%\n" +
"\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\x12L\n" +
+7 -2
View File
@@ -10,6 +10,11 @@ import (
// invoke:alarm-ack scope and forwards the acknowledge to the worker's MXAccess
// session; the resulting native MxStatus is returned in the reply.
//
// OperatorUser is required: the gateway authenticates the operator (with
// OperatorPassword and optional OperatorDomain) so the Historian records the
// real operator. An empty password is allowed. The password is scrubbed from
// any error this method returns.
//
// Acks are idempotent — re-acking an already-acked condition is a no-op at
// the MxAccess layer.
func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequest) (*AcknowledgeAlarmReply, error) {
@@ -22,10 +27,10 @@ func (c *Client) AcknowledgeAlarm(ctx context.Context, req *AcknowledgeAlarmRequ
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "acknowledge alarm", Err: err}
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
}
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
return reply, err
return reply, redactSecrets(err, req.GetOperatorPassword())
}
return reply, nil
+29
View File
@@ -5,6 +5,7 @@ import (
"errors"
"io"
"net"
"strings"
"testing"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -38,6 +39,8 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
AlarmFullReference: "Tank01.Level.HiHi",
Comment: "investigating",
OperatorUser: "alice",
OperatorPassword: "pw-secret",
OperatorDomain: "CORP",
})
if err != nil {
t.Fatalf("AcknowledgeAlarm() error = %v", err)
@@ -51,11 +54,37 @@ func TestAcknowledgeAlarmSendsRequestAndReturnsReply(t *testing.T) {
if got := fake.acknowledgeRequest.GetComment(); got != "investigating" {
t.Fatalf("captured comment = %q", got)
}
if got := fake.acknowledgeRequest.GetOperatorPassword(); got != "pw-secret" {
t.Fatalf("captured operator password mismatch")
}
if got := fake.acknowledgeRequest.GetOperatorDomain(); got != "CORP" {
t.Fatalf("captured operator domain = %q", got)
}
if got := fake.acknowledgeAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q", got)
}
}
func TestAcknowledgeAlarmRedactsPasswordFromError(t *testing.T) {
fake := &fakeGatewayWithAlarms{
acknowledgeError: status.Error(codes.Internal, "boom pw-secret"),
}
client, cleanup := newBufconnClientWithAlarms(t, fake)
defer cleanup()
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
AlarmFullReference: "Tank01.Level.HiHi",
OperatorUser: "alice",
OperatorPassword: "pw-secret",
})
if err == nil {
t.Fatal("AcknowledgeAlarm() returned no error")
}
if strings.Contains(err.Error(), "pw-secret") {
t.Fatalf("error leaks operator password: %v", err)
}
}
func TestAcknowledgeAlarmRejectsNilRequest(t *testing.T) {
fake := &fakeGatewayWithAlarms{}
client, cleanup := newBufconnClientWithAlarms(t, fake)
+6 -3
View File
@@ -114,8 +114,11 @@ try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
yields alarm-feed messages from the gateway's central monitor), and
`acknowledgeAlarm` (ack by full alarm reference with an optional comment and
ack target). Close the subscription to cancel the underlying gRPC stream.
`acknowledgeAlarm` (ack by full alarm reference with an optional comment).
The ack carries operator credentials: `operator_user` is required, and
`operator_password` / `operator_domain` let the gateway authenticate as that
operator so the Historian records the real operator. An empty password is
allowed. The password is scrubbed from exceptions the client throws. Close the subscription to cancel the underlying gRPC stream.
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
@@ -417,7 +420,7 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localho
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 4 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --item TestObject.TestInt --json"
```
File diff suppressed because it is too large Load Diff
@@ -1822,6 +1822,31 @@ public final class MxGatewayCli implements Callable<Integer> {
@Option(names = "--operator", description = "Operator user performing the acknowledge.")
String operator = "";
@Option(names = "--operator-domain", description = "Operator Galaxy domain (optional).")
String operatorDomain = "";
@Option(
names = "--operator-password",
description = "Operator Galaxy password (credential; prefer --operator-password-env). Never echoed.")
String operatorPassword;
@Option(
names = "--operator-password-env",
defaultValue = "MXGATEWAY_OPERATOR_PASSWORD",
description = "Environment variable holding the operator password when --operator-password is omitted.")
String operatorPasswordEnv;
private String resolveOperatorPassword() {
if (operatorPassword != null && !operatorPassword.isEmpty()) {
return operatorPassword;
}
String name = operatorPasswordEnv == null || operatorPasswordEnv.isBlank()
? "MXGATEWAY_OPERATOR_PASSWORD"
: operatorPasswordEnv;
String value = System.getenv(name);
return value == null ? "" : value;
}
AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@@ -1833,6 +1858,8 @@ public final class MxGatewayCli implements Callable<Integer> {
.setAlarmFullReference(reference)
.setComment(comment)
.setOperatorUser(operator)
.setOperatorDomain(operatorDomain == null ? "" : operatorDomain)
.setOperatorPassword(resolveOperatorPassword())
.build());
writeOutput(
"acknowledge-alarm",
@@ -714,9 +714,16 @@ final class MxGatewayCliTests {
"checked",
"--operator",
"operator1",
"--operator-domain",
"CORP",
"--operator-password",
"pw-secret",
"--json");
assertEquals(0, run.exitCode());
assertEquals("CORP", factory.client.lastAcknowledgeAlarmRequest.getOperatorDomain());
assertEquals("pw-secret", factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword());
assertFalse(run.output().contains("pw-secret"), run.output());
assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
@@ -770,6 +777,10 @@ final class MxGatewayCliTests {
"--plaintext",
"--reference",
"\\Galaxy\\Area001.Pump001.PumpFault",
"--operator",
"operator1",
"--operator-password-env",
"MXGATEWAY_OPERATOR_PASSWORD",
"--json"
};
assertReadmeExampleParses(args);
@@ -273,6 +273,11 @@ public final class MxGatewayClient implements AutoCloseable {
* worker's MXAccess session; the resulting native MxStatus is returned
* in the reply. Acks are idempotent at the MxAccess layer.
*
* <p>{@code operator_user} is required; the gateway authenticates it with
* {@code operator_password} (and optional {@code operator_domain}) and the
* Historian records that operator. An empty password is allowed. The
* password is scrubbed from any exception this method throws.
*
* @param request the {@code AcknowledgeAlarmRequest}
* @return the raw acknowledge reply
* @throws MxGatewayException on transport or protocol failure
@@ -283,10 +288,14 @@ public final class MxGatewayClient implements AutoCloseable {
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
return reply;
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
? gatewayError
: (MxGatewayException) MxGatewayErrors.fromGrpc("acknowledge alarm", error);
String secret = request.getOperatorPassword();
if (secret.isEmpty()) {
throw mapped;
}
throw MxGatewayErrors.fromGrpc("acknowledge alarm", error);
throw MxGatewayErrors.redacted(mapped, secret);
}
}
@@ -299,10 +308,24 @@ public final class MxGatewayClient implements AutoCloseable {
*/
public CompletableFuture<AcknowledgeAlarmReply> acknowledgeAlarmAsync(AcknowledgeAlarmRequest request) {
CompletableFuture<AcknowledgeAlarmReply> future = toCompletable(rawFutureStub().acknowledgeAlarm(request));
return future.thenApply(reply -> {
String secret = request.getOperatorPassword();
CompletableFuture<AcknowledgeAlarmReply> checked = future.thenApply(reply -> {
MxGatewayErrors.ensureProtocolSuccess("acknowledge alarm", reply.getProtocolStatus(), null);
return reply;
});
if (secret.isEmpty()) {
return checked;
}
// Scrub the operator password from any failure surfaced through the future.
return checked.exceptionally(error -> {
Throwable cause = error instanceof java.util.concurrent.CompletionException && error.getCause() != null
? error.getCause()
: error;
if (cause instanceof MxGatewayException gatewayError) {
throw new java.util.concurrent.CompletionException(MxGatewayErrors.redacted(gatewayError, secret));
}
throw new java.util.concurrent.CompletionException(cause);
});
}
/**
@@ -28,6 +28,43 @@ final class MxGatewayErrors {
return new MxGatewayException("gateway " + operation + " failed: " + error.getMessage(), error);
}
/**
* Rebuilds a gateway exception with an exact secret scrubbed from its
* message and protocol status. The original is not chained as a cause
* because its message and cause chain may carry the secret.
*/
static MxGatewayException redacted(MxGatewayException error, String secret) {
String message = MxGatewaySecrets.redactExact(error.getMessage(), secret);
if (error instanceof MxAccessException ex) {
return new MxAccessException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
}
if (error instanceof MxGatewayCommandException ex) {
return new MxGatewayCommandException(message, scrub(ex.protocolStatus(), secret), ex.reply(), null);
}
if (error instanceof MxGatewaySessionException ex) {
return new MxGatewaySessionException(message, scrub(ex.protocolStatus(), secret), null);
}
if (error instanceof MxGatewayWorkerException ex) {
return new MxGatewayWorkerException(message, scrub(ex.protocolStatus(), secret), null);
}
if (error instanceof MxGatewayAuthenticationException) {
return new MxGatewayAuthenticationException(message, null);
}
if (error instanceof MxGatewayAuthorizationException) {
return new MxGatewayAuthorizationException(message, null);
}
return new MxGatewayException(message);
}
private static ProtocolStatus scrub(ProtocolStatus status, String secret) {
if (status == null) {
return null;
}
return status.toBuilder()
.setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secret))
.build();
}
static void ensureProtocolSuccess(String operation, ProtocolStatus status, MxCommandReply reply) {
if (status == null || status.getCode() == ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) {
return;
@@ -74,6 +74,52 @@ final class MxGatewayCredentialReplyTests {
}
}
@Test
void acknowledgeAlarmAsyncRedactsOperatorPasswordFromFailure() throws Exception {
String serverName = "mxgw-java-ack-" + UUID.randomUUID();
MxAccessGatewayGrpc.MxAccessGatewayImplBase service = new MxAccessGatewayGrpc.MxAccessGatewayImplBase() {
@Override
public void acknowledgeAlarm(
mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest request,
StreamObserver<mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply> observer) {
observer.onNext(mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply.newBuilder()
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("auth failed for operator1 password " + CREDENTIAL))
.build());
observer.onCompleted();
}
};
Server server = InProcessServerBuilder.forName(serverName)
.directExecutor().addService(service).build().start();
ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build();
try (MxGatewayClient client = new MxGatewayClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process").apiKey("").plaintext(true)
.callTimeout(Duration.ofSeconds(5)).build())) {
var request = mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest.newBuilder()
.setAlarmFullReference("Tank01.Level.HiHi")
.setOperatorUser("operator1")
.setOperatorPassword(CREDENTIAL)
.build();
java.util.concurrent.ExecutionException async = assertThrows(
java.util.concurrent.ExecutionException.class,
() -> client.acknowledgeAlarmAsync(request).get());
assertTrue(async.getCause() instanceof MxGatewayException, String.valueOf(async.getCause()));
assertFalse(async.getCause().getMessage().contains(CREDENTIAL));
assertFalse(((MxGatewayCommandException) async.getCause()).protocolStatus().getMessage()
.contains(CREDENTIAL));
MxGatewayException sync = assertThrows(MxGatewayException.class, () -> client.acknowledgeAlarm(request));
assertFalse(sync.getMessage().contains(CREDENTIAL));
} finally {
channel.shutdownNow();
server.shutdownNow();
}
}
@Test
void authenticateUserMissingPayloadThrowsMalformedReply() throws Exception {
MxCommandReply reply = loadReply("authenticate-user.missing-payload.reply.json");
+10 -4
View File
@@ -111,9 +111,15 @@ MXAccess commands and preserve raw replies on typed command exceptions.
For alarms, the client exposes `GatewayClient.query_active_alarms` (one-shot
snapshot), `GatewayClient.stream_alarms` (async generator yielding alarm-feed
messages from the gateway's central monitor), and
`GatewayClient.acknowledge_alarm` (ack by alarm reference, optional comment
and ack target). Cancel the surrounding task or `aclose()` the iterator to
terminate the stream.
`GatewayClient.acknowledge_alarm` (ack by alarm reference with an optional
comment). The ack is made as the operator: set `operator_user` (required) and
`operator_password` (may be empty) and optionally `operator_domain` on
`AcknowledgeAlarmRequest`. The gateway authenticates the operator and writes the
comment to the alarm's `AckMsg`, so the alarm history records the operator. The
password is redacted from any raised error.
Cancel the surrounding task or `aclose()` an alarm iterator to terminate the
stream.
`ActiveAlarmSnapshot.from_truncated_snapshot` reports that the record came from
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
@@ -457,7 +463,7 @@ mxgw-py add-item --session-id <id> --server-handle 1 --item Object.Attribute --j
mxgw-py advise --session-id <id> --server-handle 1 --item-handle 2 --json
mxgw-py stream-events --session-id <id> --max-events 1 --json
mxgw-py stream-alarms --max-messages 4 --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --json
mxgw-py acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD --json
mxgw-py write --session-id <id> --server-handle 1 --item-handle 2 --type int32 --value 123 --json
```
@@ -9,7 +9,7 @@ from typing import Any
import grpc
from .auth import merge_metadata
from .errors import ensure_protocol_success, map_rpc_error
from .errors import MxGatewayError, ensure_protocol_success, map_rpc_error
from .generated import mxaccess_gateway_pb2 as pb
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
from .options import ClientOptions, create_channel, resolve_channel_security
@@ -148,17 +148,23 @@ class GatewayClient:
self,
request: pb.AcknowledgeAlarmRequest,
) -> pb.AcknowledgeAlarmReply:
"""Acknowledge an active MXAccess alarm condition through the gateway.
"""Acknowledge an alarm as the operator.
The gateway authenticates the request against the API key's
``invoke:alarm-ack`` scope and forwards the acknowledge to the worker's
MXAccess session; the resulting native ``MxStatus`` is returned in the
reply. Acks are idempotent — re-acking an already-acked condition is a
no-op at the MxAccess layer.
The gateway authenticates ``operator_user`` with ``operator_password`` and
writes the comment to the alarm's ``AckMsg``, so the alarm history records
the operator. The password is redacted from any raised error.
"""
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return reply
try:
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
return reply
except MxGatewayError as error:
if request.operator_password:
# Imported lazily: session.py imports this module at its tail.
from .session import _redact_error
_redact_error(error, [request.operator_password])
raise
def query_active_alarms(
self,
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from typing import Any
from .auth import redact_secret
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
@@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
Operates on a clone so the caller's original reply object is never mutated.
Only the free-text diagnostic fields that can echo a client-supplied secret
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
each field is scrubbed only when the message type declares it.
"""
clone = type(reply)()
clone.CopyFrom(reply)
if clone.protocol_status.message:
fields = clone.DESCRIPTOR.fields_by_name
if "protocol_status" in fields and clone.protocol_status.message:
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
if clone.diagnostic_message:
if "diagnostic_message" in fields and clone.diagnostic_message:
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
for status in clone.statuses:
if status.diagnostic_text:
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
if "statuses" in fields:
for status in clone.statuses:
if status.diagnostic_text:
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
return clone
@@ -36,6 +36,7 @@ MAX_AGGREGATE_EVENTS = 10_000
#: CLI (CLI-45) so one exported variable drives the same operator workflow in all
#: five languages.
DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD"
DEFAULT_OPERATOR_PASSWORD_ENV = "MXGATEWAY_OPERATOR_PASSWORD"
_BATCH_EOR = "__MXGW_BATCH_EOR__"
@@ -528,16 +529,30 @@ def stream_alarms(**kwargs: Any) -> None:
@gateway_options
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
@click.option("--comment", default="", help="Acknowledgement comment.")
@click.option("--operator", default="", help="Operator user name.")
@click.option("--operator", required=True, help="Operator user name (recorded in the alarm history).")
@click.option("--operator-domain", default="", help="Operator Windows/Galaxy domain (optional).")
@click.option(
"--operator-password",
default=None,
help="Operator password. Prefer --operator-password-env so the secret is not visible on the command line.",
)
@click.option(
"--operator-password-env",
default=DEFAULT_OPERATOR_PASSWORD_ENV,
show_default=True,
help="Environment variable holding the operator password (an unset variable means an empty password).",
)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def acknowledge_alarm(**kwargs: Any) -> None:
"""Acknowledge an active MXAccess alarm condition (session-less)."""
"""Acknowledge an active MXAccess alarm condition as the operator (session-less)."""
password = _resolve_operator_password(kwargs)
kwargs["operator_password"] = password
_run(
_acknowledge_alarm(**kwargs),
output_json=kwargs["output_json"],
secrets=_secrets(kwargs),
secrets=_secrets(kwargs) + [password],
)
@@ -836,6 +851,21 @@ async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
return {"userId": user_id}
def _resolve_operator_password(kwargs: dict[str, Any]) -> str:
"""Resolve the alarm-ack operator password from --operator-password or its env var.
Unlike :func:`_resolve_password`, an unset or empty value is allowed: Galaxy
accounts may have empty passwords. The value is never echoed; callers add it
to the ``secrets`` redaction list.
"""
env_name = kwargs.get("operator_password_env") or DEFAULT_OPERATOR_PASSWORD_ENV
password = kwargs.get("operator_password")
if password is None:
password = os.environ.get(env_name, "")
return password
def _resolve_password(kwargs: dict[str, Any]) -> str:
"""Resolve the authenticate-user password from --password or --password-env.
@@ -1161,6 +1191,8 @@ async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
alarm_full_reference=kwargs["reference"],
comment=kwargs["comment"],
operator_user=kwargs["operator"],
operator_domain=kwargs["operator_domain"],
operator_password=kwargs["operator_password"],
),
)
return _message_dict(reply)
+61
View File
@@ -34,6 +34,8 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
alarm_full_reference="Tank01.Level.HiHi",
comment="investigating",
operator_user="alice",
operator_password="pw-secret",
operator_domain="CORP",
),
)
@@ -44,9 +46,68 @@ async def test_acknowledge_alarm_sends_request_and_returns_reply() -> None:
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"
assert captured_request.operator_password == "pw-secret"
assert captured_request.operator_domain == "CORP"
assert stub.acknowledge_alarm.metadata == (("authorization", "Bearer mxgw_test_secret"),)
@pytest.mark.asyncio
async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
stub = FakeGatewayStub()
stub.acknowledge_alarm.exception = FakeRpcError(
grpc.StatusCode.UNAUTHENTICATED, "bad credentials pw-secret",
)
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
with pytest.raises(MxGatewayAuthenticationError) as excinfo:
await client.acknowledge_alarm(
pb.AcknowledgeAlarmRequest(
alarm_full_reference="Tank01.Level.HiHi",
operator_user="alice",
operator_password="pw-secret",
),
)
assert "pw-secret" not in str(excinfo.value)
@pytest.mark.asyncio
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
from zb_mom_ww_mxgateway.errors import MxGatewayError
stub = FakeGatewayStub()
stub.acknowledge_alarm.replies = [
pb.AcknowledgeAlarmReply(
protocol_status=pb.ProtocolStatus(
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
message="rejected credential pw-secret",
),
),
]
client = await GatewayClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
with pytest.raises(MxGatewayError) as excinfo:
await client.acknowledge_alarm(
pb.AcknowledgeAlarmRequest(
alarm_full_reference="Tank01.Level.HiHi",
operator_user="alice",
operator_password="pw-secret",
),
)
assert "pw-secret" not in str(excinfo.value)
raw = excinfo.value.raw_reply
assert isinstance(raw, pb.AcknowledgeAlarmReply)
assert "pw-secret" not in raw.protocol_status.message
assert "pw-secret" not in str(raw)
@pytest.mark.asyncio
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
stub = FakeGatewayStub()
@@ -771,6 +771,7 @@ def test_cli_stream_alarms_positive_max_messages_remains_finite(monkeypatch) ->
def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
monkeypatch.setenv("MXGW_TEST_OPPW", "pw-secret")
stub = _BulkFakeStub()
stub.acknowledge_alarm = _BulkFakeUnary(
[
@@ -798,12 +799,19 @@ def test_cli_acknowledge_alarm_happy_path(monkeypatch) -> None:
"investigating",
"--operator",
"alice",
"--operator-domain",
"CORP",
"--operator-password-env",
"MXGW_TEST_OPPW",
"--json",
],
)
assert result.exit_code == 0, result.output
assert "pw-secret" not in result.output
captured_request = stub.acknowledge_alarm.requests[0]
assert captured_request.operator_domain == "CORP"
assert captured_request.operator_password == "pw-secret"
assert captured_request.alarm_full_reference == "Tank01.Level.HiHi"
assert captured_request.comment == "investigating"
assert captured_request.operator_user == "alice"
+5 -1
View File
@@ -78,7 +78,8 @@ cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 4 --json
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
cargo run -p mxgw-cli -- acknowledge-alarm --reference "Area001.Pump001.PumpFault" --operator <user> [--operator-domain <domain>] --json
# operator password: MXGATEWAY_OPERATOR_PASSWORD, or --operator-password-env / --operator-password
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json
```
@@ -124,6 +125,9 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
`stream_alarms`, `acknowledge_alarm`, and `raw_client`. `stream_alarms`
returns an `AlarmFeedStream` async stream of alarm-feed messages and
shares the gateway's central alarm monitor with every other client.
`acknowledge_alarm` requires `operator_user` and carries `operator_password` (may be
empty) and `operator_domain`; the gateway authenticates as that operator so the Historian
records them. The password is scrubbed from any returned error's `Display`/`Debug`.
The `from_truncated_snapshot` field on `ActiveAlarmSnapshot` reports that the record came from
a provider fetch which hit the per-fetch cap: the snapshot set may omit active
+3 -1
View File
@@ -174,6 +174,8 @@ alarm monitor — no worker session is opened — so any number of clients may
attach. Dropping the stream cancels the gRPC call cooperatively.
`acknowledge_alarm` is idempotent at the MxAccess layer; the returned
`AcknowledgeAlarmReply` carries the native MxStatus from the worker.
`operator_user` is required; `operator_password` may be empty and is scrubbed from any
returned error via `Error::redact_secrets`.
## Galaxy Repository
@@ -346,7 +348,7 @@ mxgw write-secured-bulk ...
mxgw write-secured2-bulk ...
mxgw stream-events --session-id <id> --json
mxgw stream-alarms [--filter-prefix <prefix>] [--max-events <n>]
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] [--operator <user>]
mxgw acknowledge-alarm --reference <full-ref> [--comment <txt>] --operator <user> [--operator-domain <domain>] [--operator-password-env <VAR>=MXGATEWAY_OPERATOR_PASSWORD] [--operator-password <pw>]
mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-size <n>]
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
mxgw batch
+33 -1
View File
@@ -374,8 +374,19 @@ enum Command {
reference: String,
#[arg(long, default_value = "")]
comment: String,
#[arg(long, default_value = "")]
/// Galaxy account of the acknowledging operator (required; the gateway
/// rejects an empty operator so the real operator is recorded).
#[arg(long)]
operator: String,
/// Galaxy domain of the operator; empty authenticates the bare user name.
#[arg(long, default_value = "")]
operator_domain: String,
/// Operator password (prefer `--operator-password-env`; empty is allowed).
#[arg(long)]
operator_password: Option<String>,
/// Environment variable holding the operator password.
#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")]
operator_password_env: String,
#[arg(long)]
json: bool,
},
@@ -1081,8 +1092,16 @@ async fn dispatch(command: Command) -> Result<(), Error> {
reference,
comment,
operator,
operator_domain,
operator_password,
operator_password_env,
json,
} => {
// An empty password is allowed (Galaxy accounts may have one), so
// an unset flag and env resolve to an empty string.
let operator_password = operator_password
.or_else(|| env::var(&operator_password_env).ok())
.unwrap_or_default();
let client = connect(connection).await?;
let reply = client
.acknowledge_alarm(AcknowledgeAlarmRequest {
@@ -1090,6 +1109,8 @@ async fn dispatch(command: Command) -> Result<(), Error> {
alarm_full_reference: reference,
comment,
operator_user: operator,
operator_password,
operator_domain,
})
.await?;
print_acknowledge_alarm_reply(&reply, json);
@@ -2771,6 +2792,17 @@ mod tests {
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn acknowledge_alarm_requires_operator() {
let parsed = Cli::try_parse_from([
"mxgw",
"acknowledge-alarm",
"--reference",
"Tank01.Level.HiHi",
]);
assert!(parsed.is_err());
}
#[test]
fn acknowledge_alarm_requires_reference() {
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
+30 -13
View File
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6;
string operator_domain = 7;
string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
}
message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// AckMsg write was applied or sent but unconfirmed; otherwise the
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// this payload exists so GatewayAlarmMonitor
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5;
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
}
message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
optional int32 hresult = 4;
+15 -6
View File
@@ -327,6 +327,9 @@ impl GatewayClient {
/// MXAccess session; the resulting native MxStatus is returned in the
/// reply. Acks are idempotent at the MxAccess layer.
///
/// `operator_user` is required; `operator_password` may be empty. The
/// password is scrubbed from any returned error's `Display`/`Debug`.
///
/// # Errors
///
/// Returns [`Error::ProtocolStatus`] when the gateway accepts the call but
@@ -336,13 +339,19 @@ impl GatewayClient {
&self,
request: AcknowledgeAlarmRequest,
) -> Result<AcknowledgeAlarmReply, Error> {
let secret = request.operator_password.clone();
let mut client = self.inner.clone();
let response = client
.acknowledge_alarm(self.unary_request(request))
.await?;
let reply = response.into_inner();
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
Ok(reply)
let result = async {
let response = client
.acknowledge_alarm(self.unary_request(request))
.await?;
let reply = response.into_inner();
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
Ok(reply)
}
.await;
// The operator password must never surface in a returned error.
result.map_err(|error: Error| error.redact_secrets(&[secret.as_str()]))
}
/// Open the server-streaming `QueryActiveAlarms` RPC — the gateway's
+70
View File
@@ -314,6 +314,68 @@ impl std::fmt::Display for MxAccessError {
impl std::error::Error for MxAccessError {}
impl Error {
/// Scrub exact caller-supplied secrets (for example an operator password)
/// from the error's rendered text and any wrapped `tonic::Status`, so that
/// neither `Display` nor `Debug` can reveal them. Empty secrets are ignored.
#[must_use]
pub fn redact_secrets(self, secrets: &[&str]) -> Self {
let scrub = |text: &str| {
let mut out = text.to_owned();
for secret in secrets.iter().filter(|secret| !secret.is_empty()) {
out = out.replace(*secret, "<redacted>");
}
out
};
let scrub_status = |status: Box<tonic::Status>| {
Box::new(tonic::Status::new(status.code(), scrub(status.message())))
};
match self {
Self::Authentication { message, status } => Self::Authentication {
message: scrub(&message),
status: scrub_status(status),
},
Self::Authorization { message, status } => Self::Authorization {
message: scrub(&message),
status: scrub_status(status),
},
Self::Timeout { message, status } => Self::Timeout {
message: scrub(&message),
status: scrub_status(status),
},
Self::Cancelled { message, status } => Self::Cancelled {
message: scrub(&message),
status: scrub_status(status),
},
Self::Unavailable { message, status } => Self::Unavailable {
message: scrub(&message),
status: scrub_status(status),
},
Self::Status(status) => Self::Status(scrub_status(status)),
Self::ProtocolStatus {
operation,
code,
message,
} => Self::ProtocolStatus {
operation,
code,
message: scrub(&message),
},
Self::MalformedReply { detail } => Self::MalformedReply {
detail: scrub(&detail),
},
Self::InvalidArgument { name, detail } => Self::InvalidArgument {
name,
detail: scrub(&detail),
},
Self::MxAccess(error) => Self::MxAccess(Box::new(
error.with_secrets(secrets.iter().map(|secret| (*secret).to_owned()).collect()),
)),
other => other,
}
}
}
impl From<tonic::Status> for Error {
fn from(status: tonic::Status) -> Self {
let message = redact_credentials(status.message());
@@ -461,6 +523,14 @@ fn redact_credentials(message: &str) -> String {
#[cfg(test)]
mod tests {
#[test]
fn redact_secrets_scrubs_status_display_and_debug() {
let error: super::Error = tonic::Status::internal("boom pw-secret").into();
let error = error.redact_secrets(&["pw-secret", ""]);
assert!(!format!("{error:?}").contains("pw-secret"));
assert!(!error.to_string().contains("pw-secret"));
}
use tonic::{Code, Status};
use super::{ensure_mxaccess_success, Error};
+6
View File
@@ -14,6 +14,9 @@ pub mod mxaccess_gateway {
/// gateway to language clients.
pub mod v1 {
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
// tonic's generated server traits use async_trait, whose expansion adds
// redundant must_use attributes to Future returns under Rust 1.99.
#![allow(clippy::double_must_use)]
tonic::include_proto!("mxaccess_gateway.v1");
}
@@ -36,6 +39,9 @@ pub mod galaxy_repository {
/// discovery and deploy-event watch RPCs.
pub mod v1 {
#![allow(clippy::large_enum_variant, clippy::result_large_err)]
// tonic's generated server traits use async_trait, whose expansion adds
// redundant must_use attributes to Future returns under Rust 1.99.
#![allow(clippy::double_must_use)]
tonic::include_proto!("galaxy_repository.v1");
}
+33 -2
View File
@@ -229,6 +229,8 @@ async fn acknowledge_alarm_returns_reply_with_native_status() {
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
comment: "investigating".to_owned(),
operator_user: "alice".to_owned(),
operator_password: String::new(),
operator_domain: String::new(),
})
.await
.unwrap();
@@ -1036,6 +1038,30 @@ async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
));
}
#[tokio::test]
async fn acknowledge_alarm_error_does_not_leak_operator_password() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let err = client
.acknowledge_alarm(AcknowledgeAlarmRequest {
client_correlation_id: "corr-pw".to_owned(),
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
comment: String::new(),
operator_user: "alice".to_owned(),
operator_password: "pw-secret".to_owned(),
operator_domain: String::new(),
})
.await
.unwrap_err();
assert!(!format!("{err:?}").contains("pw-secret"));
assert!(!err.to_string().contains("pw-secret"));
}
#[tokio::test]
async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_id() {
// The CLI's `stream-alarms` and `acknowledge-alarm` paths used to
@@ -1073,6 +1099,8 @@ async fn cli_subcommands_propagate_unique_correlation_ids_from_next_correlation_
alarm_full_reference: "Tank01.Level.HiHi".to_owned(),
comment: String::new(),
operator_user: String::new(),
operator_password: String::new(),
operator_domain: String::new(),
})
.await
.unwrap();
@@ -1424,8 +1452,11 @@ impl MxAccessGateway for FakeGateway {
&self,
request: Request<AcknowledgeAlarmRequest>,
) -> Result<Response<AcknowledgeAlarmReply>, Status> {
*self.state.last_correlation_id.lock().await =
Some(request.into_inner().client_correlation_id);
let request = request.into_inner();
if request.operator_password == "pw-secret" {
return Err(Status::internal("boom pw-secret"));
}
*self.state.last_correlation_id.lock().await = Some(request.client_correlation_id);
Ok(Response::new(AcknowledgeAlarmReply {
correlation_id: "corr-1".to_owned(),
protocol_status: Some(ok_status("ack ok")),
+85 -35
View File
@@ -621,8 +621,9 @@ returned to normal but is unacknowledged — i.e., visible in the
"current alarms" list because operator hasn't acked it yet) and
`UNACK_ALM` (the alarm is currently active and unacknowledged).
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
is the method to call.
appear when an ack is performed. The alarm-client ack methods
(`AlarmAckByGUID` and friends) do not produce them headless; an `AckMsg`
write does (`AlarmProbeFindings.md`).
### `GetStatistics` AV — unrelated quirk
@@ -660,7 +661,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
events for added/changed/removed records.
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
fullName)` for client-driven acknowledgements (matches
PR A.5's `AlarmAckCommand` payload).
PR A.5's `AlarmAckCommand` payload). *Ack step superseded
2026-10-19: acks write `AckMsg` as the authenticated operator
(see "Acknowledge in subtag mode" and `AlarmProbeFindings.md`).*
- Lifecycle teardown: `DeregisterConsumer` +
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
3. **Conversion layer:** map XML record fields to
@@ -701,8 +704,9 @@ alarm-consumer surface unblocks A.2 fully. Outline:
`bRetainHiddenAlarms` parameter at all.
- Subscription expression format: `\\<machine>\Galaxy!<area>`
(literal `Galaxy` provider) for both libraries.
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
node, domain, fullName)` on the v2 surface; ID 5-arg
- Native ack surface (not used by the gateway; superseded 2026-10-19 by
the authenticated `AckMsg` write): `AlarmAckByGUID(VBGUID guid,
comment, oprName, node, domain, fullName)` on the v2 surface; ID 5-arg
variant on the legacy `IwwAlarmConsumer` interface.
These findings retire the open follow-up probes from the
@@ -735,25 +739,32 @@ for `SetXmlAlarmQuery`.
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
### 2. Two consumers required: read-side vs. ack-side
### 2. Two consumers required: read-side vs. ack-side (historical)
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
> see `AlarmProbeFindings.md`. The worker no longer provisions an ack-only
> wnwrap consumer. The text below records why the split existed.
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
the same consumer instance**. With SetXml applied, AlarmAckByName
returns -55 even with valid name+provider+group+operator. Without
SetXml, AlarmAckByName succeeds with rc=0.
The production consumer therefore provisions **two** wnwrap COM
The consumer at the time provisioned **two** wnwrap COM
instances:
- Primary consumer (`client`): runs full lifecycle including
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
- Ack-only consumer (`ackClient`): runs Initialize → Register →
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
All `AcknowledgeByName` calls dispatch through this instance.
All by-name ack calls dispatched through this instance.
Both consumers subscribe to the same expression. Disposal cleans up
both via a shared `ReleaseConsumerCom` helper.
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg (historical)
> Superseded 2026-10-19: the gateway no longer calls `AlarmAckByName`; see
> `AlarmProbeFindings.md`.
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
@@ -762,8 +773,8 @@ both via a shared `ReleaseConsumerCom` helper.
The v2 8-arg method returns -55 on this AVEVA build regardless of
operator-identity inputs — looks like a stub. The v1 6-arg method
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
6-arg overload and discards the proto's `domain` + `full_name` fields.
works. `WnWrapAlarmConsumer.AcknowledgeByName` called the
6-arg overload and discarded the proto's `domain` + `full_name` fields.
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
of the paragraph above are narrower than they read. The -55 tracks the
@@ -775,23 +786,58 @@ snapshot stay `UNACK_ALM`, `OPERATOR_NAME` stay empty, and the alarm
extension's own `.Acked` attribute stay `False` for 16 s after each
call. On this rig the wnwrap ack is accepted and then inert, so a zero
return code must not be read as an applied acknowledgement.
The proto contract keeps the 8 fields for forward compatibility if
AVEVA fixes the v2 method later.
### 4. `AlarmAckByGUID` is not implemented
### 4. `AlarmAckByGUID` is not implemented (historical)
> Superseded 2026-10-19: acks now write `AckMsg` as the authenticated operator;
> see `AlarmProbeFindings.md`.
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
build. The reference→GUID lookup that we initially planned to wire
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
must go through `AlarmAckByName`.
through `AlarmAckByGUID` is therefore not viable on wnwrap.
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
in the codebase for the forward-compat shape, but the gateway-side
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
`Provider!Group.Tag` reference.
#### Managed GUID ack experiment — did not work (live-tested 2026-10-18, removed 2026-10-19)
An opt-in experiment drove `aaAlarmManagedClient.AlarmClient` by reflection
(its members are non-public) to take GUID-keyed acks in place of the wnwrap
method. The live test showed it could not work, and the experiment and its
enabling flag were removed once the `AckMsg` write replaced it. The findings
are kept because they explain why the alarm-client route was abandoned.
The test raised `TestMachine_001.TestAlarm001` with a secured write,
read its GUID from the wnwrap XML snapshot (the public API exposes no
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
with the experiment enabled:
| Step | Result |
|---|---|
| RPC ack by GUID, experiment off (control) | `NotImplementedException`; `.Acked` stays `False` |
| RPC ack by GUID, experiment on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
Three separate problems follow:
1. **The two libraries cannot share a process.** The worker always runs
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
The dispatcher is built to drop back to the wnwrap ack path on a
lifecycle failure (so alarm reads keep working), and it does so
silently — which is why the experiment-on RPC was indistinguishable from the
control.
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
`SelectByGUID` is "not found", not a security refusal, even with the
client holding records. Getting the client's own identifier for the
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
FILETIME `ArgumentOutOfRangeException` described above. The
`MxSecurityOperate` question was therefore never reached.
3. **The gateway does not send the identity fields.** Its GUID arm fills
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
strings, so the domain and full name the managed method carries
would not arrive through the public RPC even if 1 and 2 were solved.
The route was dropped in favour of the authenticated `AckMsg` write
(`AlarmProbeFindings.md`, "Fourth attempt").
### 5. STA / threading — resolved
@@ -947,20 +993,24 @@ AVEVA-internal GUID.
### Acknowledge in subtag mode
`AlarmDispatcher` routes ack calls by active provider mode:
Both provider modes acknowledge through the worker's `AlarmAckWriter`, which
calls `AuthenticateUser` as the requesting operator and writes the comment to
the ack item over MXAccess on its own `LMXProxyServer`:
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
write target).
- **Alarm-manager mode:** the item is `<TagName>.AckMsg` for Galaxy-provider
alarms. An alarm from another provider is not ackable.
- **Subtag mode:** `SubtagAlarmConsumer` resolves the watch-list entry's
`ack_comment_subtag` and the writer writes that item verbatim. Writing the
`AckMsg` subtag performs the acknowledge in AVEVA (`AckMsg` is the confirmed
`AlarmExtension` ack-comment write target).
If the alarm has no writable ack-comment subtag (`AckComment` config key is
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
returns a failure code that the gateway surfaces as `FailedPrecondition`.
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
internal dictionary, then calls the same write path.
Because the write is authenticated, the Historian records the operator's name,
domain and full name, and the writer's MXAccess client name
(`MxGateway:Alarms:AckClientName`) as OperatorNode. If the alarm has no ack
target (`AckComment` config key is empty, the entry's `ack_comment_subtag` is
empty, or the reference or synthetic GUID is unknown), the worker reports it
as not found or not ackable and the gateway surfaces `InvalidRequest` with
`hresult` -1.
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
observed ones (active/acked/priority). This is required: MXAccess rejects a
@@ -978,7 +1028,7 @@ The subtag path was validated against live MXAccess on the dev rig
`…​.Priority` → `500` (Int32), `…​.AckMsg` → string — confirming the field
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
with **no** intermediate alarm-condition segment.
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
- The `AckMsg` write returned `0` once the ack-comment subtag
was advised — confirming the ack-by-comment-write mechanism end to end.
### Fidelity limitations
+127 -2
View File
@@ -24,13 +24,15 @@ the blocker rather than rediscovering it.
|---|---|
| GUID stable across polls and `ALM → RTN` | Answered — yes (2026-05-01 capture in `AlarmClientDiscovery.md`, reconfirmed 2026-08-18) |
| GUID stable across clear-then-re-raise | Answered 2026-08-18 — **no**, a re-raise mints a new GUID |
| GUID stable across `UNACK → ACK` | **Open** — the rig cannot be driven into an acknowledged state; 2026-08-18 (third attempt) identifies the reason as the test attribute's `MxSecurityOperate` classification, which no non-interactive ack surface on the rig can satisfy |
| GUID stable across `UNACK → ACK` | Blocker resolved 2026-10-19 — the rig is driven to `ACK_ALM` by an `AckMsg` write, and the `MxSecurityOperate` classification was not the cause (see "Fourth attempt"); GUID survival across that transition has not been recorded yet |
| Acknowledge an alarm headless | Answered 2026-10-19 — **an `AckMsg` write as the authenticated operator**; every alarm-client ack is inert (see "Fourth attempt") |
| `COUNT` = total active vs records-in-reply under a capped fetch | Answered 2026-08-18 — **records in the reply** |
The 2026-08-17 run below is kept because it is the record of the wrong-verb blocker. The
2026-08-18 run cleared that blocker with `AuthenticateUser` + `WriteSecured` and answered
two of the three questions; the acknowledge leg is now blocked on something narrower and
different, described in "Second attempt" and diagnosed in "Third attempt".
different, described in "Second attempt" and diagnosed in "Third attempt"
(resolved in the Fourth attempt, 2026-10-19).
## First attempt (2026-08-17): plain `Write`
@@ -420,3 +422,126 @@ The second attempt's inventory, unchanged since: the three
snapshot, since nothing can acknowledge them away. `SnapshotActiveAlarms` counts only
`UNACK_ALM` and `ACK_ALM` as active, so these are inert for the gateway; they will clear on
the next `alarmmgr` restart.
## Fourth attempt (2026-10-19): authenticated AckMsg write
The third attempt left the acknowledge leg blocked. This run found a route that
acknowledges and identifies the operator, and ruled out the alarm-client routes.
It drove `TestMachine_001.TestAlarm001`, raised with
`mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`.
### Writing `AckMsg` acknowledges
Writing the comment to `TestMachine_001.TestAlarm001.AckMsg` over MXAccess
acknowledges the alarm: `.Acked` becomes `True` and the wnwrap record moves to
`STATE=ACK_ALM` with an `ACK_COMMENT`. `AckMsg` is `MxSecurityFreeAccess` even
though the alarm UDA itself is `MxSecurityOperate`, so no Galaxy change is
needed.
What the Historian records, read from `Runtime.dbo.v_AlarmEventHistory2`:
| Write | Operator | DomainName | UserFullName | OperatorNode | Description |
|---|---|---|---|---|---|
| Unauthenticated `Write` | `NULL` | `NULL` | `NULL` | `NULL` | the comment |
| `AuthenticateUser` + `Write(value, userId)` | `Administrator` | `ArchestrA` | `Administrator` | `mxa` (the MXAccess `Register()` client name) | the comment |
The wnwrap XML also gains `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`,
`ACK_OPERATOR_NODE` and `ACK_COMMENT` on the acknowledged record; the worker's
snapshot parser reads them for Acknowledge transitions. An unauthenticated write
therefore acknowledges but leaves no operator in the Historian, which is why the
gateway requires `operator_user` and authenticates it.
Another actor's `AckMsg` writes on the same day (authenticated as another
operator account) logged that account as `Operator`, `Domain="."` and that machine's name as the node,
which matches the client-name rule. By this date unauthenticated writes to
`MxSecurityOperate` attributes were also accepted Galaxy-wide on the rig, where
August refused them with 1008.
### Alarm-client acks are inert in every variant
All of these returned `rc=0` or `-1` and changed neither the snapshot, `.Acked`
nor the Historian:
- wnwrap `AlarmAckByName` and `AlarmAckByGUID` (`E_NOTIMPL`) on the ack-only and
reader consumers.
- wnwrap `IwwAlarmConsumer2` on a sole consumer with `SetXmlAlarmQuery` applied
(list populated, `GetAlarm(0)` returns the row): `AlarmAck` with ids
0/1/2/3/-1/65536, and `Select(0)` + `AlarmAckSelected`, with operators
`TestOperator`, `Administrator` and a third account. `SelectByGUID` is `E_NOTIMPL`.
- The same calls with `TestAlarm001` temporarily reclassified to
`MxSecurityFreeAccess` (template and instance, restored afterwards), so the
security classification is not the cause.
- Managed `aaAlarmManagedClient`: `AlarmAckByGUID` and `SelectByGUID` return
`-1` (the client's list never fills headless, even with a real `hWnd` and a
message pump); `AckSelectedTagAlarms_Ex` returns `0` even for a
`NOSUCHPROVIDER` provider and writes no Historian row. The managed and wnwrap
consumers cannot share a process (`RegisterConsumer` returns `-65`).
- A 20 s continuous message pump after `AlarmAck`, and registering as `InTouch`,
`WindowViewer` or `Wonderware InTouch`.
Static reverse engineering matched: the five-string COM acks forward domain and
full name with an empty password, and InTouch's `AlarmViewer.ocx` also sends an
empty password. The user-request call is synchronous request-to-return-code with
no completion channel, so the drop happens provider-side. Hooking the user
request in a live WindowViewer that acknowledges successfully and diffing it
against the headless probe is the one definitive step not taken.
### Consequence
The gateway acknowledges by authenticating the operator and writing `AckMsg`
(`docs/DesignDecisions.md`, "Alarm acknowledgement writes AckMsg as the
authenticated operator"). The managed ack consumer and the second wnwrap ack
consumer were removed.
### Gateway-level verification
The same result holds end to end through the gateway. With the feature build deployed on
the windev rig and `TestAlarm001` raised, an `AcknowledgeAlarm` RPC for
`Galaxy!TestArea.TestMachine_001.TestAlarm001` with `operator_user=Administrator` and an
empty password returned `PROTOCOL_STATUS_CODE_OK`, `hresult` 0. `.Acked` turned True,
`.AckMsg` held the comment, and the Historian logged the `ACK_ALM` row with
`Operator=Administrator`, `DomainName=ArchestrA`, `UserFullName=Administrator`,
`OperatorNode=MxGateway` (the default `AckClientName`), and `Description` = the comment.
A wrong password, or a user Galaxy security does not know, makes `AuthenticateUser`
throw `ArgumentException` (`E_INVALIDARG`, 0x80070057) rather than a `COMException`.
That is why the writer treats `ArgumentException` from the MXAccess call itself as an
interop failure alongside `COMException`, and reports the HRESULT the exception carries.
Such an ack returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked,
the Historian gains no ack row, and the password string does not appear in the gateway
log, `stdout.log`, or `stderr.log`. A non-Galaxy provider reference returns
`PROTOCOL_STATUS_CODE_INVALID_REQUEST` with `hresult` -1, and an empty `operator_user`
returns gRPC `InvalidArgument`.
The GUID arm of the ack command was not driven live. The public alarm RPCs expose only
the full reference, so clients reach the by-name arm; worker unit tests cover the GUID
arm.
## Item handle reuse
`AlarmAckWriter` correlates an ack with its `OnWriteComplete` by the
(server handle, item handle) pair, because the callback carries no per-write id.
After an unconfirmed ack it drops the `AckMsg` item and re-adds it so the next
ack waits on a new handle that a late completion for the old one cannot match.
That only works if MXAccess does not hand the same number out again.
`ItemHandleReuseProbeTests` (Skip-gated, worker tests) checked this on windev
(`10.100.0.48`, Galaxy `DEV`) on 2026-10-20 against
`TestMachine_001.TestAlarm001.AckMsg`, without writing:
| Sequence on one registration (server handle 1) | Item handles returned |
|---|---|
| `AddItem` + `Advise`, then `UnAdvise` + `RemoveItem`, five times | 1, 2, 3, 4, 5 |
| `AddItem`, then `RemoveItem`, five times | 6, 7, 8, 9, 10 |
| Add and remove `AckMsg`, add `Acked`, add `AckMsg` again | 11, then 12 (`Acked`), 13 |
| Add `AckMsg` twice while both are held | 14, 15 |
Within a registration the numbers increase and a freed number is never reused,
so the drop-and-re-add mitigation holds. Numbering is per registration:
`Unregister` + `Register` on the same COM object returned server handle 2 and
item handle 1, and a new `LMXProxyServerClass` returned server handle 1 and
item handle 1 again. The writer is not exposed to that restart because its
teardown detaches its `OnWriteComplete` handler from the old COM object and
clears that server handle's completion entries before it creates a new one.
No per-handle generation counter is needed.
+1 -1
View File
@@ -125,7 +125,7 @@ public string ResolveRequiredScope(object request)
}
```
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*` — and `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated. `AcknowledgeAlarm` is treated as a write — it mutates alarm state, mirroring `MxCommandKind.Write*`. It stays `invoke:write` rather than `invoke:secure` because the request carries the operator's own Galaxy credential, which the worker authenticates before the `AckMsg` write; the credential, not the API-key scope, authorizes the identity the Historian records. The trade-off is that an `invoke:write` key can tell `AuthenticationFailed` (an `MXACCESS_FAILURE` whose diagnostic reports operator authentication failure) apart from every other outcome, which makes `AcknowledgeAlarm` a Galaxy-password oracle for that key — whereas the `AuthenticateUser` command itself requires `invoke:secure`. What bounds it: the caller must already hold a valid, admin-issued, revocable `invoke:write` key, and every acknowledgement is serialized through the single monitor worker session's STA, so guesses run one at a time. The gateway does **not** throttle failed operator authentications: the `ApiKeyFailureLimiter` counts only failed API-key verifications, and the ASP.NET Core rate limiter covers only the dashboard `/login` endpoint. Any lockout after repeated bad passwords comes from the Galaxy security configuration, not from the gateway. Grant `invoke:write` only to clients trusted with that exposure. `StreamAlarms` and `QueryActiveAlarms` share the alarm/event surface with `StreamEvents` and `MxCommandKind.DrainEvents`, so they carry `events:read` (the active-alarm snapshot is the same data reachable through the event surface). All three alarm RPCs are session-less: the scope check is the only authorization gate, since there is no per-session ownership to enforce.
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
+10 -7
View File
@@ -140,13 +140,16 @@ publish while the version constants still read the already-published
0.2.0/0.2.1 numbers above — see `clients/python/README.md` for the
user-facing warning. **0.2.0 is superseded by 0.2.1; do not install it.**
As of this remediation (CLI-46), the in-source version constants for the
.NET, Go, Python, and Rust clients (plus Contracts) read **0.2.1**, and the
Java client reads **0.2.2**. These are not yet published — the numbers
above (0.2.0 four clients + Contracts, 0.2.1 Java) remain the live registry
state until the next `pack-clients.ps1` / `tag-go-module.ps1` run. Once that
publish lands, update this section with the new coordinates and commit(s),
the same way the 2026-08-07 record above was written after the fact.
On 2026-08-25 the superseding release shipped from commit
`5776021970cbbe59813ba57579abe00d9fc61a46`. Published coordinates on
`gitea.dohertylan.com` are `nuget` `ZB.MOM.WW.MxGateway.Client` **0.2.1** and
`ZB.MOM.WW.MxGateway.Contracts` **0.2.1**, `pypi`
`zb-mom-ww-mxaccess-gateway-client` **0.2.1**, `cargo`
`zb-mom-ww-mxgateway-client` **0.2.1**, and `maven`
`com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client` **0.2.2**. The Go module
tag `clients/go/v0.2.1` points to that same commit. Every exact package
coordinate returned HTTP 200 through the Gitea package API after publication,
and `go list -m` resolved `v0.2.1` to the tagged commit.
## .NET
+11
View File
@@ -49,6 +49,17 @@ worker prepares the replacement on its STA before swapping it into the existing
failover consumer and returns a normal `MxCommandReply`; callers must treat only
`protocol_status = OK` as acknowledgement of the new list.
`AcknowledgeAlarmRequest` carries the operator's identity: `operator_user`
(required), `operator_password` (field 6) and `operator_domain` (field 7). The
gateway copies them onto `AcknowledgeAlarmCommand` (`operator_password` = 7) and
`AcknowledgeAlarmByNameCommand` (`operator_password` = 9); the worker
authenticates the operator and writes `AckMsg` as that user. The fields are
additive, so `GatewayProtocolVersion` stays `3`. `operator_password` is a
credential and follows the same never-log rule as
`AuthenticateUserCommand.verify_user_password`: it must not reach logs, metric
labels, `DiagnosticMessage`, `ProtocolStatus.Message`, or exception text. A
not-found or not-ackable alarm is reported with `hresult` -1.
The command model also includes bulk write/read command kinds:
`WriteBulk`, `Write2Bulk`, `WriteSecuredBulk`, `WriteSecured2Bulk`, and
`ReadBulk`. They are unary `Invoke` payloads on the same `MxAccessGateway`
+50 -8
View File
@@ -703,14 +703,15 @@ latch in subtag mode.
### Acknowledge via ack-comment write
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
performs the native ack in AVEVA. This differs from alarmmgr mode, where
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
subtag name is empty by default; configuring it is required for ack to work in
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
config block exists precisely so names are not guessed without validation
against the live MXAccess attribute set.
Both provider modes acknowledge by writing the operator comment to `AckMsg` as
the authenticated operator (see "Alarm acknowledgement writes AckMsg as the
authenticated operator" below). In alarm-manager mode the target is
`<TagName>.AckMsg`; in subtag mode it is the watch-list entry's ack-comment
subtag (`Fallback:Subtags:AckComment`, default `AckMsg`). The write performs
the ack in AVEVA. An entry with an empty ack-comment subtag is not ackable and
the RPC reports `InvalidRequest`. The exact AVEVA subtag names are not
hard-coded — the `Subtags` config block exists so names are not guessed
without validation against the live MXAccess attribute set.
### Related documentation
@@ -784,6 +785,47 @@ The following items were previously listed here and have since shipped:
`MxGateway:Sessions:MaxEventSubscribersPerSession`.
See [Event Subscribers](#event-subscribers) above and [Sessions](./Sessions.md).
## Alarm acknowledgement writes AckMsg as the authenticated operator
**Decision (2026-10-19).** `AcknowledgeAlarm` authenticates the requesting
operator with `AuthenticateUser` (`operator_user`, `operator_password`,
optional `operator_domain`) and writes the comment to `<TagName>.AckMsg` over
MXAccess as that user, on a dedicated `LMXProxyServer` registered as
`MxGateway:Alarms:AckClientName`. Both provider modes use this path.
**Why.** Live tests (2026-10-18/19) showed every alarm-client ack is inert on a
headless consumer: wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/
`AlarmAckSelected`, and the managed `aaAlarmManagedClient` GUID ack, return
rc=0 or -1 and change nothing, regardless of security classification,
operator name or consumer name. An MXAccess write to `AckMsg` does acknowledge
the alarm, and when the write is made under an authenticated user the AVEVA
runtime historizes that identity (`Operator`, `DomainName`, `UserFullName`),
with the MXAccess client name as `OperatorNode`. The requirement is that the
real operator appears in the Historian, as with InTouch, so a shared gateway
service account was rejected. Operators have Galaxy accounts, so the
per-request credential is workable. See
[Alarm Probe Findings](./AlarmProbeFindings.md).
**Consequences.**
- `operator_user` is required; `operator_password` may be empty.
- `operator_password` is a credential. It is forwarded to the worker over the
pipe but never logged: `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are
credential-bearing commands and any property named like `password` is
redacted.
- `Applied` and `Unconfirmed` (write sent, no `OnWriteComplete` in the bounded
wait) both report `OK`; clients that need certainty watch for the transition.
- The managed ack consumer, the WNAL native ack consumer, the second wnwrap
ack-only consumer and the opt-in managed-ack environment flag are removed.
**Upgrade note.** This is a breaking change for callers. They must now send
`operator_user` (required; empty is rejected with `InvalidArgument`) and the
operator's Galaxy password; a caller that sends no password for a
password-protected account gets `MXACCESS_FAILURE` from `AuthenticateUser`
instead of an acknowledgement. OtOpcUa's `GalaxyDriver` alarm acknowledger is a
known caller that must be updated to forward the operator's credentials before
the gateway is upgraded.
## Related Documentation
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
+5 -3
View File
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
## Goals
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, `AcknowledgeAlarm` (its `operator_password`), or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them.
@@ -70,7 +70,9 @@ private static readonly HashSet<string> SensitiveCommandMethods = new(StringComp
{
"AuthenticateUser",
"WriteSecured",
"WriteSecured2"
"WriteSecured2",
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
};
public static bool IsCredentialBearingCommand(string? commandMethod)
@@ -80,7 +82,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod)
}
```
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below. `AcknowledgeAlarm` and `AcknowledgeAlarmByName` are in the set because their commands carry the operator's `operator_password`. Independently of the method set, the name-based seam redacts any property whose name contains `password`, so a credential field added to a payload later is redacted without a code change.
### API key redaction
+10 -1
View File
@@ -442,10 +442,19 @@ behavior.
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor re-resolves and, on material change, hot-swaps the subtag fallback watch-list, then reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot. The acknowledged watch-list fingerprint advances only after an OK worker reply. Floored at 5 seconds. |
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be between `100` and `3600000` (one hour): every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path, while a value above an hour stops being a cadence and silently disables alarm polling. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or out-of-range value leaves the worker on the 500 ms default rather than failing the session. |
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be between `64` and `65536` — the worker is a 32-bit process that materializes each reply as one BSTR plus a full `XmlDocument`, so an unbounded cap faults the STA with an out-of-memory rather than merely slowing it. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning to its stderr (identifiers and counts only). **Remediation when you see that warning: raise this value** so the steady-state active-alarm count fits inside one fetch. A galaxy permanently above the cap holds stale entries in the snapshot until a sub-cap poll, and loses clear→re-raise cycles that happen entirely out of window (see `docs/DesignDecisions.md`). Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or out-of-range value leaves the worker on the 1024 default. |
| `MxGateway:Alarms:AckClientName` | `MxGateway` | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via `MXGATEWAY_ALARM_ACK_CLIENT_NAME`. |
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor.
`AckClientName` is the MXAccess client name the alarm worker's ack writer
registers its dedicated `LMXProxyServer` with. The Historian records it as the
OperatorNode of each acknowledgement, so it identifies the gateway as the
acknowledging node. The gateway passes it to each worker through the
`MXGATEWAY_ALARM_ACK_CLIENT_NAME` environment variable. Startup validation
rejects an empty value, a value over 64 characters, or one containing control
characters.
### Alarm fallback options
The `Fallback` sub-section controls how the alarm feed selects between the
@@ -463,7 +472,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback.
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the merged watch-list (case-insensitive). The exclude is applied after the Repository-derived rows and the explicit `IncludeAttributes` entries are combined, so an exclude that matches an explicit include suppresses it too — excludes win. Ignored when `UseGalaxyRepository` is `false`. |
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag, as the authenticated operator, performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the alarm is not ackable and the RPC returns `InvalidRequest`. |
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
Validation rules:
+24 -2
View File
@@ -96,9 +96,30 @@ Selected control commands have additional public behavior beyond their protobuf
### `AcknowledgeAlarm`
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
`AcknowledgeAlarm` is a unary, **session-less** RPC that acknowledges a single alarm. The handler validates `alarm_full_reference` and `operator_user` inline (it does not run through `MxAccessGrpcRequestValidator`) and delegates to `IGatewayAlarmService.AcknowledgeAsync`. The always-on `GatewayAlarmMonitor` routes the ack over its own gateway-managed worker session — clients no longer open a session to acknowledge an alarm. A reference that parses as a canonical GUID forwards to `AcknowledgeAlarmCommand`; a `Provider!Group.Tag` reference forwards to `AcknowledgeAlarmByNameCommand`.
An `OK` response means the alarm provider accepted the acknowledgement, not that it applied it: the gateway forwards the ack and reports what the provider returned, in keeping with the parity rule. On galaxies whose alarmed attributes carry a non-free-access security classification the by-name ack is accepted and inert — the underlying `AlarmAckByName` conveys an operator name rather than an authenticated identity, observed on the probe rig with the mechanism inferred rather than confirmed — so a client that needs to confirm an acknowledgement should watch for the resulting transition on `StreamAlarms` instead of treating `OK` as proof. See [Alarm Probe Findings](./AlarmProbeFindings.md).
Request fields that identify the acknowledging operator:
- `operator_user` (required): the Galaxy account name. An empty value is rejected with `InvalidArgument` so the real operator is always recorded.
- `operator_password` (field 6): the operator's Galaxy password. May be empty because Galaxy accounts can have empty passwords. It is a credential and is never logged.
- `operator_domain` (field 7): optional. When set, the worker authenticates `domain\user`; when empty it authenticates `user`.
The worker calls `AuthenticateUser` as the operator, then writes `operator_comment` to `<TagName>.AckMsg` (subtag mode: the configured ack-comment subtag) on a dedicated MXAccess server registered as `MxGateway:Alarms:AckClientName`, and waits a bounded time for `OnWriteComplete`. The AVEVA runtime treats that write as the acknowledgement, and the Historian (`Runtime.dbo.v_AlarmEventHistory2`) records the operator name, domain and full name from the authenticated account, with the ack client name as `OperatorNode`. The alarm-client ack calls (`AlarmAckByName`, `AlarmAckByGUID`) are not used because a headless consumer's acks are accepted and dropped; see [Alarm Probe Findings](./AlarmProbeFindings.md).
| Worker outcome | Response status | `hresult` |
|---|---|---|
| Write applied, or sent but no `OnWriteComplete` within the wait (unconfirmed, with a diagnostic) | `OK` | `0` |
| Unknown alarm, or no ack target (non-Galaxy provider, no ack-comment subtag) | `INVALID_REQUEST` | `-1` |
| `AuthenticateUser` raised an MXAccess error (bad user or wrong password surfaces as `E_INVALIDARG` `0x80070057`, raised by the interop as `ArgumentException`; `COMException` and `UnauthorizedAccessException` are classified the same way) | `MXACCESS_FAILURE` | the exception's HRESULT |
| `AuthenticateUser` returned user id `0` | `MXACCESS_FAILURE` | `-1` |
| `AuthenticateUser` failed with a server-gone HRESULT (`RPC_E_DISCONNECTED`, `RPC_S_SERVER_UNAVAILABLE`, `RPC_E_SERVERFAULT`); the writer tears down its MXAccess registration and re-registers on the next ack | `MXACCESS_FAILURE` | that HRESULT |
| `AddItem`, `Advise` or `Write` on the `AckMsg` item raised an MXAccess error (`COMException`, or `ArgumentException` for `E_INVALIDARG`); the item is dropped and re-added on the next ack, and a server-gone HRESULT also tears down the registration | `MXACCESS_FAILURE` | the exception's HRESULT |
| Write rejected (`OnWriteComplete` non-OK) | `MXACCESS_FAILURE` | the `detail` of the `OnWriteComplete` `MXSTATUS_PROXY`, or `-1` when `detail` is `0` |
| Any other exception escaping the ack (e.g. the ack writer's MXAccess `Register` failing, the monitor's worker session having no active alarm subscription — `InvalidOperationException` — or an exception from the worker's own code rather than from an MXAccess call: interop failures are classified only when the `AuthenticateUser`, `AddItem`, `Advise` or `Write` call itself raises them, and an `ArgumentNullException` or `ArgumentOutOfRangeException` is never classified because the interop does not raise them for an HRESULT) | `MXACCESS_FAILURE` | the exception's `HResult`; the diagnostic names only the exception type and HRESULT |
`reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when they were observed. Because an unconfirmed write also returns `OK`, a client that must be certain should watch for the resulting transition on `StreamAlarms`. `AcknowledgeAlarm` stays under the `invoke:write` scope; see [Authorization](./Authorization.md).
**Upgrade note.** Callers must now send `operator_user` (required) and the operator's Galaxy password in `operator_password`. A caller that sends no `operator_user` gets `InvalidArgument`; a caller that sends no password for a password-protected Galaxy account gets `MXACCESS_FAILURE` from `AuthenticateUser` instead of an acknowledgement. Every existing caller must be updated before the gateway is upgraded — known callers include OtOpcUa's `GalaxyDriver` alarm acknowledger, which must supply the operator's credentials.
### `StreamAlarms`
@@ -217,6 +238,7 @@ receive this event directly.
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
| `AcknowledgeAlarm` | `operator_user` must be non-empty. Validated inline in the handler. | `InvalidArgument` |
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
+17
View File
@@ -351,6 +351,23 @@ native client correlating by handle has the identical ambiguity). The exposure
is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus`
stay acceptance-only regardless of which completion (if any) was attributed.
**The alarm ack writer narrows the window for acknowledgements.**
`AlarmAckWriter` registers its own MXAccess server with its own
`MxAccessWriteCompletionCache` and reuses one advised `AckMsg` item per alarm
(least-recently-used, capped at 256). After an ack ends unconfirmed it marks the
item dirty, and the next ack to that alarm removes and re-adds the item so the
write waits on a fresh item handle the stale completion cannot match. This
relies on MXAccess never reusing an item handle number within one
registration, which a live probe confirmed (see "Item handle reuse" in
[Alarm Probe Findings](./AlarmProbeFindings.md)). Every
drop of an item (dirty re-add, eviction, or a failed `AddItem`/`Advise`/`Write`)
also removes that handle's completion entry, and tearing down the registration
removes every entry for its server handle, so the cache does not grow by one
entry per dropped handle for the life of the worker. The writer records a
completion only for an item handle it currently holds on its live
registration, so a late `OnWriteComplete` for a dropped handle cannot recreate
its entry.
## Live alarm watch-list replacement
An auto-mode `SubscribeAlarms` with `fallback_enabled = true` always builds a
@@ -394,12 +394,12 @@ Query every exact registry coordinate for HTTP 200 and resolve the Go module/tag
## Final Tasklist Exit Gate
- [ ] All 16 open P2 findings are Done with commit/test evidence.
- [ ] WRK-34 is corrected to Done (`3679e59`).
- [ ] Live watch-list refresh passes portable and windev tests.
- [ ] WRK-32 public proto comment is regenerated through every client surface.
- [ ] TST-35 nightly Windows gateway suite is proven on windev.
- [ ] Full NonWindows gateway suite and all five client suites pass.
- [ ] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
- [ ] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
- [ ] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
- [x] All 16 open P2 findings are Done with commit/test evidence.
- [x] WRK-34 is corrected to Done (`3679e59`).
- [x] Live watch-list refresh passes portable and windev tests.
- [x] WRK-32 public proto comment is regenerated through every client surface.
- [x] TST-35 nightly Windows gateway suite is proven on windev.
- [x] Full NonWindows gateway suite and all five client suites pass.
- [x] Worker.Tests and Windows gateway suite pass at the integrated release SHA.
- [x] Five client releases plus Contracts are confirmed in registries; Go tag resolves.
- [x] `archreview/2026-08-19/remediation/00-tracking.md` contains no stale Deferred row for completed work.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+22 -18
View File
@@ -140,8 +140,9 @@ one gateway-owned worker session subscribes the configured AVEVA alarm
provider, caches the active-alarm set (reconciled periodically against the
worker's snapshot), and fans it out to every client through the session-less
`StreamAlarms` RPC — the stream opens with the current active-alarm snapshot,
then streams live transitions. `AcknowledgeAlarm` is session-less and routes
through the monitor. Clients never open a worker session to see alarms, and
then streams live transitions. `AcknowledgeAlarm` is session-less, routes
through the monitor, and carries the operator's credentials (see
"Acknowledge" below). Clients never open a worker session to see alarms, and
alarm monitoring is independent of client lifecycle; the monitor re-opens its
session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
@@ -221,20 +222,22 @@ forwards only events the worker emits and never synthesizes transitions
itself. The synthesis rules are documented in
`docs/AlarmClientDiscovery.md`.
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
comment to the alarm attribute's ack-comment subtag. The write performs the
ack. If the attribute has no writable ack-comment subtag configured, the RPC
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
used as before.
**Acceptance is not application:** the ack is forwarded to the provider, and a
successful return means the provider accepted the call, not that the
acknowledgement was applied. On galaxies whose alarmed attributes carry a
non-free-access security classification, `AlarmAckByName` returns `rc=0` and the
alarm stays `UNACK_ALM` — it carries an operator name, not an authenticated
identity. (Observed on the probe rig; the mechanism is inferred.) Confirm an ack
by the resulting transition, never by the return code. See
`docs/AlarmProbeFindings.md`.
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
operator (`operator_user`, `operator_password`, optional `operator_domain`).
The AVEVA runtime treats that write as the acknowledgement and historizes it
with the authenticated account's name, domain and full name; the Historian's
OperatorNode is the ack writer's MXAccess client name
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
`MxaccessFailure` with the status detail, and no completion as success with a
diagnostic noting the write was sent but unconfirmed. An unknown alarm or one
with no ack target (non-Galaxy provider, no ack-comment subtag) is reported as
`InvalidRequest` with `hresult` -1. Alarm-client ack calls
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
used: live tests showed a headless consumer's acks are accepted and silently
dropped (see `docs/AlarmProbeFindings.md`).
**Degraded state visibility:** every subtag-mode transition carries
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
@@ -1028,8 +1031,9 @@ Internal worker IPC:
- gateway validates worker executable path and version,
- no secrets in command line when avoidable.
Credential-sensitive commands such as `AuthenticateUser` and `WriteSecured`
must not log passwords or raw credential values.
Credential-sensitive commands such as `AuthenticateUser`, `WriteSecured` and
`AcknowledgeAlarm` (its `operator_password`) must not log passwords or raw
credential values.
## Observability
@@ -25,7 +25,7 @@ public static class GatewayContractInfo
/// (<c>WorkerPipeSession.CreateDrainEventsReply</c>, the backstop that also interprets
/// <c>max_events = 0</c>) cannot drift apart. A count cap alone is necessary but not
/// sufficient: the worker additionally caps the reply by serialized bytes against the
/// negotiated frame maximum (WRK-21), so a reply may carry fewer events than this ceiling
/// negotiated frame maximum, so a reply may carry fewer events than this ceiling
/// and fewer than are queued. Callers drain iteratively until an empty reply.
/// This is a documented behavioral bound, not wire schema — it is deliberately a C#
/// constant and not a <c>.proto</c> field.
File diff suppressed because it is too large Load Diff
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6;
string operator_domain = 7;
string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
}
message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// AckMsg write was applied or sent but unconfirmed; otherwise the
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// this payload exists so GatewayAlarmMonitor
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5;
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
}
message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
optional int32 hresult = 4;
@@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment
/// <summary>Maximum alarm records requested in one current-alarm fetch.</summary>
public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <summary>
/// MXAccess client name the worker's alarm-ack writer registers with. The
/// Historian records it as the acknowledgement's OperatorNode.
/// </summary>
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
}
@@ -27,7 +27,7 @@ public sealed class DashboardLdapLiveTests
private const string SharedDirectoryPassword = "password";
/// <summary>
/// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen
/// Dashboard visibility tags used by the ACL scenarios below. They are operator-chosen
/// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
/// list — nothing in the shared directory carries them.
/// </summary>
@@ -168,7 +168,7 @@ public sealed class DashboardLdapLiveTests
}
/// <summary>
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
/// Verifies the tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
/// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
/// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
/// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
@@ -275,7 +275,7 @@ public sealed class DashboardLdapLiveTests
};
/// <summary>
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
/// The tagged fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
/// name groups that already exist in the shared directory — the tag layer is config-only.
/// </summary>
private static DashboardOptions TaggedDashboardOptions() => new()
@@ -526,7 +526,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
// Assert. But a silent fall-through to "operator only" (currentUserId 0)
// quietly demotes the rest of this test from an authenticated round-trip
// to a bare failure-reply-shape check, which a stale rig credential (e.g.
// TST-31) would otherwise hide indefinitely in nightly runs. Log the
// one that has expired) would otherwise hide indefinitely in nightly runs. Log the
// outcome loudly — never the password (verifyUser only) — so that
// degradation is visible in the test output even though it never fails
// the test by itself.
@@ -22,8 +22,8 @@
(IntegrationTests-028).
-->
<ItemGroup>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
</ItemGroup>
@@ -19,7 +19,7 @@ public static class AlarmProviderReasons
/// <summary>
/// Reason prefix set when the effective mode can need the subtag watch-list
/// (<c>Auto</c> or <c>ForceSubtag</c>) but the list resolved to zero targets, so the
/// subtag provider covers no alarms (GWC-37). In <c>Auto</c> this is a warning about the
/// subtag provider covers no alarms. In <c>Auto</c> this is a warning about the
/// standby, not about the currently-serving alarm manager, which is why the dashboard
/// renders it against the <c>ALARMMGR</c> mode as a coverage badge rather than a failover.
/// </summary>
@@ -29,7 +29,7 @@ public static class AlarmProviderReasons
/// <summary>
/// Reason prefix set when Galaxy Repository alarm discovery was enabled but failed, so
/// the watch-list carries only what configuration contributed and may be incomplete
/// (GWC-37). Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
/// . Distinct from <see cref="SubtagWatchListEmpty"/>: discovery failing with a
/// usable config-only list is a completeness warning, not a total loss of coverage.
/// </summary>
public const string SubtagDiscoveryFailed =
@@ -37,7 +37,7 @@ public static class AlarmProviderReasons
/// <summary>
/// Reason prefix set when a re-resolution at the failover edge produced a different
/// watch-list than the one sent at <c>SubscribeAlarms</c> (GWC-37). The worker keeps the
/// watch-list than the one sent at <c>SubscribeAlarms</c>. The worker keeps the
/// list it was given for the life of the subscription, so the difference is reported
/// rather than applied; a monitor restart re-resolves and re-sends.
/// </summary>
@@ -5,8 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms;
/// <summary>
/// The outcome of one subtag watch-list resolution: the composed targets plus enough
/// provenance for the caller to tell an empty list that means "nothing was configured"
/// from one that means "discovery was configured and silently produced nothing"
/// (GWC-37). <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
/// from one that means "discovery was configured and silently produced nothing".
/// <see cref="IAlarmWatchListResolver.ResolveAsync"/> deliberately never throws
/// on a discovery failure, so without this the two are indistinguishable at the call site
/// and a Galaxy Repository outage at startup can only be found in a log line.
/// </summary>
@@ -39,7 +39,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
// GWC-37: the caller cannot grade an empty watch-list without knowing whether discovery
// The caller cannot grade an empty watch-list without knowing whether discovery
// was asked to contribute and whether it failed while doing so, because the failure is
// deliberately swallowed below.
string? discoveryFailureMessage = null;
@@ -41,7 +41,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// every path that writes _alarms must null this under _sync, or readers keep a stale set.
private ActiveAlarmSnapshot[]? _currentAlarmsProjection;
// NEXT-03 dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
// Dedup tombstones, guarded by _sync: alarm instances whose Clear was synthesized by
// the most recent reconcile pass, keyed by reference with the instance's original raise
// timestamp as the identity marker. A buffered live Clear for the same instance is a duplicate
// of the repair and is suppressed. One generation deep: each reconcile pass replaces the map,
@@ -57,7 +57,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
// GWC-37 subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
// Subtag-coverage verdict, guarded by _sync: whether the watch-list the worker is
// using can actually cover anything, and why not. Independent of _providerDegraded (which
// means "the subtag provider is the one serving"): in Auto mode the alarm manager can be
// serving perfectly while the standby behind it covers zero alarms, and that is invisible
@@ -258,7 +258,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
try
{
// Attach the internal distributor subscriber BEFORE subscribing (GWC-26). The pump
// Attach the internal distributor subscriber BEFORE subscribing. The pump
// has been running since MarkReady started the dashboard mirror, and the distributor
// only fans to subscribers registered at the time of the fan-out, so a subscriber
// taken after SubscribeAlarms + the first reconcile would silently lose every
@@ -351,7 +351,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
// GWC-37: grade the coverage before the command goes out, so a Galaxy Repository outage at
// Grade the coverage before the command goes out, so a Galaxy Repository outage at
// startup is a standing status on the feed instead of one log line an operator reads weeks
// later — when the failover that needed the watch-list finds it empty. Broadcast because
// feed subscribers outlive the monitor's worker session and may already be attached.
@@ -507,7 +507,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
}
}
// NEXT-03: best-effort dedup of the reconcile/live race. A reconcile that already synthesized
// Best-effort dedup of the reconcile/live race. A reconcile that already synthesized
// this transition as a feed repair left the cache carrying the worker's transition timestamp
// and resulting state — both derived from the same worker-side value the live transition
// carries — so an exact (timestamp, state) match means this live transition's outcome has
@@ -521,7 +521,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
&& existing.CurrentState == incoming.CurrentState;
}
// NEXT-03, the Clear leg. A reconcile Clear repair removes the cache entry before the buffered
// The Clear leg of the dedup. A reconcile Clear repair removes the cache entry before the buffered
// live Clear drains, so there is no cached state to compare against; the tombstone recorded by
// ApplyReconcile identifies the cleared instance by its original raise timestamp instead. The
// match consumes the tombstone, so a genuinely new raise/clear cycle (which carries a newer
@@ -803,7 +803,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
//
// GWC-37: degraded is the OR of "the subtag provider is the one serving" and "the subtag
// Degraded is the OR of "the subtag provider is the one serving" and "the subtag
// watch-list cannot cover anything". The second is what makes an empty watch-list visible in
// Auto mode, where the alarm manager is serving happily and the standby behind it is empty.
// The reasons are concatenated when both apply, which is the worst case worth naming loudly:
@@ -857,7 +857,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// Replaces the cache with the worker's authoritative snapshot, broadcasting
// a synthetic transition for any alarm the live stream missed.
//
// Repair scope (GWC-26): presence deltas (Clear/Raise) plus acked-state deltas. These are
// Repair scope: presence deltas (Clear/Raise) plus acked-state deltas. These are
// ALARM FEED transitions (AlarmFeedMessage on the StreamAlarms/dashboard surface), rebuilt
// from the worker's own authoritative snapshot to repair what the live feed missed. They are
// not MxEvents and never reach the gRPC StreamEvents path, so this feed-level repair does not
@@ -867,7 +867,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// reads the worker's current state while the corresponding live transition may still be
// buffered in the alarm lease's channel; both would then broadcast, and the two are
// indistinguishable on the feed, since nothing serializes a reconcile against the in-flight
// live stream. ApplyTransition narrows that window with a best-effort dedup (NEXT-03): a live
// live stream. ApplyTransition narrows that window with a best-effort dedup: a live
// transition whose worker timestamp and resulting state the cache already carries — or whose
// Clear matches a tombstone recorded below — was already broadcast as a repair and is
// suppressed. The dedup fires only on a positive marker match, so the contract stays
@@ -1169,7 +1169,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
return string.Empty;
}
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
internal static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
{
parseError = null;
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
@@ -1180,6 +1180,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
string comment = request.Comment ?? string.Empty;
string operatorUser = request.OperatorUser ?? string.Empty;
string operatorPassword = request.OperatorPassword ?? string.Empty;
string operatorDomain = request.OperatorDomain ?? string.Empty;
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
{
@@ -1192,7 +1194,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment,
OperatorUser = operatorUser,
OperatorNode = string.Empty,
OperatorDomain = string.Empty,
OperatorDomain = operatorDomain,
OperatorPassword = operatorPassword,
OperatorFullName = string.Empty,
},
};
@@ -1211,7 +1214,8 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
Comment = comment,
OperatorUser = operatorUser,
OperatorNode = string.Empty,
OperatorDomain = string.Empty,
OperatorDomain = operatorDomain,
OperatorPassword = operatorPassword,
OperatorFullName = string.Empty,
},
};
@@ -73,6 +73,13 @@ public sealed class AlarmsOptions
/// </summary>
public int MaxAlarmsPerFetch { get; init; } = 1024;
/// <summary>
/// MXAccess client name the alarm worker's ack writer registers with. The
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
/// </summary>
public string AckClientName { get; init; } = "MxGateway";
/// <summary>
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
/// operating mode, failure-detection thresholds, discovery, and subtag
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <c>ZB.MOM.WW.GalaxyRepository</c> package binds the options but deliberately ships no validator
/// (see <c>A2-galaxyrepository-adoption-handoff.md</c>); the gateway owns the rule because it is the
/// process that writes the snapshot. When persistence is on, the snapshot path must be a valid,
/// rooted path on the running host for the same reason the auth DB path must be (SEC-33): a
/// rooted path on the running host for the same reason the auth DB path must be: a
/// non-rooted value silently resolves against the launch working directory.
/// </summary>
public sealed class GalaxyRepositoryOptionsValidator : OptionsValidatorBase<GalaxyRepositoryOptions>
@@ -18,7 +18,7 @@ internal static class GatewayConfigPathRules
/// against the launch working directory, so the store moves with the CWD and can leak into the
/// source tree. Rooting is checked with <see cref="Path.IsPathRooted(string)"/> — the current
/// OS — so a Windows drive/UNC literal on a Unix host fails fast at startup rather than being
/// blessed and then written as a junk-named relative file (the SEC-01/SEC-33 mechanism). Reject
/// blessed and then written as a junk-named relative file. Reject
/// rather than auto-root; silent relocation of a credential store is worse than a boot error.
/// Blank is handled by the caller's required-field check and is not treated as non-rooted here.
/// </summary>
@@ -113,7 +113,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
"MxGateway:Security:AuditRetentionDays must be greater than zero (at least one day of audit history is retained).",
builder);
// The two-layer limiter knobs (SEC-31) accept 0 as "disable this layer": a zero aggregate
// The two-layer limiter knobs accept 0 as "disable this layer": a zero aggregate
// limit turns off cross-peer counting, and a zero probe interval restores absolute blocking.
// Negatives express no intent.
AddIfNegative(
@@ -341,7 +341,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
}
// QueueCapacity flows into WorkerClient as EventChannelCapacity, where the staging channel is
// sized checked(2 * EventChannelCapacity) (GWC-24). Cap it at int.MaxValue/2 so that doubling
// sized checked(2 * EventChannelCapacity). Cap it at int.MaxValue/2 so that doubling
// cannot overflow and throw OverflowException at session creation; mirrors the MaxSparseArrayLength
// upper-bound pattern.
private const int MaximumEventQueueCapacity = int.MaxValue / 2;
@@ -488,6 +488,13 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
$"MxGateway:Alarms:MaxAlarmsPerFetch must be between {MinimumMaxAlarmsPerFetch} and {MaximumMaxAlarmsPerFetch}.");
}
if (string.IsNullOrWhiteSpace(options.AckClientName)
|| options.AckClientName.Length > 64
|| options.AckClientName.Any(char.IsControl))
{
builder.Add("MxGateway:Alarms:AckClientName must be 1-64 printable characters.");
}
if (!options.Enabled)
{
return;
@@ -607,7 +614,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
// message is not doubled up with the individual range errors.
//
// IPC-33: the coupling also has an upper half. The worker budgets a DrainEvents reply against the
// The coupling also has an upper half. The worker budgets a DrainEvents reply against the
// negotiated frame maximum less two reserves, and that same MxCommandReply is then returned
// verbatim through Invoke, where the gateway's gRPC MaxSendMessageSize (= MaxGrpcMessageBytes)
// applies. Letting the pipe maximum run arbitrarily far above the gRPC cap lets the worker pack a
@@ -671,7 +678,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// Path rooting/validity rules are shared with GalaxyRepositoryOptionsValidator (both write a
// host file) so the two validators cannot drift; see GatewayConfigPathRules. Rooting is checked
// against the running OS via Path.IsPathRooted — a Windows drive/UNC literal on a Unix host now
// fails fast instead of being blessed and written as a junk-named relative file (SEC-33).
// fails fast instead of being blessed and written as a junk-named relative file.
private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
=> GatewayConfigPathRules.AddIfNotRooted(value, message, builder);
@@ -61,7 +61,7 @@ public sealed class WorkerOptions
/// still fits one worker frame, and at most <em>twice</em> that reserve above it so a
/// <c>DrainEvents</c> reply budgeted against this maximum (frame max less two reserves) still
/// fits the gRPC send cap it is returned through — otherwise the worker can pack a reply the
/// pipe carries and the gRPC layer refuses, losing the already-drained events (IPC-33). The
/// pipe carries and the gRPC layer refuses, losing the already-drained events. The
/// gateway conveys this value to the worker in the handshake
/// (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap plus one reserve.
/// </summary>
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: succeeds only when the caller
/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which
/// is the point — there is no configuration value that can turn this into a bypass (SEC-40).
/// is the point — there is no configuration value that can turn this into a bypass.
/// </summary>
public sealed class AuthenticatedDashboardRoleHandler
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
@@ -13,9 +13,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// into this one by accident.
/// </summary>
/// <remarks>
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
/// Introduced to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> page),
/// which is gateway-central and session-less and therefore carries no per-session provenance the
/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
/// tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that
/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path),
/// state, severity, category, area, acknowledging operator — away from the read-only environment
/// bypasses, which exist for local development and unauthenticated deployments rather than as a
@@ -32,7 +32,7 @@ public sealed record DashboardAlarmProviderStatus(
/// <summary>
/// Badge label shown when the alarm manager is serving normally but the subtag standby
/// behind it covers nothing — an empty or undiscoverable watch-list (GWC-37). The feed is
/// behind it covers nothing — an empty or undiscoverable watch-list. The feed is
/// healthy today; what is degraded is the fallback that would take over tomorrow, so the
/// badge names the standby rather than claiming the feed has failed over.
/// </summary>
@@ -77,7 +77,7 @@ public sealed record DashboardAlarmProviderStatus(
&& status.Mode == AlarmProviderMode.Subtag
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
// GWC-37: degraded WITHOUT subtag mode means the alarm manager is still serving and the
// Degraded WITHOUT subtag mode means the alarm manager is still serving and the
// standby is the unsound half. "Subtag monitoring (degraded)" would misreport which
// provider is live, so this state gets its own label; the amber badge is shared because
// the operator action — fix discovery — is equally overdue in both.
@@ -35,7 +35,7 @@ public static class DashboardAuthenticationDefaults
/// <summary>
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
/// <c>/alarms</c> page that reads the same central monitor in process. Accepts the
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
@@ -49,10 +49,10 @@ public static class DashboardAuthenticationDefaults
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary>
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
/// Claim carrying one dashboard event-visibility tag the caller is granted. Stamped
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
/// <c>MxGateway:Dashboard:GroupToTag</c>. <see cref="IDashboardSessionAcl"/> reads
/// this claim only as a FALLBACK, for a principal that carries no
/// <see cref="LdapGroupClaimType"/> claims to re-resolve from (the hub-token principal, whose
/// payload carries tags but not groups); a cookie principal is re-resolved against current
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
/// </param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
@@ -142,7 +142,7 @@ public sealed class DashboardAuthenticator(
claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
// Dashboard event-visibility tags. Visibility only — never a data-access grant —
// and never logged: only the decision, never the tag values, reaches diagnostics.
claims.AddRange(DashboardGroupTagMapping
.MapGroupsToTags(groupNames, groupToTag)
@@ -107,7 +107,7 @@ public static class DashboardEndpointRouteBuilderExtensions
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is
// real authenticated dashboard role — see AlarmsHub for why the alarm feed is
// gated harder than the session-scoped ones.
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
@@ -177,7 +177,7 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.AuthenticationScheme,
DashboardAuthenticationDefaults.HubAuthenticationScheme)
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
// SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
// Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader
// and an external hub reader), but a requirement type DashboardAuthorizationHandler
// does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost
// can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role.
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call
/// <strong>Where the grant comes from.</strong> The grant is re-resolved on every call
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
@@ -120,7 +120,7 @@ public sealed class DashboardSessionAcl(
if (groups is not null)
{
// SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an
// Current options, not the login-time snapshot. MapGroupsToTags returns an
// OrdinalIgnoreCase set, so probing it with the session's tags carries the
// case-insensitive comparison in the same direction the claim scan below does.
IReadOnlySet<string> granted = DashboardGroupTagMapping.MapGroupsToTags(
@@ -39,7 +39,7 @@ public sealed class HubTokenService
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
// bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
// the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
// the same way: the token carries the tags resolved from the caller's LDAP groups at
// mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
// connections within one lifetime — the natural place the deferred "tokens gain session
// binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
@@ -11,9 +11,9 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// </summary>
/// <remarks>
/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no
/// session provenance, so the SEC-25 per-session tag ACL that scopes <c>EventsHub</c> has nothing
/// session provenance, so the per-session tag ACL that scopes <c>EventsHub</c> has nothing
/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole
/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate:
/// subscription expression's alarm metadata. What is tightened instead is the entry gate:
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
/// those audiences is subject to this dashboard-display flag.
/// <para>
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
/// The broadcast stays deliberately unfiltered. Audience is decided once, at hub
/// admission — <see cref="AlarmsHub"/> carries
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — and the alarm
@@ -39,8 +39,8 @@ public sealed class EventsHub(
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
/// only checks that the caller carries one of the dashboard roles, which by
/// itself would let any Viewer subscribe to any session id they name. The
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
/// (SEC-25 / TST-15). The admin bypass is evaluated first, so an
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s.
/// The admin bypass is evaluated first, so an
/// Administrator joins any session id they name; every check below it
/// applies to non-Admin callers only. For those: a Viewer sees a session
/// only when its tags intersect their granted tags, an untagged session
@@ -95,16 +95,12 @@ public sealed class EventsHub(
viewerRegistry.RemoveViewer(Context.ConnectionId, sessionId);
}
/// <summary>
/// Releases every session subscription the dropped connection held. A browser
/// tab that closes never calls <see cref="UnsubscribeSession"/>, so without
/// this the session would look watched forever and the mirror would keep
/// cloning and sending events to an empty group.
/// </summary>
/// <param name="exception">The exception that terminated the connection, if any.</param>
/// <returns>A task representing the disconnect handling.</returns>
/// <inheritdoc />
public override Task OnDisconnectedAsync(Exception? exception)
{
// Release every session subscription the dropped connection held. A browser tab that
// closes never calls UnsubscribeSession, so without this the session would look watched
// forever and the mirror would keep cloning and sending events to an empty group.
viewerRegistry.ReleaseConnection(Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Decides whether a dashboard principal may observe one session's mirrored
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
/// event stream. Consulted at every subscribe seam: the
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
/// session-details page.
@@ -12,7 +12,10 @@ public static class GatewayLogRedactor
{
"AuthenticateUser",
"WriteSecured",
"WriteSecured2"
"WriteSecured2",
// Carry operator_password.
"AcknowledgeAlarm",
"AcknowledgeAlarmByName"
};
/// <summary>
@@ -33,6 +33,14 @@ public sealed class GatewayLogRedactorSeam : ILogRedactor
}
RedactCommandValue(properties);
foreach (string key in properties.Keys.ToList())
{
if (key.Contains("password", StringComparison.OrdinalIgnoreCase) && properties[key] is not null)
{
properties[key] = GatewayLogRedactor.RedactedValue;
}
}
}
/// <summary>
@@ -154,8 +154,8 @@ public static class GatewayApplication
// having pushed the ambient identity before the library's galaxy service runs. That
// ordering is still required, but it is no longer trusted silently: with
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
// invariant when the identity is absent, instead of resolving to the full hierarchy
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
// invariant when the identity is absent, instead of resolving to the full hierarchy.
// Under Mode=Disabled identity-absent is expected and stays unscoped.
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
Security.Authorization.GatewayBrowseScopeProvider>();
@@ -164,7 +164,7 @@ public static class GatewayApplication
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
// byte-identical to the removed appsettings literal on Windows (SEC-33).
// byte-identical to the removed appsettings literal on Windows.
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
{
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
@@ -175,7 +175,7 @@ public static class GatewayApplication
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
// Validate that persistence has a valid, host-rooted snapshot path (SEC-33).
// Validate that persistence has a valid, host-rooted snapshot path.
builder.Services.AddSingleton<
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
Configuration.GalaxyRepositoryOptionsValidator>();
@@ -224,7 +224,7 @@ public static class GatewayApplication
/// </para>
/// <para>
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
/// together, and the mechanism is the one SEC-33 already used for
/// together, and the mechanism is the same mechanism already used for
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
@@ -116,7 +116,7 @@ public sealed class EventStreamService(
// its `yield return` is what feeds the gRPC writer.
ChannelReader<MxEvent> reader = subscriber.Reader;
// GWC-15: register this subscriber's channel as a live backlog source instead of
// Register this subscriber's channel as a live backlog source instead of
// reconciling the queue-depth gauge on every event. The gauge previously read the
// bounded channel's Count (which takes the channel's internal lock) and adjusted the
// metric under its own lock on every streamed event. Now the metric reads Count only
@@ -197,7 +197,7 @@ public sealed class EventStreamService(
}
// The queue-depth gauge is maintained lazily via the backlog registration
// above (GWC-15): the metric reads this subscriber's channel Count only when
// above: the metric reads this subscriber's channel Count only when
// scraped, so there is no per-event gauge bookkeeping on this hot path.
yield return mxEvent;
}
@@ -35,7 +35,7 @@ public sealed class MxAccessGatewayService(
// The session's owner id and its dashboard-visibility tags both come from the resolved
// API key identity, never from the request: the key is the tenant principal, so a
// client cannot label its own session with another tenant's tag (SEC-25).
// client cannot label its own session with another tenant's tag.
ApiKeyIdentity? owner = identityAccessor.Current;
GatewaySession session = await sessionManager
.OpenSessionAsync(
@@ -130,7 +130,7 @@ public sealed class MxAccessGatewayService(
// Map from the command alone: cloning the whole request only to overwrite its command with
// commandToInvoke deep-cloned the (potentially large) original payload for nothing, since
// MapCommand reads nothing but the command (GWC-29). The one clone that matters still
// MapCommand reads nothing but the command. The one clone that matters still
// happens inside MapCommand, which is what keeps the worker-bound graph unaliased from
// commandToInvoke — the caller still reads it below via TrackCommandReply.
WorkerCommand workerCommand = mapper.MapCommand(commandToInvoke);
@@ -167,7 +167,7 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
// GWC-06: measure send latency with the allocation-free timestamp API rather
// Measure send latency with the allocation-free timestamp API rather
// than allocating a Stopwatch object per event per subscriber on the highest-
// volume gateway path. Stopwatch.GetTimestamp/GetElapsedTime measure the exact
// same wall-clock span the former Stopwatch.StartNew()/.Elapsed did.
@@ -185,15 +185,6 @@ public sealed class MxAccessGatewayService(
}
/// <inheritdoc />
/// <remarks>
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
/// session-less: the gateway routes it through the always-on
/// <see cref="IGatewayAlarmService"/> monitor session. An
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
/// </remarks>
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
AcknowledgeAlarmRequest request,
ServerCallContext context)
@@ -206,6 +197,11 @@ public sealed class MxAccessGatewayService(
throw new RpcException(new Status(StatusCode.InvalidArgument, "alarm_full_reference is required."));
}
if (string.IsNullOrWhiteSpace(request.OperatorUser))
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
}
return await alarmService.AcknowledgeAsync(request, context.CancellationToken)
.ConfigureAwait(false);
}
@@ -35,7 +35,7 @@ public sealed class MxAccessGrpcMapper
/// <summary>
/// Maps a gRPC MX command to a worker command. Callers that already hold the command — including
/// the constraint pipeline, whose rewritten command is not the one on the request — use this
/// overload rather than cloning a whole request to carry a single field (GWC-29); nothing outside
/// overload rather than cloning a whole request to carry a single field; nothing outside
/// the command is read here.
/// </summary>
/// <param name="command">Command payload.</param>
@@ -71,7 +71,7 @@ public sealed class MxAccessGrpcMapper
};
}
// GWC-07 / IPC-05: ownership transfer, not a deep clone — the same rule MapEvent follows,
// ownership transfer, not a deep clone — the same rule MapEvent follows,
// applied to the other (and larger, on bulk reads) hot-path message. The enclosing
// WorkerCommandReply is parsed fresh from a single pipe frame in WorkerClient's read loop
// and is single-consumer by construction: CompleteCommand's TryRemove hands it to exactly
@@ -93,10 +93,10 @@ public sealed class MxAccessGrpcMapper
{
ArgumentNullException.ThrowIfNull(workerEvent);
// GWC-07 / IPC-05: ownership transfer, not a deep clone. The enclosing WorkerEvent is
// ownership transfer, not a deep clone. The enclosing WorkerEvent is
// parsed fresh from a single pipe frame in WorkerClient's read loop and is discarded
// immediately after this mapping — the SessionEventDistributor pump is its single
// consumer (GWC-01 claims the worker event channel as single-reader), so nothing else
// consumer (it claims the worker event channel as single-reader), so nothing else
// aliases or mutates workerEvent.Event. We therefore move the inner MxEvent into the
// outbound graph instead of cloning it. Downstream the pump fans this one MxEvent to
// every subscriber and retains it in the replay ring, but that sharing is READ-ONLY
@@ -35,7 +35,7 @@ public sealed class GatewayMetrics : IDisposable
private readonly ConcurrentDictionary<string, long> _eventsBySession = new(StringComparer.Ordinal);
private readonly Dictionary<string, long> _retryAttemptsByArea = new(StringComparer.OrdinalIgnoreCase);
// GWC-15: live backlog sources for the gRPC event-stream queue-depth gauge. Each active
// live backlog sources for the gRPC event-stream queue-depth gauge. Each active
// StreamEvents subscriber registers a delegate returning its channel's current backlog; the
// gauge sums them ONLY when scraped or projected (GetGrpcEventStreamQueueDepth), so nothing is
// read on the per-event streaming hot path. Concurrent because subscribers register/unregister
@@ -43,7 +43,7 @@ public sealed class GatewayMetrics : IDisposable
private readonly ConcurrentDictionary<long, Func<int>> _eventStreamBacklogSources = new();
private long _nextEventStreamBacklogSourceId;
// PERF-02: the same pull model for the worker event queue depth. It replaces a pushed scalar that
// the same pull model for the worker event queue depth. It replaces a pushed scalar that
// every WorkerClient wrote twice per event (staged, consumed) under _syncRoot — a process-wide
// lock on the hottest path, and last-writer-wins across sessions, so the gauge reported one
// arbitrary session's backlog instead of the gateway's. Each client registers a source returning
@@ -210,7 +210,7 @@ public sealed class GatewayMetrics : IDisposable
/// <param name="method">Name of the command method.</param>
public void CommandStarted(string method)
{
// PERF-02: the three command counters run two-to-three times per gRPC call, so they use
// the three command counters run two-to-three times per gRPC call, so they use
// Interlocked rather than _syncRoot — the same idiom as EventReceived. Nothing here needs a
// consistent multi-field view; GetSnapshot reads each with Interlocked.Read.
Interlocked.Increment(ref _commandsStarted);
@@ -281,7 +281,7 @@ public sealed class GatewayMetrics : IDisposable
/// Registers a live depth source for the worker event queue-depth gauge and returns a handle
/// that removes it when disposed. Each <c>WorkerClient</c> registers once and reports its own
/// undelivered (staged + queued) event count, so the gauge is the gateway-wide sum instead of
/// the last value any one session happened to push (PERF-02).
/// the last value any one session happened to push.
/// </summary>
/// <param name="depth">
/// Returns this worker client's current undelivered event count. Invoked only at collection
@@ -303,7 +303,7 @@ public sealed class GatewayMetrics : IDisposable
/// returns a handle that removes it when disposed. The gauge sums every registered
/// source's current value on demand (when scraped or via <see cref="GetSnapshot"/>),
/// rather than tracking a pushed running total, so the streaming hot path does no
/// per-event gauge bookkeeping (GWC-15).
/// per-event gauge bookkeeping.
/// </summary>
/// <param name="backlog">
/// Returns this subscriber's current channel backlog. Invoked only at collection time;
@@ -351,7 +351,7 @@ public sealed class GatewayMetrics : IDisposable
{
// Tagged by stage only. Neither the key id nor the peer address may become a tag: key ids
// are credential material and peer addresses are unbounded cardinality, and /metrics is not
// authenticated (open SEC-14), so anything tagged here is world-readable.
// authenticated, so anything tagged here is world-readable.
_authThrottledCounter.Add(1, new KeyValuePair<string, object?>("stage", stage));
}
@@ -460,7 +460,7 @@ public sealed class GatewayMetrics : IDisposable
{
// Compute the live queue depths outside _syncRoot: the sources are the subscriber channels'
// Count (their own locks) and the worker clients' interlocked counters, neither of which may
// run under this lock. GWC-15, PERF-02.
// run under this lock.
int workerEventQueueDepth = GetWorkerEventQueueDepth();
int grpcEventStreamQueueDepth = GetGrpcEventStreamQueueDepth();
lock (_syncRoot)
@@ -521,7 +521,7 @@ public sealed class GatewayMetrics : IDisposable
}
}
// Sums the undelivered event backlog across every live worker client (PERF-02).
// Sums the undelivered event backlog across every live worker client.
private int GetWorkerEventQueueDepth() => SumSources(_workerEventQueueDepthSources);
// Sums the live backlog across every registered event-stream subscriber.
@@ -571,6 +571,7 @@ public sealed class GatewayMetrics : IDisposable
{
private int _disposed;
/// <summary>Removes the source from its gauge; subsequent calls are no-ops.</summary>
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
@@ -39,12 +39,7 @@ public sealed class AuditDrainService(
/// <summary>Upper bound on how long shutdown waits for the remaining buffered events.</summary>
private static readonly TimeSpan ShutdownDrainCap = TimeSpan.FromSeconds(2);
/// <summary>
/// Bootstraps the audit table, runs one retention sweep, then attaches the drain so the
/// writer switches from synchronous write-through to enqueueing.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <inheritdoc />
public override async Task StartAsync(CancellationToken cancellationToken)
{
try
@@ -64,12 +59,7 @@ public sealed class AuditDrainService(
await base.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Detaches the drain (so late writes go straight to the sink) and gives the buffered
/// events a bounded window to reach the store.
/// </summary>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
writer.DetachDrain();
@@ -87,11 +87,9 @@ public sealed class SqliteCanonicalAuditStore(
}
/// <inheritdoc />
/// <remarks>
/// One connection, one transaction and one prepared command for the whole batch: the drain
/// pays a single commit for up to <see cref="AuditDrainService.MaxBatchSize"/> events rather
/// than one round-trip per event.
/// </remarks>
// One connection, one transaction and one prepared command for the whole batch: the drain
// pays a single commit for up to AuditDrainService.MaxBatchSize events rather
// than one round-trip per event.
public async Task InsertBatchAsync(IReadOnlyList<AuditEvent> auditEvents, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(auditEvents);
@@ -148,17 +146,15 @@ public sealed class SqliteCanonicalAuditStore(
}
/// <inheritdoc />
/// <remarks>
/// The comparison goes through SQLite's <c>datetime()</c> rather than comparing the stored
/// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
/// ISO-8601 — which <see cref="AuditEvent.OccurredAtUtc"/> guarantees for rows written through
/// this store, but not for rows that entered the table any other way (a repair script, an
/// older schema, a future producer). On a mixed-format column a text comparison silently
/// deletes live audit: <c>2026-05-17T09:00:00-05:00</c> is two hours AFTER a
/// <c>2026-05-17T12:00:00+00:00</c> cutoff yet sorts before it. Comparing instants is correct
/// regardless of how the text got there, and anything <c>datetime()</c> cannot parse yields
/// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
/// </remarks>
// The comparison goes through SQLite's datetime() rather than comparing the stored
// ISO-8601 text directly. Text comparison is only correct while every row is UTC-normalized
// ISO-8601 — which AuditEvent.OccurredAtUtc guarantees for rows written through
// this store, but not for rows that entered the table any other way (a repair script, an
// older schema, a future producer). On a mixed-format column a text comparison silently
// deletes live audit: 2026-05-17T09:00:00-05:00 is two hours AFTER a
// 2026-05-17T12:00:00+00:00 cutoff yet sorts before it. Comparing instants is correct
// regardless of how the text got there, and anything datetime() cannot parse yields
// NULL and is therefore never deleted — audit that cannot be dated is kept, not swept.
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection =
@@ -13,7 +13,7 @@ public sealed record ApiKeyConstraints(
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
/// <summary>
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
/// Gets the dashboard event-visibility tags granted to this key.
/// </summary>
/// <remarks>
/// <para>
@@ -44,7 +44,7 @@ public interface IApiKeyCacheInvalidator
/// evicting so an in-flight verification that started under the old generation discards its own
/// repopulation (set-then-recheck in <c>VerifyAsync</c>) — making the "gateway-initiated mutations
/// take effect immediately" contract true even against a verify that was already in the inner library
/// when the revoke landed (SEC-34 window 3).
/// when the revoke landed.
/// </para>
/// <para>
/// The short TTL is the backstop for two remaining bounded-staleness windows. (1) Out-of-band
@@ -141,7 +141,7 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
}
// Caches a successful verification unless the key was invalidated while the inner verify was in
// flight (SEC-34 window 3, detected via the generation snapshot). The entry lifetime stays the
// flight (detected via the generation snapshot). The entry lifetime stays the
// TTL: the library verification identity carries no expiry, so the cache cannot cap at the key's
// ExpiresUtc — that window remains TTL-bounded and documented in the class remarks.
private void TryCacheSuccess(
@@ -202,11 +202,14 @@ public sealed class CachingApiKeyVerifier : IApiKeyVerifier, IApiKeyCacheInvalid
? generation
: 0;
// Parses the key id out of a "Bearer mxgw_<keyId>_<secret>" header without any store access —
// the same split the authorization interceptor does. Returns null for a header this cache cannot
// attribute to a key id (in which case the generation race-guard is simply not applied).
//
// Correctness of the SEC-34 generation guard rests on parts[1] being the FULL key id: the '_'
/// <summary>
/// Parses the key id out of a <c>Bearer mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> header without any store access,
/// using the same split the authorization interceptor does.
/// </summary>
/// <param name="authorizationHeader">The raw authorization header value, or <see langword="null"/>.</param>
/// <returns>The full key id, or <see langword="null"/> for a header this cache cannot attribute to a key id (the generation race-guard is then not applied).</returns>
// Implementation note:
// Correctness of the generation guard rests on parts[1] being the FULL key id: the '_'
// split would truncate a key id that itself contained '_', silently disarming the guard for that
// key. This is safe because '_' is the token's field delimiter and both — and the only — key
// creation paths in the gateway forbid it: ApiKeyAdminCommandLineParser.IsValidKeyId and
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// source-rotating sprayer that never trips any single partition.
/// </para>
/// <para>
/// An over-limit state is a valve, not a wall (SEC-31): at most one request per
/// An over-limit state is a valve, not a wall: at most one request per
/// <see cref="SecurityOptions.ApiKeyFailureProbeIntervalSeconds"/> is admitted through to the real
/// verifier, and a successful verification resets both layers. The reset path therefore stays
/// reachable while throttled — the earlier design keyed solely on the attacker-supplied key id and
@@ -29,8 +29,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>).
/// Because only a validly shaped token mints a key-id partition and each transport peer may mint at
/// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer
/// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush.
/// A validly shaped presented key id still feeds its cross-peer aggregate when its peer
/// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray
/// protection; the aggregate map remains bounded by the same capped eviction policy:
/// eviction prefers fully expired windows, never removes an over-limit partition below a transient
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
/// <remarks>
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
/// Distinguishes two states the earlier code collapsed into one: "authenticated, no
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
@@ -140,12 +140,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
// Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the
// secret may itself contain underscores, which does not affect the key id). The shape is checked
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check
// partition each and flush the limiter's bounded map. Anything that fails the check
// falls back to the sender's transport-peer partition.
//
// Internal rather than private so the parse rules can be pinned directly by test; the shape
// check is a security boundary (SEC-32) and is worth asserting without routing every case
// check is a security boundary and is worth asserting without routing every case
// through a full RPC.
/// <summary>
/// Extracts the API key id from a bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The raw <c>authorization</c> header value, or <see langword="null"/>.</param>
/// <returns>The key id when the header has the <c>mxgw_&lt;keyId&gt;_&lt;secret&gt;</c> shape; otherwise <see langword="null"/>.</returns>
internal static string? TryResolveKeyId(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -39,7 +39,7 @@ public sealed class GatewaySession
private bool _everHadEventSubscriber;
private SessionEventDistributor? _eventDistributor;
private bool _eventDistributorStarted;
// GWC-34: set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
// Set under _syncRoot when DisposeAsync nulls _eventDistributor, making the
// "one distributor per session, never reset" lifecycle symmetric at both ends. Without it
// EnsureDistributorCreated reads the null as "not created yet" and builds a REPLACEMENT
// distributor after teardown — one whose pump can never start (_eventDistributorStarted is a
@@ -163,7 +163,7 @@ public sealed class GatewaySession
/// is unit-testable.
/// </param>
/// <param name="ownerDashboardTags">
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). Copied into
/// Dashboard event-visibility tags inherited from the owning API key. Copied into
/// the immutable <see cref="Tags"/> set; <see langword="null"/> or empty means untagged.
/// The tags come from the owner key, never from the client's wire request, so a client
/// cannot label its own session with another tenant's tag.
@@ -265,8 +265,8 @@ public sealed class GatewaySession
public string? OwnerKeyId { get; }
/// <summary>
/// Gets the dashboard event-visibility tags this session inherited from its owning API key
/// (SEC-25). An empty set means untagged.
/// Gets the dashboard event-visibility tags this session inherited from its owning API key.
/// An empty set means untagged.
/// </summary>
/// <remarks>
/// Immutable for the session's life — assigned once at construction from the owner key's
@@ -541,7 +541,7 @@ public sealed class GatewaySession
// concurrent callers (e.g. MarkReady's dashboard mirror and a racing first
// AttachEventSubscriber) agree on a single distributor and a single start.
//
// GWC-34: after DisposeAsync the null distributor means "gone", not "not built yet", so this
// After DisposeAsync the null distributor means "gone", not "not built yet", so this
// throws instead of constructing a replacement nothing will ever pump. The attach paths already
// roll back and surface the exception, which is the same SessionNotReady the readiness gates
// raise — a caller that loses the race to teardown now fails immediately instead of hanging.
@@ -610,7 +610,7 @@ public sealed class GatewaySession
/// </exception>
public IEventSubscriberLease AttachInternalEventSubscriber()
{
// Readiness gate, mirroring AttachEventSubscriber (GWC-27). It must run BEFORE
// Readiness gate, mirroring AttachEventSubscriber. It must run BEFORE
// EnsureDistributorCreated: a premature attach would construct the distributor and start
// its pump against a not-yet-Ready worker, the pump source would throw SessionNotReady,
// PumpAsync would complete every subscriber with that error and latch _completed, and
@@ -694,7 +694,7 @@ public sealed class GatewaySession
// These three calls are OUTSIDE _syncRoot to avoid holding it across
// EnsureDistributorCreated's own lock and StartAsync's Task.Run.
//
// GWC-34: the state check above can be overtaken by a close+dispose before we get here, and
// The state check above can be overtaken by a close+dispose before we get here, and
// EnsureDistributorCreated now says so instead of building an orphan distributor. The
// dashboard mirror is a best-effort observability attachment on a session that is already
// gone, so it is abandoned here rather than propagated out of MarkReady — the mirror-started
@@ -1918,7 +1918,7 @@ public sealed class GatewaySession
{
distributor = _eventDistributor;
_eventDistributor = null;
// GWC-34: latch the null-out so EnsureDistributorCreated cannot read it as
// Latch the null-out so EnsureDistributorCreated cannot read it as
// "not created yet" and hand a racing attach a distributor that will never pump.
_eventDistributorDisposed = true;
}
@@ -19,7 +19,7 @@ public interface ISessionManager
/// <summary>
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
/// tags onto it (SEC-25).
/// tags onto it.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param>

Some files were not shown because too many files have changed in this diff Show More