Compare commits

...

45 Commits

Author SHA1 Message Date
Joseph Doherty f2a422b02f docs(auth): canonical scope strings in the two remaining stale lists
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m18s
ci / java (push) Successful in 2m58s
ci / portable (push) Successful in 8m49s
Both lists predate the scope rename and would mislead anyone creating a key:
CLAUDE.md's Authentication section still named the pre-rename scopes, and
docs/Authentication.md's ops.alice example passed 'read,write', which
GatewayScopes.ValidateScopes rejects outright. Same defect family as the
Build/Test/Run sample fixed in a5f843c.

Recorded as a follow-up: code review finding Server-012 claims it fixed the two
CLAUDE.md lists on 2026-05-18, but neither correction was present — a Resolved
finding is not re-examined, so the sibling Server-0xx doc resolutions want a
spot-check for the same pattern.
2026-08-18 06:13:52 -04:00
Joseph Doherty a5f843cbfb chore(plan): close the record — prior follow-ups annotated, closeout as-built noted
The prior plan's "Follow-ups recorded, not started" block described pre-branch
behavior; every item is now closed, narrowed, or restated with its evidence, so
the block no longer misleads a reader who lands on it first. The stale Rust-guard
bullet is corrected in place rather than deleted: Check 3 always existed, and
saying so is the only way the reader learns what the real (one-directional) gap was.

Also fixes the CLAUDE.md apikey sample, which named a verb the parser has never
accepted ('create'; only 'create-key' exists, no alias), omitted the required
--key-id, and listed non-canonical scope strings that GatewayScopes now rejects
at create time — the sample could not have run.
2026-08-18 06:12:31 -04:00
Joseph Doherty c3c603f169 docs(alarms): describe the real StreamAlarms open sequence, and close TST-16
The final integration review's non-blocker reservations, all documentation
or comment truth except one test arm.

The alarm feed opens provider_status -> snapshot_status -> cached
active_alarm -> snapshot_complete, which is what GatewayAlarmMonitor has
done since the snapshot_status frame landed. Two places still described
the old order: docs/Grpc.md said provider_status arrived *after* the
initial snapshot, contradicting its own snapshot_status section two
paragraphs down, and AlarmFeedMessage's leading proto comment named
neither status frame at all. Both now state the sequence the monitor
emits, so a client author reading either one gets the frame order right.

The proto comment change flows through the generated trees (Contracts,
Go, Java) and the client descriptor set; the Rust vendored copy stays
byte-identical to canonical. Python's generator does not carry proto
comments into its output, so it has no delta.

AlarmsHubPublisherTests' valueless-payload case covered snapshot_complete
and provider_status but not snapshot_status, leaving the newest arm
unpinned against the redaction switch that must ignore it. Added.

WnWrapAlarmConsumer's ack comment led with the 2026-05-01 reading that
-55 tracks the 8-arg overload, then refuted itself six lines later with
the 2026-08-18 probe. It now leads with the observation labelled as
narrower than it reads -- mirroring the correction already in
docs/AlarmClientDiscovery.md -- so the block argues one thing: the 6-arg
call site stays for parity, and rc semantics are per the probe. A
paragraph orphaned by an earlier splice is rewrapped. Comment interior
only; the file compiles on Windows.

TST-16 gets a dated closure note rather than a rewrite: the flag it
called dead was implemented 2026-08-18. GatewayDashboardDesign's /browse
paragraph gains the failed-read carve-out GatewayConfiguration already
documented, so the two agree that a failed read keeps its - placeholder.
2026-08-18 06:09:06 -04:00
Joseph Doherty a95e510015 chore(plan): Task 8 verification-green at 90331b6 — residuals recorded 2026-08-18 05:56:45 -04:00
Joseph Doherty 47d74032e6 chore(plan): Task 7 complete — probe retry answered Q2 and the re-raise leg, ack leg re-blocked with evidence 2026-08-18 05:48:10 -04:00
Joseph Doherty 540a1188aa docs(alarms): stop describing the wnwrap ack as working, and say the re-raise replaces the record
Follow-up to 90331b6. Three comment/prose corrections, no behaviour change.

AcknowledgeByName's comment still said the 6-arg overload "works and reaches the
alarm-history path correctly", which the same commit's own findings contradict in
three other places. It now says what was observed: rc=0 means accepted, not
applied — the 2026-08-18 probe acked a live alarm six ways and the snapshot, the
OPERATOR_NAME field, and the extension's .Acked attribute all stayed put. The -55
tracks the consumer, not the overload. Subscribe's comment gets the same
treatment: "lets AlarmAckByName succeed" becomes "return rc=0".

AlarmProbeFindings.md said the re-raise arrives as "a separate record" alongside
the returned one, which reads as coexistence and is wrong. The snapshot carries
one record per tag: the cap=1024 replies bracketing the re-raise are both
elementCount=3 (one per TestMachine_00{1,2,3}) at an identical 1613 bytes, and the
old GUID is absent from the later one. The re-raise replaces the record, so a
single poll spanning it sees the Clear and the Raise together.

Worker diff verified strictly comment-only; builds x86 on windev, 0W/0E.
2026-08-18 05:46:35 -04:00
Joseph Doherty 90331b622c docs(alarms): second wnwrap probe attempt — secured-write verb unblocked the rig, answering @COUNT and the re-raise GUID leg
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m23s
ci / java (push) Successful in 2m25s
ci / portable (push) Successful in 24m49s
AuthenticateUser("Administrator", "") + WriteSecured raises the alarm UDAs that
plain Write could not touch (SecurityError detail=1008), so the 2026-08-17 blocker
was the verb, exactly as that run's own Unblocking list predicted.

Two of the three open questions are now observed rather than assumed:

- ALARM_RECORDS/@COUNT reports the records in the reply, not the total active
  count. With three alarms active it read 1 at cap 1 and 2 at cap 2. There is no
  exact truncation signal to switch to, so IsTruncatedFetch's conservative rule is
  the design rather than a placeholder — behaviour unchanged, only the comments.
- Clear-then-re-raise mints a new GUID; the ALM->RTN leg keeps its GUID
  (reconfirming the 2026-05-01 capture). ComputeTransitions already reads the
  re-raise correctly as one instance ending and another beginning.

The acknowledge leg stays unobserved for a narrower reason: every wnwrap ack
surface is inert on this rig. AlarmAckByName returns 0 from the ack-only consumer
and -55 from the SetXmlAlarmQuery-applied one, for both the 6-arg and 8-arg forms,
and neither the snapshot STATE, OPERATOR_NAME, nor the extension's own .Acked
attribute moves. That corrects AlarmClientDiscovery.md, which read the zero return
as a working ack.

Comment- and prose-only; no behaviour change. The three throwaway probes ran from
the windev CI clone and were deleted; that clone is a clean tree at ab3ff16.
2026-08-18 05:40:11 -04:00
Joseph Doherty ad30adca2f chore(plan): Task 2 complete — five-client snapshot_status rollout approved 2026-08-18 05:33:25 -04:00
Joseph Doherty 4d4f443c27 fix(clients): review nits — alphabetize the Java CLI alarm imports, cover the Rust snapshot-status render
The Java import block's AlarmFeedMessage already sorted after
AlarmProviderStatus before c748361; adding AlarmSnapshotStatus widened the
gap. Order all three alphabetically.

The Rust CLI tests the sibling ProviderStatus render path but not the new
SnapshotStatus arm, so the summary string and the JSON shape were both
uncovered. Add the matching test over alarm_feed_message_summary and
alarm_feed_message_to_json.
2026-08-18 05:33:00 -04:00
Joseph Doherty c7483615cf chore(clients): roll out feed-level snapshot_status to all five clients
Task 1 added AlarmSnapshotStatus and AlarmFeedMessage.snapshot_status = 5.
Carry it downstream from the canonical Contracts protos:

- Rust vendored protos under clients/rust/protos, refreshed byte-identical
  (build.rs falls back to them for out-of-repo tarball builds)
- client descriptor set (protoc 34.1 pin)
- Go (protoc-gen-go v1.36.11 / protoc-gen-go-grpc 1.6.2)
- Python (grpcio-tools 1.80.0 pin)
- Java (gradle generateProto)

.NET needs no regeneration: the client compiles against the Contracts
Generated/ output committed with the proto change.

The hand-written CLI feed renderers switch on the payload oneof, so codegen
alone does not carry the arm. Add snapshot-status to the .NET, Go, Rust, and
Java renderers; the .NET and Go renderers were also missing provider-status,
which has been on the wire since the provider-mode work, so add it there too.
Java's renderer is an exhaustive switch expression and did not compile until
the new case landed. The Python CLI renders generic protobuf-JSON and needs
no change.

Each client README gains a paragraph on the feed-level frame next to its
existing from_truncated_snapshot paragraph: it arrives at stream open after
provider_status and before the cached active_alarm frames, then on every
verdict change including the clearing frame a monitor restart emits, so a
live consumer can track set completeness without polling QueryActiveAlarms.

GatewayDashboardDesign: list the two new payload cases the AlarmsHub forwards,
and — separately — record the GroupToRole / GroupToTag / UntaggedSessionVisibility
rows the settings page already renders but the bullet list omitted.
2026-08-18 05:26:06 -04:00
Joseph Doherty d13144a9d7 chore(plan): Task 3 complete — ShowTagValues coverage approved across all three seams 2026-08-18 05:21:45 -04:00
Joseph Doherty 155c9d619f chore(plan): Task 6 complete — dashboard_tags display and create input approved 2026-08-18 05:21:21 -04:00
Joseph Doherty 51b0a8f55f chore(plan): Task 1 complete — feed-level snapshot_status approved 2026-08-18 05:21:11 -04:00
Joseph Doherty e50ec493cb docs(config): ShowTagValues /browse clause — redaction applies to successful reads only 2026-08-18 05:20:25 -04:00
Joseph Doherty 312e072070 docs(proto): spell out the monitor-restart clearing emission on AlarmSnapshotStatus 2026-08-18 05:19:31 -04:00
Joseph Doherty fab600d3b0 fix(dashboard): de-duplicate dashboard_tags typed into the API-key create form
The form split tags with the shared ParseList and attached the result verbatim,
so "team-a, TEAM-A" persisted as two entries and the constraints column read
dashboard_tags=[team-a, TEAM-A] — one grant reported as two on the page whose
job is to show what a key was granted. Enforcement never saw it (a session holds
its tags in a case-insensitive set), which is exactly why the display was the
only place it could surface.

De-duplicates ordinal-ignore-case at the attach point only, first spelling
winning, matching ApiKeyAdminCommandLineParser.ParseDashboardTags. ParseList is
untouched: the five glob lists are matched literally, so near-duplicates there
are not necessarily the same rule and must survive verbatim — pinned by a test.

The help text claimed to mirror the CLI flag; it now claims only the shared
separators and the dedupe, since the form still drops an empty segment silently
where the CLI hard-fails. A browser form has no exit code to fail with, so that
difference stays, and Authorization.md now records it.
2026-08-18 05:19:02 -04:00
Joseph Doherty 1ea6f60ea2 fix(dashboard): keep the '-' placeholder on failed /browse reads instead of '[redacted]' 2026-08-18 05:18:47 -04:00
Joseph Doherty bf7b44c2d7 chore(plan): Task 5 complete — settings rows approved with comparer fix 2026-08-18 05:15:37 -04:00
Joseph Doherty ff16c69d59 fix(config): propagate GroupToTag's comparer into the effective-config projection 2026-08-18 05:14:58 -04:00
Joseph Doherty c037d9960d feat(dashboard): ApiKeysPage lists and accepts dashboard_tags constraints
The constraints column enumerated only the eight positional ApiKeyConstraints
members, so a key whose sole recorded policy was a dashboard tag summarised to
an empty string and rendered as "-" — the same cell a key with no policy at
all gets. ApiKeyConstraints.IsEmpty counts DashboardTags, so that key is not
unconstrained, and the column was quietly telling operators otherwise about a
grant that decides who can watch a session's events.

The create form had no dashboard-tags input either, so tagged keys could only
be minted from the apikey create-key CLI. Adds the field beside the other
constraint lists (same ParseList separators) and attaches it through the
record's init-only member, since it postdates the eight-member constructor.

CreateModel, OpenCreateDialog and TryBuildCreateRequest widen to internal for
the new render tests: the create form is behind a click and static rendering
cannot dispatch one. That is the assembly's existing InternalsVisibleTo seam.
2026-08-17 07:17:43 -04:00
Joseph Doherty fccf75324b feat(alarms): feed-level snapshot_status truncation frame on StreamAlarms 2026-08-17 07:16:51 -04:00
Joseph Doherty 094f2ffee4 feat(dashboard): settings page shows GroupToTag and UntaggedSessionVisibility 2026-08-17 07:15:24 -04:00
Joseph Doherty eff17d177c fix(dashboard): ShowTagValues now gates the alarms hub and /browse live values 2026-08-17 07:14:40 -04:00
Joseph Doherty 222b01f488 chore(plan): Task 4 complete — Check 3 reverse sweep approved 2026-08-17 07:07:09 -04:00
Joseph Doherty 711793ffdc ci(codegen): Check 3 sweeps both directions — canonical protos missing a vendored copy now fail 2026-08-17 07:04:49 -04:00
Joseph Doherty 338bc20a0a docs(plan): follow-up closeout — feed-level truncation signal, ShowTagValues coverage completion, codegen reverse sweep, dashboard display gaps, probe retry 2026-08-17 07:01:48 -04:00
Joseph Doherty ab3ff1612c Merge feat/deferred-closeout: SEC-25 per-session dashboard event ACL on both seams, alarm-truncation degraded-status signal through proto and all five clients, frame-writer lock-parking closed, dead ReadEventsAsync chain removed, alarm probes recorded blocked with evidence
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Successful in 7m56s
2026-08-17 05:34:24 -04:00
Joseph Doherty 870b744e9b docs(closeout): reviewer's two residual-record asks — alarms-hub redaction gap named, ConstraintText artifact recorded; all 12 tasks complete
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m18s
ci / java (push) Successful in 2m17s
ci / portable (push) Successful in 8m13s
2026-08-17 05:27:31 -04:00
Joseph Doherty b621d692d0 docs+test(closeout): final-review reservations — stale ACL prose, worker test gaps, config sample fix
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m14s
ci / java (push) Successful in 2m10s
ci / portable (push) Successful in 8m31s
2026-08-17 05:23:23 -04:00
Joseph Doherty f5a58d884b docs(plans): closeout as-built record; prior plan's out-of-scope table closed 2026-08-17 05:03:02 -04:00
Joseph Doherty d05f38b661 docs(plans): closeout tasks 1-10 complete
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m13s
ci / portable (push) Successful in 8m2s
2026-08-17 04:54:36 -04:00
Joseph Doherty 64da630258 docs(rust-client): field-access notation fix in the truncation note (review nit) 2026-08-17 04:52:20 -04:00
Joseph Doherty 1d8a4a6442 test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented
The per-session dashboard event ACL shipped in 693a78d + 7ec0b35 with unit
coverage over a fabricated principal. What a fabricated principal cannot show is
that the group names the shared directory actually returns -- short RDN values,
not DNs -- are the ones Dashboard:GroupToTag keys match. Two [LiveLdapFact]s
close that: gw-viewer binds for real, its GwReader membership grants team-a, and
IDashboardSessionAcl then admits a team-a-tagged session and refuses a
team-b-tagged one; multi-role takes the Administrator bypass. The mapping is
config-side only -- no GLAuth entry, group, or membership was added, and
glauth.md records that explicitly so a future reader does not go looking for a
directory change that never happened.

multi-role is a member of GwReader as well as GwAdmin, so it holds team-a too.
Its bypass is therefore asserted on team-b and on the untagged session -- the two
it would lose if the Administrator branch were ever dropped -- rather than on
team-a, which would pass either way.

One cheap hardening from a prior review: a GatewayOptionsTests case binds
Dashboard:GroupToTag through a real ConfigurationBuilder and looks the group up
mis-cased. The property initializer seeds an OrdinalIgnoreCase dictionary, but
only the binder decides whether that instance survives; if it did not, a
mis-cased group name from the directory would grant no tags and the ACL would
deny with no diagnostic.

Docs follow the shipped shape: docs/Sessions.md gains the session-tag model
(owner-key sourced, immutable, visibility-not-access), gateway.md and CLAUDE.md
gain the ACL in their dashboard-auth paragraphs, and three
GatewayDashboardDesign.md passages that still described the ACL as outstanding
now describe both gated seams and the decision order. GatewayConfiguration.md's
ShowTagValues row no longer claims the redaction is the only thing between a
Viewer and another session's values -- it is now the second of two independent
layers. gateway.md's hub-token lifetime corrected 30 minutes -> 5, matching
HubTokenService. Authentication.md disambiguates --dashboard-tags as the only
constraint flag that splits on commas. The plan doc header is Implemented; its
as-built section 12 already existed and is not duplicated.

Verified: NonWindows.slnx builds clean; GatewayOptions/DashboardSessionAcl/
EventsHub filters 37/37; the live-LDAP suite skips cleanly without the env var
and runs 7/7 green against the shared GLAuth with it.
2026-08-17 04:48:34 -04:00
Joseph Doherty d9ea8a81f1 chore(clients): regenerate for alarm truncation fields; READMEs note the degraded flag
Task 8 added ActiveAlarmSnapshot.from_truncated_snapshot = 16 and
QueryActiveAlarmsReplyPayload.snapshot_truncated = 2. Regenerate every
downstream binding from the canonical Contracts protos:

- client descriptor set (protoc 34.1 pin)
- Go (protoc-gen-go v1.36.11 / protoc-gen-go-grpc 1.6.2)
- Python (grpcio-tools 1.80.0 pin)
- Java (gradle generateProto)
- Rust vendored protos under clients/rust/protos, which build.rs falls back
  to for out-of-repo tarball builds and which must track Contracts

.NET needed no regeneration - the client compiles against the Contracts
Generated/ output already committed with the proto change. No client has a
typed wrapper model around ActiveAlarmSnapshot; all five pass the generated
type straight through, so codegen alone carries the field.

Each client README's alarm section gains a paragraph on the flag: the
snapshot set may omit actives and absence-implies-cleared inference was
suspended, so callers must not reconcile deletions from a truncated set.
Distinguished from the per-record 'degraded' subtag-fallback flag, which it
is easily confused with.
2026-08-17 04:48:20 -04:00
Joseph Doherty b9fb0dd720 fix(alarms): atomic snapshot+truncation read; direct tests for the flag plumbing (review)
Review found AlarmDispatcher.SnapshotActiveAlarms reading the snapshot and the
truncation verdict through two independent lock acquisitions, defended by a
comment claiming read-order made a race "widen only, never narrow". That claim
was false: a not-truncated -> truncated poll landing between the two reads pairs
a stale false with a capped snapshot, which is exactly the false all-clear the
feature exists to prevent. It was safe only because AlarmCommandHandler
STA-serializes consumer calls — an accident of the call graph, not an invariant.

Made the invariant structural instead of documented. IMxAccessAlarmConsumer now
exposes ONE accessor, `IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(
out bool truncated)`, which implementations must satisfy from a single
acquisition of the lock guarding the retained snapshot — mirroring the write
side, where FoldFetch already updates snapshot and verdict together. The
separate LastSnapshotTruncated property is gone from every layer, so there is no
second read left to pair badly. `out` over a result struct follows the file's
established idiom (FoldFetch, ParseSnapshotXml).

The same threading applies one level up: IAlarmCommandHandler.QueryActive now
carries `out bool snapshotTruncated`, so MxAccessCommandExecutor stamps the reply
payload from the value the records were stamped with rather than reading the
state a second time.

Direct tests for the three hops that were only covered end-to-end:
- AlarmDispatcherTests: truncated consumer snapshot stamps FromTruncatedSnapshot
  on every mapped record, with a complete-snapshot control, plus an assertion
  that the independent per-record Degraded flag is not dragged along.
- AlarmCommandHandlerTests: the verdict delegates through the dispatcher
  (Theory over both values), and survives a prefix filter that removes every
  record — the case the per-record flag cannot cover.
- AlarmCommandExecutorTests: the reply payload's SnapshotTruncated comes from the
  handler (Theory over both values), including the zero-record case.
The WnWrapAlarmConsumer truncation tests now assert through
SnapshotActiveAlarms(out ...) rather than an internal field, because the pairing
is the contract.

Also: GatewayAlarmMonitor's _snapshotTruncated comment now says "as of the last
full reconcile" rather than implying it tracks the current _alarms contents,
which live transitions keep moving via ApplyTransition between passes.

Detection heuristic still untouched (fetchedRecordCount >= maxAlarmsPerFetch);
no @COUNT parsing, per docs/AlarmProbeFindings.md. Still additive gateway
metadata about our fetch mechanics, not MXAccess behavior — not a parity
deviation, and no event is synthesized.

Gateway: NonWindows.slnx builds clean (0 warnings); ~Alarm filter 107/107 pass.
Worker + Worker.Tests are windev-gated; the signature change was reviewed by
inspection across all 7 IMxAccessAlarmConsumer implementers, all 3
IAlarmCommandHandler implementers, and every call site.
2026-08-17 04:39:33 -04:00
Joseph Doherty 7ec0b3594c fix(dashboard): close AttachEventsAsync re-entrancy window; pin ACL decision-table corners (SEC-25 review)
Follow-up to the per-session event ACL. Part of that change rode into 693a78d
via a concurrent agent's pathspec-less commit; this commit carries the review
fixes and uses pathspecs on the commit itself so it cannot recur in either
direction.

Gating the page's subscribe seam made AttachEvents asynchronous — it awaits the
authentication state — and that await is a suspension point the synchronous
version did not have. On a rapid A -> B navigation the suspended A continuation
resumes after B's parameter set has run to completion, re-reads the live
SessionId (now B's), and attaches B a SECOND time. The ACL is not bypassed —
the newer attach already cleared that same session — but the fields holding B's
first subscription are overwritten in place, so nothing ever disposes it: its
EventsHubViewerRegistry entry is never released, which keeps the mirror cloning
events for a session the page is no longer watching through that handle, and
its pump is never cancelled. A resource leak the ACL work introduced.

OnParametersSetAsync now claims a monotonic _attachGeneration synchronously,
before its first await, and AttachEventsAsync re-checks it after the await and
before any field write or Subscribe call. A stale attach returns rather than
detaching: it owns nothing, and tearing down there would destroy the newer
attach's subscription. DetachEventsAsync needs no such guard — it captures and
nulls the live fields synchronously before it awaits, so a resumed detach only
unwinds what it already took ownership of. Same dispatcher-owned identity idea
as the existing ReferenceEquals guards in PumpEventsAsync and
MarkDisconnectedAsync, one level up.

The interleaving is not expressible with the static HtmlRenderer idiom the other
page tests use: it renders a root component once and exposes no parameter-update
seam. The new test therefore adds a minimal Renderer subclass whose only job is
to mount a component and drive a second SetParametersAsync into it while the
first is parked on a gated AuthenticationStateProvider. That subclass is the
lone reason for a narrowly scoped BL0006 suppression, justified in place: it is
test-only scaffolding that never ships, and the cost of the warning coming true
is a compile break in one test file on an SDK bump. Confirmed non-vacuous by
mutation — with the generation check disabled the test goes red on the doubled
subscription and the two passing ACL tests stay green.

Two decision-table corners are now pinned rather than implied. Admin x
nonexistent session id resolves to ALLOW, because the admin bypass is evaluated
before the registry lookup; a plausible "look the session up first, it reads
better" refactor would flip it, so a test documents the ordering. EventsHub's
remarks said "an unknown session id is denied" without qualification, which read
as universal; they now state that the bypass is checked first and every rule
below it is a non-Admin rule.

HubTokenServiceTests gains the truly-absent-field case: a hand-built payload
JSON with no Tags key at all, protected through the same purpose, which is the
shape every in-flight token has across the deploy that introduces the field. The
existing test covered present-but-empty, which does not exercise the null
coalesce that stands between a legacy token and a crash on the hub auth path.
ProtectorPurpose became internal so the test cannot drift from the real purpose
string.

Tag-count cardinality cap considered and recorded as a deliberate non-goal.

Build 0 warnings / 0 errors; 48 filtered (ACL/hub/token/page) and 257 dashboard
tests pass.
2026-08-17 04:35:59 -04:00
Joseph Doherty 693a78db7d feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.

Two additive proto3 booleans carry the verdict out:

- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)

The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.

Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.

Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.

Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.

Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.

Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
2026-08-17 04:18:34 -04:00
Joseph Doherty b8b7b69ba0 fix(worker): observe detached drain faults per NEXT-04 discipline
Both tasks the detached-lock-wait path starts and discards now carry the
file's fault-observing continuation, factored out of ObserveAbandonedFault as
ObserveFault so the idiom has one definition. DrainDetachedAsync swallows the
drain but its _writeLock.Release() sits in a finally outside that catch, so a
Release that ever throws — a SemaphoreFullException from some future
double-release regression — had no awaiter and would have surfaced on net48 as
TaskScheduler.UnobservedTaskException at finalization instead of an
attributable failure. Same for a throw out of OnDetachedLockWaitSettled.

Review's Minor (distinguishing a cancelled from a faulted lock wait before
tombstoning) is deliberately not taken: a faulted WaitAsync is unreachable
here — nothing disposes _writeLock — so the branch would be untestable new
logic whose only effect is internal state, the caller already receiving the
fault itself from the rethrow. Recorded as a comment at the site instead.

edited on macOS, windev verification pending (plan Task 11). Re-ran the net10
scratch harness over WorkerFrameWriter and the writer suite: 0 warnings,
31/31 pass.
2026-08-17 04:07:44 -04:00
Joseph Doherty ce5d8ae7c2 docs(alarms): wnwrap live-probe findings — GUID identity, ALARM_RECORDS COUNT
Both questions stay open, and the reason is the finding: the dev rig's alarm
UDAs reject a plain MXAccess Write with SecurityError/detail=1008 from the
responding automation object, so no alarm instance can be created to follow
through an acknowledge and no population can be built to overflow a capped
fetch. The rig is otherwise live — objects deployed and on scan, wnwrap
subscribed, GetXmlCurrentAlarms2 returning well-formed XML — which is what
makes the blocker specific and the unblock (engine-side script, or
AuthenticateUser + WriteSecured, or reclassifying the UDAs) actionable.

Comment-only changes in WnWrapAlarmConsumer: scope the GUID-identity claim to
the leg live capture actually covers, and record that ALARM_RECORDS/@COUNT
exists as a candidate exact truncation signal but is deliberately not trusted
because its semantics under a capped reply are unverified. No behavior change.
2026-08-17 04:03:39 -04:00
Joseph Doherty a212e145ac feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)
Adds a dashboard event-visibility tag to ApiKeyConstraints, riding in the existing
constraints JSON blob so no auth-store schema migration is needed (design
docs/plans/2026-07-10-dashboard-session-acl-tst15.md sections 3/3.1, open call
settled per its own recommendation). The tag is visibility-only: no read, write,
browse, or subscribe path consults it, and HasRead/HasWriteConstraints ignore it.

GatewaySession gains an immutable, ordinal-ignore-case Tags set stamped at
construction from the owning API key, forwarded by MxAccessGatewayService.OpenSession
from the resolved ApiKeyIdentity — never from the wire request, so a client cannot
label its own session with another tenant's tag. ISessionManager gains a tag-carrying
OpenSessionAsync overload whose default implementation forwards to the tagless one, so
an implementation that does not model tags opens an untagged (least visible) session.

apikey create-key gains --dashboard-tags team-a,team-b (repeatable, trimmed,
de-duplicated; an empty segment is rejected rather than dropped) and list-keys prints
the tags column. No enforcement yet — the EventsHub ACL that consumes the tag is a
later change.
2026-08-17 03:58:14 -04:00
Joseph Doherty 9130994736 perf(worker): unpark awaited control-frame writers from the winning drain pass
WriteAsync enqueued its frame and then contended unconditionally for the write
lock, so a caller that lost the race stayed in WaitAsync until the winning
drainer released — even though that winner writes, flushes, and completes the
loser's control frame at the control-to-event class boundary, part-way through
its pass. The boundary flush made the delivery point honest; the awaited task
was still charged for the whole event backlog it had just been flushed ahead of.

WriteAsync now awaits its own frame's completion racing the lock acquisition.
Completion first: the caller returns at its frame's delivery point and the
outstanding acquisition is detached, not dropped — a continuation drains
whatever is queued and releases, so the lock is never acquired and silently
held and a frame enqueued between the previous drainer's last dequeue and its
release is still written. Lock first: drain as before. Cancellation keeps the
WRK-22 tombstone semantics exactly, and a wait cancelled after the caller has
already detached releases nothing (SemaphoreSlim hands no count to a wait it
cancels), so no count leaks and no queued frame is stranded. A token that fires
after the frame's completion won the race changes nothing — the frame was
delivered. WriteBatchAsync deliberately keeps the plain wait-then-drain shape:
its last completion resolves at the end-of-pass flush anyway.

Three tests: the latency win (a control caller returning while the winning
WriteBatchAsync event burst is demonstrably still blocked mid-pass), a
mixed-priority concurrency soak pinning exactly-once writes and a single
drainer, and the cancel-after-detach corner (a wrongly released count would
surface as the drainer's own Release throwing SemaphoreFullException).

edited on macOS, windev verification pending (plan Task 11). Verified here by
compiling and running WorkerFrameWriter plus the writer suite against net10.0
in a scratch harness: 31/31 pass, and the two behaviour-pinning tests fail
against the pre-change parked implementation.
2026-08-17 03:55:05 -04:00
Joseph Doherty c79aaaf9eb feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)
Groundwork for the per-session dashboard event ACL (docs/plans/2026-07-10-dashboard-session-acl-tst15.md 3.2): a dashboard group can now grant visibility tags, and untagged sessions default to AdminOnly. Enforcement lands with the EventsHub ACL; nothing consumes the grant yet.

GroupToTag is deliberately uncoupled from GroupToRole - a group may appear in either map, both, or neither - and is validated for shape only. Tags gate dashboard event visibility, never data access.
2026-08-17 03:46:56 -04:00
Joseph Doherty fa9eb0c0b4 refactor(sessions): remove the dead ISessionManager.ReadEventsAsync chain
ISessionManager.ReadEventsAsync had zero production call sites: the worker
event channel is drained once by GatewaySession.MapWorkerEventsAsync (the
distributor pump), and every consumer — gRPC subscribers, the dashboard
mirror, the alarm monitor — attaches to the distributor. The interface
member, SessionManager's forwarder, and GatewaySession.ReadEventsAsync are
gone; IWorkerClient/WorkerClient.ReadEventsAsync is untouched, it is the
live worker-channel claim.

No test was removed or rewired: nothing invoked the member through the
interface. Nine ISessionManager test fakes carried a required-member stub
(seven threw NotSupportedException or yielded nothing; EventStreamServiceTests
and GatewaySessionDashboardMirrorTests forwarded to the session; the two
MxAccessGatewayService fakes yielded their Events list) — all nine stubs were
deleted. The MxAccessGatewayService suites' streaming tests already run
through FakeEventStreamService, which reads the same Events list, so their
coverage is unchanged; only the now-inaccurate doc comments on Events /
LastReadEventsSessionId were reworded.

The MapWorkerEventsAsync comment no longer describes a twin to keep in step;
it now states the single-reader claim directly. docs/Sessions.md drops
ReadEventsAsync from the SessionManager member list and from the Run-state
prose. The 2026-08-15 deferred-remediation as-built note records the removal.
2026-08-17 03:46:41 -04:00
Joseph Doherty af9f185d32 docs(plans): deferred-closeout plan — SEC-25, truncation signal, lock-parking, dead chain, alarm probes 2026-08-17 03:41:59 -04:00
Joseph Doherty ac3f04f6ac Merge perf/deferred-remediation: deferred perf findings closed — in-process dashboard feeds, worker teardown/frame-writer fixes, value-cache clone removal, first green Windows secrets test
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m22s
ci / java (push) Successful in 2m37s
ci / portable (push) Successful in 9m0s
2026-08-16 04:38:53 -04:00
125 changed files with 9947 additions and 1196 deletions
+3 -3
View File
@@ -71,7 +71,7 @@ dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Test
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
# API-key admin CLI (same exe, "apikey" subcommand)
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create --display-name "dev" --scopes session,invoke,event,metadata,admin
dotnet run --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj -- apikey create-key --key-id dev --display-name "dev" --scopes session:open,session:close,invoke:read,invoke:write,invoke:secure,events:read,metadata:read,admin
```
Single test by name (xUnit `--filter`):
@@ -155,11 +155,11 @@ External analysis sources referenced by design docs:
## Authentication
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw_<key-id>_<secret>`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session:open`, `session:close`, `invoke:read`, `invoke:write`, `invoke:secure`, `events:read`, `metadata:read`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`.
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`; untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with no tag claims (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
## Process / Platform Notes
@@ -25,7 +25,7 @@
| TST-10 | Not started | **Confirmed open** | No `docs/Deployment.md` (`ls docs/` — no deploy doc); no deploy script under `scripts/` | Deploy knowledge still lives only in operator memory notes. |
| TST-14 | Not started | **Confirmed open** | Repo root still holds `MxAccessGateway-docs-{issues,fixed,final}.md`, `MxGatewayClient-docs-{issues,fixed}.md`, `oldtasks.md`, `stillpending.md` (root `ls`) | `oldtasks.md` did gain a legitimate new role as the epic-governance record (TST-04), so "delete when epic closes" needs re-deciding; the five untracked `*-docs-*.md` files remain pure clutter. |
| TST-15 | Not started (design done) | **Confirmed open** | `Server/Dashboard/Hubs/EventsHub.cs:42``TODO(per-session-acl)` still present | Design now exists: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Mitigation shipped meanwhile: SEC-25 value redaction in the hub mirror (see TST-27). |
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29``ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). |
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29``ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). **Closed 2026-08-17** (branch `feat/followup-closeout`): both residuals shipped — `AlarmsHubPublisher` redacts `current_value`/`limit_value` from a deep clone on both value-bearing `AlarmFeedMessage` arms before the `/hubs/alarms` broadcast, and `DashboardLiveDataService` substitutes `[redacted]` for `/browse` value text at the service boundary (the recommended seam), so the flag now gates every dashboard value surface. Source never mutated on either hub, so gRPC subscribers are unaffected. Tests: `AlarmsHubPublisherTests`, two `DashboardLiveDataServiceTests` cases. TST-16 → `Done` in [../remediation/00-tracking.md](../remediation/00-tracking.md#change-log). |
| TST-17 | Not started | **Confirmed open** | `Worker/MxAccess/WnWrapAlarmConsumer.cs:~264-275``_ = ackOperatorDomain; _ = ackOperatorFullName;` then 6-arg call; no diagnostic surfaced in the ack reply | Silent drop unchanged (comment block explains the 55 stub, but callers still can't see the degrade). |
| TST-18 | Not started | **Confirmed open** | No `*HostedService*` test files under `Tests/Gateway/Sessions/` or `Tests/Gateway/Workers/` | Sweep cores remain covered indirectly (`SessionManagerTests.cs:985,1015` now also cover faulted-reap). |
| TST-19 | Not started | **Confirmed open** | `Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs` — no lockstep note / `WorkerPipeSession` pointer (grep empty); no README in `Fakes/` | Risk is higher now that the real-worker suite has no scheduled run (TST-25). |
@@ -53,7 +53,7 @@
### TST-27 — `ShowTagValues` config doc still says "Reserved" after SEC-25 made the flag live — **Medium** · documentation currency
- **Files:** `docs/GatewayConfiguration.md:185` ("Reserved display control for tag values"); `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16-29` (flag read and enforced); `docs/GatewayDashboardDesign.md:170` (current, documents the redaction)
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.)
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.) **Closed 2026-08-17** — the config row was rewritten again when TST-16's residuals shipped: it now states the three seams the flag covers (events-hub mirror, alarms hub, `/browse`), so neither the "Reserved" wording nor the `/browse` carve-out survives. See the TST-16 row above.
- **Recommendation:** Update the `:185` row to describe the mirror-redaction behavior (and its security relevance given the missing per-session ACL, TST-15); note the `/browse` gap or close it when TST-16 is decided.
### TST-28 — Gateway-side `max_frame_bytes` handshake field has no test in the portable suite — **Low** · test coverage
+5 -2
View File
@@ -227,8 +227,8 @@ Full design + implementation for each row lives in the linked domain doc under i
| TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour |
| TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches |
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
| TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL |
| TST-16 | Medium | | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag |
| TST-15 | Medium | P2 | M | TST-04 | Done | Dashboard EventsHub has no per-session ACL |
| TST-16 | Medium | P1 | S | — | Done | `Dashboard:ShowTagValues` is a dead flag |
| TST-17 | Medium | — | S | — | Not started | Vendor-gated alarm parity residuals silently lossy |
| TST-18 | Low | — | S | — | Not started | Hosted-service wrappers untested |
| TST-19 | Low | — | S | — | Not started | Keep FakeWorkerHarness canned replies in lockstep |
@@ -253,6 +253,9 @@ Findings the review flagged as one coordinated design pass — sequence them tog
| Date | Change |
|---|---|
| 2026-08-17 | **TST-16 → `Done`: `Dashboard:ShowTagValues` now gates every dashboard value surface** (branch `feat/followup-closeout`). The flag was live on one seam only (the events-hub mirror, shipped with SEC-25); the two recorded residuals are closed, so it is no longer a partly-dead flag. **Alarms hub:** `AlarmsHubPublisher` took `IOptions<GatewayOptions>` and now clears `current_value`/`limit_value` from a **deep clone** of each `AlarmFeedMessage` on both value-bearing payload arms (`transition`, `active_alarm`) before the `/hubs/alarms` broadcast. Clone-only, matching `DashboardEventBroadcaster.RedactValues`: the source message fans out from one feed to gRPC `StreamAlarms` subscribers and the alarms page, none of which this dashboard-display flag governs. The redaction switches on the value-bearing arms and passes everything else through untouched and uncloned, so a payload arm added later (the `snapshot_status` arm landing alongside this change) is forwarded unbroken rather than silently dropped. **`/browse`:** the gate lives at the service boundary per TST-16's own recommendation — `DashboardLiveDataService` substitutes `DashboardTagValue.RedactedValueText` (`[redacted]`) for the formatted value text, so the value never enters the render tree and `BrowsePage.razor` needed no change; data type, quality, source timestamp, and read errors stay real, keeping the panel diagnostic. A visible marker, not a blank, so a suppressed value is distinguishable from a tag that read back empty. Tests: new `AlarmsHubPublisherTests` (4 — both arms redacted with metadata intact, source not mutated, `true` passes values through as the same instance, valueless arms forwarded as the same instance) and two `DashboardLiveDataServiceTests` cases (`false``[redacted]`, `true` → formatted); `DashboardEventBroadcasterTests` re-run as regression. Docs: `docs/GatewayConfiguration.md`'s `ShowTagValues` row rewritten from "gates only the events hub mirror, two still-open residuals" to the three seams it now covers, with the "no gRPC client is affected" scope statement; `docs/GatewayDashboardDesign.md` gained the redaction note on the `AlarmsHub` row and had its Browse-panel paragraph corrected (it claimed the panel "always shows values regardless of `Dashboard:ShowTagValues`"). |
| 2026-08-17 | **Alarm-snapshot truncation now has a structural degraded-status signal** (branch `feat/deferred-closeout`, commits `693a78d` + `b9fb0dd`). No review ID — this is branch work outside the 153-finding register, recorded here so the tracker is not silent on a shipped change to the alarm surface. Before it, a capped `GetXmlCurrentAlarms2` fetch suppressed absence-implies-Clear inference and said so only in a rate-limited worker stderr warning, so no client and no operator could tell a complete active set from a capped one. Two additive proto3 booleans carry the verdict out — `QueryActiveAlarmsReplyPayload.snapshot_truncated = 2` and `ActiveAlarmSnapshot.from_truncated_snapshot = 16` (per record, because `QueryActiveAlarms` is a bare `stream ActiveAlarmSnapshot` with no envelope; the reply payload states it too, since a prefix filter can leave zero records and a capped fetch with nothing to report still has to say so). Flow: `WnWrapAlarmConsumer``AlarmDispatcher` / `IAlarmCommandHandler``MxAccessCommandExecutor` reply → `GatewayAlarmMonitor``IGatewayAlarmService.SnapshotTruncated``AlarmsPage` banner. `b9fb0dd` then made the pairing structural after review: `IMxAccessAlarmConsumer` and `IAlarmCommandHandler` expose one accessor (`SnapshotActiveAlarms(out bool truncated)` / `QueryActive(..., out bool snapshotTruncated)`) satisfied from a single lock acquisition, so the snapshot and its verdict can no longer be read across a poll; the separate `LastSnapshotTruncated` property is gone from every layer. Detection is deliberately unchanged (`fetchedRecordCount >= maxAlarmsPerFetch`); switching to `ALARM_RECORDS/@COUNT` stays blocked on probe evidence (`docs/AlarmProbeFindings.md`). Not latched, and dropped with the cache generation by `ClearCache`. Additive gateway metadata about our fetch mechanics, not MXAccess behaviour — no synthesized event, so not a parity deviation. Docs: `gateway.md` alarm surface, `docs/DesignDecisions.md`. |
| 2026-08-17 | **TST-15 → `Done` (discharges the ACL half of SEC-25): per-session dashboard event ACL shipped** (branch `feat/deferred-closeout`, commits `693a78d` + `7ec0b35`). Implements `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, whose header is now `Implemented` with as-built notes in its §12. `IDashboardSessionAcl.CanViewSession` is the single decision **both** subscribe seams consult — `EventsHub.SubscribeSession` (denial is a `HubException`; the caller is neither joined to the group nor registered in `EventsHubViewerRegistry`, so the mirror stays off) and `SessionDetailsPage`'s in-process subscribe (inline denial, no subscription) — so neither path is the weaker one and the `TODO(per-session-acl)` is gone. Decision order, fail-closed on every branch: authenticated Administrator → allow (evaluated **before** the registry lookup, so Admin × unknown-session allows — pinned by a test because reordering the two checks is a plausible refactor); session not found → deny; untagged session → `Dashboard:UntaggedSessionVisibility` (`AdminOnly` default); else allow iff `session.Tags ∩ zb:dashboardtag` claims, ordinal-ignore-case. Session tags are inherited from the owning API key's `dashboard_tags` constraint (`apikey --dashboard-tags`, already in the `ApiKeyConstraints` JSON blob — no SQLite migration) and never from the client's wire request. Viewer grants come from `Dashboard:GroupToTag` over the user's LDAP groups, stamped at cookie login (`DashboardAuthenticator.CreatePrincipal`) and **re-resolved, not copied**, at hub-token mint (`HubTokenService.Issue`), so the 5-minute token lifetime bounds a stale grant. Anonymous localhost is an empty-grant Viewer; `Dashboard:DisableLogin` auto-login carries both roles and so takes the admin bypass unchanged. Tests: `DashboardSessionAclTests` (decision table, every branch asserted in its denying direction too), `EventsHubTests`, `DashboardAuthenticatorTests`, `HubTokenServiceTests`, a `GatewayOptionsTests` case proving `Dashboard:GroupToTag` keeps its ordinal-ignore-case lookup through configuration binding, and two `[LiveLdapFact]`s in `DashboardLdapLiveTests` that drive a real bind against the shared GLAuth (`gw-viewer``team-a` grant admits the `team-a` session and refuses the `team-b` one; `multi-role` bypasses on the sessions its own grant does not cover). The live pair needed **no GLAuth change** — the tag layer is config-side, keyed on the existing `GwAdmin`/`GwReader` groups (recorded in `glauth.md`). Docs: `docs/Sessions.md` (session-tag model), `gateway.md` + CLAUDE.md dashboard-auth paragraphs, `docs/GatewayDashboardDesign.md` (three passages that described the ACL as outstanding), `docs/GatewayConfiguration.md` (`ShowTagValues` row: redaction is now the second of two layers, not the only one), `docs/Authentication.md` (`--dashboard-tags` is the only *constraint* flag that splits on commas). |
| 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. |
| 2026-08-10 | **TST-24 → `Done`: per-client wire tests land for the two clients that lacked them** (branch `feat/tst-24-client-wire-tests`). Audit first corrected the finding's premise: **Go, Rust, and Java already had real-server wire tests**`newBufconnClient`/`fakeGatewayServer` over `grpc/test/bufconn`, `spawn_fake_gateway` over a loopback `TcpListener` with tonic's `Server`, and `InProcessGateway`/`TestGatewayService` over `InProcessServerBuilder` — each already asserting the round trip, the server-observed `authorization` bearer header, and the `ReplayGap` sentinel. The real gaps were **.NET** (every test substituted `FakeGatewayTransport` for `IMxGatewayClientTransport`, and the test project had no server package) and **Python** (stub monkeypatching everywhere except one opt-in TLS test serving only `OpenSession`). Added `WireFakeGatewayServer` + `MxGatewayClientWireTests` (Kestrel h2c on `127.0.0.1:0` serving `MxAccessGatewayBase`; new `Grpc.AspNetCore.Server` 2.76.0 + `Microsoft.AspNetCore.App` refs on the test project) and `clients/python/tests/test_wire_fake_gateway.py` (`grpc.aio` server on `127.0.0.1:0`, no new deps). Four shapes each: full round trip with every reply field asserted, the bearer header **as received by the server** on the streaming RPC too, the `ReplayGap` sentinel surfaced as the client's typed signal, and a genuine `PERMISSION_DENIED` mapping to the typed authorization error. CI: the `portable` job only *built* the .NET client, so a `dotnet test` step was added. **The new tests immediately caught a shipped bug** — Python `GatewayClient.connect()`/`GalaxyRepositoryClient.connect()` constructed the `grpc.aio` channel inside `asyncio.to_thread`, which raises `RuntimeError: There is no current event loop in thread 'asyncio_0'` because a `grpc.aio` channel binds to the loop current on the constructing thread; every non-stub connection failed, and the one test guarding the off-loop behaviour (Client.Python-028) monkeypatched `create_channel` and so asserted the bug. Fixed by splitting `resolve_channel_security` (blocking TOFU probe, off-loop) from `create_channel` (on-loop), with the `-028` tests retargeted to assert both halves. Verified: .NET 133 passed/1 skipped (pre-existing live-gateway skip), Python 168 passed/1 skipped plus 6/6 opt-in TLS. Docs: `docs/GatewayTesting.md` § Client Wire Tests, `clients/dotnet/README.md`, `clients/python/README.md`. |
| 2026-08-10 | **TST-05 revisited under the restored Windows tier → `Partially done`** (branch `feat/tst-24-client-wire-tests`, doc/tracker-only). The finding's **scheduling** half is closed: cycle-2 TST-25's `nightly-windev` job (cron `0 6 * * *`) runs `scripts/ci/run-windev-ci.sh live``windev-worker-ci.ps1 -Mode live`, which sets `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`, runs `WorkerLiveMxAccessSmokeTests` on windev after the x86 build/Worker.Tests/full-slnx steps, and files a Gitea issue when red. The **coverage-audit** half is *not* closed, and the audit the design asked for now has a negative answer: the suite's eight `[LiveMxAccessFact]`s cover all six late-added COM commands (`Suspend`, `Activate`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`) but zero of the five control commands — `MxCommandKind.{Ping,GetSessionState,GetWorkerInfo,DrainEvents,ShutdownWorker}` appear nowhere in `WorkerLiveMxAccessSmokeTests.cs`, so the exact paths the Finding calls masked are still only proven against `FakeWorkerHarness` canned replies while the real implementations live in `Worker/Ipc/WorkerPipeSession.cs`. Residual work (two `[LiveMxAccessFact]`s, windev-only to author and verify) is specified in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-05--real-worker-controlcom-paths-verified-opt-in-only---medium--p1). |
@@ -457,6 +457,8 @@ This document turns every finding in the Security/Dashboard/Observability review
- Tests: broadcaster test asserting values redacted when `ShowTagValues=false`.
- Docs: `docs/GatewayDashboardDesign.md` — clarify the current v1 posture.
**Update 2026-08-17 — the deferred half landed.** The scoping mechanism this finding waited on shipped as TST-15 (`693a78d` + `7ec0b35`): `IDashboardSessionAcl` gates `SubscribeSession` *and* the session-details page's in-process subscribe, so the `TODO(per-session-acl)` is gone and the redaction is no longer the only thing between a low-trust Viewer and another session's events. See the TST-15 section in [60-testing-docs-gaps.md](60-testing-docs-gaps.md#tst-15--dashboard-eventshub-has-no-per-session-acl) and the 2026-08-17 change-log row in [00-tracking.md](00-tracking.md#change-log). Redaction stays — the two layers are independent: the ACL decides who may subscribe, `ShowTagValues` decides what a permitted subscriber sees.
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server` and the events-hub/broadcaster test filter.
---
@@ -335,11 +335,11 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
**Impact.** Acceptable for a single-tenant dashboard; wrong the moment `GroupToRole` admits low-trust viewers. It is the dashboard-side twin of the gRPC owner-revalidation gap (TST-02).
**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 1619, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there. Until Phase 4 lands, keep the TODO (it correctly documents the accepted single-tenant assumption); do not silently remove it.
**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 1619, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there.
**Implementation.** `Dashboard/Hubs/EventsHub.cs` (ACL check on group join), hub-token minting to carry the session tag, `Configuration/DashboardOptions.cs` for any group-to-tag config (Task 17). Tests: `...Tests/Gateway/Dashboard/` hub ACL cases incl. live-LDAP users (Task 19). Docs: `docs/Sessions.md`/`gateway.md` dashboard section document the ACL model; CLAUDE.md dashboard-auth paragraph.
**Implementation.** Shipped 2026-08-17 on `feat/deferred-closeout` (`693a78d` + `7ec0b35`); the `TODO(per-session-acl)` is gone. `Dashboard/IDashboardSessionAcl.cs` + `Dashboard/DashboardSessionAcl.cs` hold the single decision, consulted by `Dashboard/Hubs/EventsHub.cs` (`SubscribeSession``HubException` on denial, no group join and no viewer registration) and by `Dashboard/Components/Pages/SessionDetailsPage.razor`'s in-process subscribe — the design's one correction, since the page path was not a hub client and would otherwise have been the unguarded seam. Tags ride from the owning key via `ISessionManager.OpenSessionAsync`'s tagged overload into the immutable `GatewaySession.Tags`; grants are stamped by `DashboardAuthenticator.CreatePrincipal` and re-resolved at `HubTokenService.Issue`. Config: `Dashboard:GroupToTag` and `Dashboard:UntaggedSessionVisibility` on `Configuration/DashboardOptions.cs`. Tests: `Tests/Gateway/Dashboard/DashboardSessionAclTests.cs`, `EventsHubTests.cs`, a `Configuration/GatewayOptionsTests.cs` binding case for the `GroupToTag` comparer, and two `[LiveLdapFact]`s in `IntegrationTests/DashboardLdapLiveTests.cs`. Docs: `docs/Sessions.md`, `gateway.md`, CLAUDE.md, `docs/GatewayDashboardDesign.md`, `docs/GatewayConfiguration.md`, `glauth.md`.
**Verification.** `dotnet test ... --filter FullyQualifiedName~EventsHub`; `dotnet build src/ZB.MOM.WW.MxGateway.Server`.
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`; `dotnet test ... --filter FullyQualifiedName~DashboardSessionAclTests`, `~EventsHubTests`, `~GatewayOptionsTests`; live-LDAP pair run green against the shared GLAuth with `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` (and skipping cleanly without it).
---
@@ -355,6 +355,8 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
**Verification.** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; test toggling the flag suppresses/shows values; docs match.
**Closed 2026-08-18 — implemented (the Design's recommended path).** The flag is no longer dead: it now gates value display on all three seams that carry a tag value. `Dashboard/DashboardLiveDataService.cs` substitutes `[redacted]` at the service boundary for each successfully read `/browse` tag (a failed read keeps its `-` placeholder, so the error row and the redaction cannot contradict each other); `Dashboard/Hubs/AlarmsHubPublisher.cs` clears `current_value`/`limit_value` from a **deep-cloned** `AlarmFeedMessage` before broadcasting to `/hubs/alarms`, and `DashboardEventBroadcaster` does the same for the events-hub mirror — clones, because the sources fan out to gRPC `StreamAlarms`/`StreamEvents` and the replay ring, so no gRPC client is affected by this dashboard-display flag. The flag is **kept**, default still `false`. The stale prose the Finding flagged is gone: `docs/GatewayConfiguration.md` and `docs/GatewayDashboardDesign.md` now describe the real behaviour. Status tracked in `00-tracking.md` (already **Done**).
---
## TST-17 — Vendor-gated alarm parity residuals silently lossy `Medium` · `—`
+17
View File
@@ -149,6 +149,23 @@ 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.
`ActiveAlarmSnapshot.FromTruncatedSnapshot` marks a record that came from a
provider fetch which hit the per-fetch cap: the snapshot set may omit active
alarms, and the gateway suspended its absence-implies-cleared inference for that
poll. Treat the set as possibly incomplete rather than reconciling deletions
from it. It is set-level degraded status, not a comment on the record's own
fidelity, and is distinct from `Degraded` (the subtag fallback provider).
`StreamAlarmsAsync` also carries that completeness verdict at feed level, as an
`AlarmFeedMessage.PayloadOneofCase.SnapshotStatus` frame whose
`SnapshotStatus.Truncated` is true while the monitor's cached set derives from a
truncated fetch. One arrives at stream open (after the `ProviderStatus` frame,
before the cached `ActiveAlarm` frames) so a late joiner learns the current
verdict, then one on every verdict change — including the clearing frame sent
when the gateway's alarm monitor restarts and drops a truncated verdict. Track
it if you need set completeness on a live feed without polling
`QueryActiveAlarmsAsync`.
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
the first `CloseSessionReply` instead of sending another close request.
@@ -1546,8 +1546,9 @@ public static class MxGatewayClientCli
/// <summary>
/// Renders one <see cref="AlarmFeedMessage"/> for the human-readable
/// (non-JSON) stream-alarms output, distinguishing the <c>payload</c> oneof
/// arms: a snapshot active alarm, the snapshot-complete sentinel, or a live
/// transition.
/// arms: a snapshot active alarm, the snapshot-complete sentinel, a live
/// transition, the provider-mode status, or the feed-level
/// snapshot-completeness status.
/// </summary>
private static string FormatAlarmFeedMessage(AlarmFeedMessage feedMessage)
{
@@ -1559,6 +1560,10 @@ public static class MxGatewayClientCli
$"snapshot-complete {feedMessage.SnapshotComplete}",
AlarmFeedMessage.PayloadOneofCase.Transition =>
$"transition {ProtobufJsonFormatter.Format(feedMessage.Transition)}",
AlarmFeedMessage.PayloadOneofCase.ProviderStatus =>
$"provider-status {ProtobufJsonFormatter.Format(feedMessage.ProviderStatus)}",
AlarmFeedMessage.PayloadOneofCase.SnapshotStatus =>
$"snapshot-status {ProtobufJsonFormatter.Format(feedMessage.SnapshotStatus)}",
_ => $"unknown-payload {feedMessage.PayloadCase}",
};
}
+17
View File
@@ -145,6 +145,23 @@ call returns a `StreamAlarmsClient`; cancel its context to terminate the
stream. All three pass straight through to the gateway's central alarm
monitor.
`ActiveAlarmSnapshot.GetFromTruncatedSnapshot()` reports that the record came
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
active alarms, and the gateway suspended its absence-implies-cleared inference
for that poll. Treat the set as possibly incomplete rather than reconciling
deletions from it. It is set-level degraded status, not a comment on the
record's own fidelity, and is distinct from `Degraded` (the subtag fallback
provider).
`StreamAlarms` also carries that completeness verdict at feed level, as a frame
whose `GetSnapshotStatus()` is non-nil and whose `GetTruncated()` is true while
the monitor's cached set derives from a truncated fetch. One arrives at stream
open (after the `GetProviderStatus()` frame, before the cached
`GetActiveAlarm()` frames) so a late joiner learns the current verdict, then one
on every verdict change — including the clearing frame sent when the gateway's
alarm monitor restarts and drops a truncated verdict. Track it if you need set
completeness on a live feed without polling `QueryActiveAlarms`.
## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway
+7 -1
View File
@@ -1097,7 +1097,8 @@ func runStreamAlarms(ctx context.Context, args []string, stdout, stderr io.Write
// formatAlarmFeedMessage renders one AlarmFeedMessage in the CLI's plain-text
// output style, distinguishing the active-alarm snapshot, snapshot-complete
// sentinel, and transition cases of the message's payload oneof.
// sentinel, transition, provider-status, and snapshot-status cases of the
// message's payload oneof.
func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
switch {
case message.GetActiveAlarm() != nil:
@@ -1108,6 +1109,11 @@ func formatAlarmFeedMessage(message *mxgateway.AlarmFeedMessage) string {
case message.GetTransition() != nil:
transition := message.GetTransition()
return fmt.Sprintf("transition %s kind=%s severity=%d", transition.GetAlarmFullReference(), transition.GetTransitionKind(), transition.GetSeverity())
case message.GetProviderStatus() != nil:
status := message.GetProviderStatus()
return fmt.Sprintf("provider-status mode=%s degraded=%t reason=%q", status.GetMode(), status.GetDegraded(), status.GetReason())
case message.GetSnapshotStatus() != nil:
return fmt.Sprintf("snapshot-status truncated=%t", message.GetSnapshotStatus().GetTruncated())
default:
return "unknown"
}
@@ -6103,10 +6103,17 @@ func (x *AcknowledgeAlarmReplyPayload) GetNativeStatus() int32 {
// an ActiveAlarmSnapshot proto for the gateway-side ConditionRefresh
// stream.
type QueryActiveAlarmsReplyPayload struct {
state protoimpl.MessageState `protogen:"open.v1"`
Snapshots []*ActiveAlarmSnapshot `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Snapshots []*ActiveAlarmSnapshot `protobuf:"bytes,1,rep,name=snapshots,proto3" json:"snapshots,omitempty"`
// True when the provider fetch backing this reply came back holding the
// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
// active alarms, and the worker suspends its absence-implies-Clear inference
// for that poll — so a reference missing from `snapshots` is not evidence the
// alarm cleared. Carried on the payload as well as per-record because a
// truncated fetch that filters down to zero records still has to say so.
SnapshotTruncated bool `protobuf:"varint,2,opt,name=snapshot_truncated,json=snapshotTruncated,proto3" json:"snapshot_truncated,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *QueryActiveAlarmsReplyPayload) Reset() {
@@ -6146,6 +6153,13 @@ func (x *QueryActiveAlarmsReplyPayload) GetSnapshots() []*ActiveAlarmSnapshot {
return nil
}
func (x *QueryActiveAlarmsReplyPayload) GetSnapshotTruncated() bool {
if x != nil {
return x.SnapshotTruncated
}
return false
}
type MxEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
Family MxEventFamily `protobuf:"varint,1,opt,name=family,proto3,enum=mxaccess_gateway.v1.MxEventFamily" json:"family,omitempty"`
@@ -6958,8 +6972,18 @@ type ActiveAlarmSnapshot struct {
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
// wire (never UNSPECIFIED).
SourceProvider AlarmProviderMode `protobuf:"varint,15,opt,name=source_provider,json=sourceProvider,proto3,enum=mxaccess_gateway.v1.AlarmProviderMode" json:"source_provider,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// True when the provider fetch that produced this snapshot hit the per-fetch
// cap: the snapshot set may omit active alarms, and the worker suspended its
// absence-implies-Clear inference for that poll. Says nothing about THIS
// record's fidelity — the record is as accurate as any other; it flags that
// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
// boolean is the only additive way to carry set-level degraded status on that
// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
// Additive (proto3): clients that ignore it deserialize the stream unchanged.
FromTruncatedSnapshot bool `protobuf:"varint,16,opt,name=from_truncated_snapshot,json=fromTruncatedSnapshot,proto3" json:"from_truncated_snapshot,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActiveAlarmSnapshot) Reset() {
@@ -7097,6 +7121,13 @@ func (x *ActiveAlarmSnapshot) GetSourceProvider() AlarmProviderMode {
return AlarmProviderMode_ALARM_PROVIDER_MODE_UNSPECIFIED
}
func (x *ActiveAlarmSnapshot) GetFromTruncatedSnapshot() bool {
if x != nil {
return x.FromTruncatedSnapshot
}
return false
}
type AcknowledgeAlarmRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientCorrelationId string `protobuf:"bytes,2,opt,name=client_correlation_id,json=clientCorrelationId,proto3" json:"client_correlation_id,omitempty"`
@@ -7311,9 +7342,13 @@ func (x *StreamAlarmsRequest) GetAlarmFilterPrefix() string {
return ""
}
// One message on the StreamAlarms feed. The stream opens with one
// `active_alarm` per currently-active alarm, then a single
// `snapshot_complete`, then a `transition` for every subsequent change.
// One message on the StreamAlarms feed. The stream opens with a
// `provider_status` (the current provider mode), then a `snapshot_status`
// (whether the cached active set may be incomplete), then one `active_alarm`
// per currently-active alarm, then a single `snapshot_complete`. After that
// come the live frames: a `transition` for every subsequent alarm change,
// interleaved with a further `provider_status` on each failover/failback and a
// further `snapshot_status` on each change of the truncation verdict.
type AlarmFeedMessage struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Payload:
@@ -7322,6 +7357,7 @@ type AlarmFeedMessage struct {
// *AlarmFeedMessage_SnapshotComplete
// *AlarmFeedMessage_Transition
// *AlarmFeedMessage_ProviderStatus
// *AlarmFeedMessage_SnapshotStatus
Payload isAlarmFeedMessage_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -7400,6 +7436,15 @@ func (x *AlarmFeedMessage) GetProviderStatus() *AlarmProviderStatus {
return nil
}
func (x *AlarmFeedMessage) GetSnapshotStatus() *AlarmSnapshotStatus {
if x != nil {
if x, ok := x.Payload.(*AlarmFeedMessage_SnapshotStatus); ok {
return x.SnapshotStatus
}
}
return nil
}
type isAlarmFeedMessage_Payload interface {
isAlarmFeedMessage_Payload()
}
@@ -7426,6 +7471,13 @@ type AlarmFeedMessage_ProviderStatus struct {
ProviderStatus *AlarmProviderStatus `protobuf:"bytes,4,opt,name=provider_status,json=providerStatus,proto3,oneof"`
}
type AlarmFeedMessage_SnapshotStatus struct {
// Snapshot-completeness status. Emitted once on stream open and again on
// every change of the truncation verdict, so late joiners learn whether the
// feed's active-alarm set may be incomplete.
SnapshotStatus *AlarmSnapshotStatus `protobuf:"bytes,5,opt,name=snapshot_status,json=snapshotStatus,proto3,oneof"`
}
func (*AlarmFeedMessage_ActiveAlarm) isAlarmFeedMessage_Payload() {}
func (*AlarmFeedMessage_SnapshotComplete) isAlarmFeedMessage_Payload() {}
@@ -7434,6 +7486,8 @@ func (*AlarmFeedMessage_Transition) isAlarmFeedMessage_Payload() {}
func (*AlarmFeedMessage_ProviderStatus) isAlarmFeedMessage_Payload() {}
func (*AlarmFeedMessage_SnapshotStatus) isAlarmFeedMessage_Payload() {}
type AlarmProviderStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mode AlarmProviderMode `protobuf:"varint,1,opt,name=mode,proto3,enum=mxaccess_gateway.v1.AlarmProviderMode" json:"mode,omitempty"`
@@ -7502,6 +7556,63 @@ func (x *AlarmProviderStatus) GetSince() *timestamppb.Timestamp {
return nil
}
// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
// (after the initial provider_status frame, before the cached active_alarm
// frames) so late joiners learn the current verdict, and again on every change
// of the truncation verdict — when a reconcile reports a different verdict, and
// when the gateway's alarm monitor restarts and drops a truncated verdict with
// the cache generation it described (feed subscribers outlive that monitor
// session, so they are sent the clearing frame). Mirrors the per-record
// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
// consumers can reason about completeness without polling QueryActiveAlarms.
type AlarmSnapshotStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
// True while the monitor's cached active-alarm set derives from a truncated
// (capped) worker fetch — the set may be missing alarms. Distinct from
// provider degradation (AlarmProviderStatus.degraded), which describes the
// fidelity of the records rather than the completeness of the set.
Truncated bool `protobuf:"varint,1,opt,name=truncated,proto3" json:"truncated,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AlarmSnapshotStatus) Reset() {
*x = AlarmSnapshotStatus{}
mi := &file_mxaccess_gateway_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AlarmSnapshotStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AlarmSnapshotStatus) ProtoMessage() {}
func (x *AlarmSnapshotStatus) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AlarmSnapshotStatus.ProtoReflect.Descriptor instead.
func (*AlarmSnapshotStatus) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87}
}
func (x *AlarmSnapshotStatus) GetTruncated() bool {
if x != nil {
return x.Truncated
}
return false
}
type MxStatusProxy struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
@@ -7526,7 +7637,7 @@ type MxStatusProxy struct {
func (x *MxStatusProxy) Reset() {
*x = MxStatusProxy{}
mi := &file_mxaccess_gateway_proto_msgTypes[87]
mi := &file_mxaccess_gateway_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -7538,7 +7649,7 @@ func (x *MxStatusProxy) String() string {
func (*MxStatusProxy) ProtoMessage() {}
func (x *MxStatusProxy) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[87]
mi := &file_mxaccess_gateway_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7551,7 +7662,7 @@ func (x *MxStatusProxy) ProtoReflect() protoreflect.Message {
// Deprecated: Use MxStatusProxy.ProtoReflect.Descriptor instead.
func (*MxStatusProxy) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{87}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88}
}
func (x *MxStatusProxy) GetSuccess() int32 {
@@ -7629,7 +7740,7 @@ type MxValue struct {
func (x *MxValue) Reset() {
*x = MxValue{}
mi := &file_mxaccess_gateway_proto_msgTypes[88]
mi := &file_mxaccess_gateway_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -7641,7 +7752,7 @@ func (x *MxValue) String() string {
func (*MxValue) ProtoMessage() {}
func (x *MxValue) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[88]
mi := &file_mxaccess_gateway_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7654,7 +7765,7 @@ func (x *MxValue) ProtoReflect() protoreflect.Message {
// Deprecated: Use MxValue.ProtoReflect.Descriptor instead.
func (*MxValue) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{88}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89}
}
func (x *MxValue) GetDataType() MxDataType {
@@ -7877,7 +7988,7 @@ type MxArray struct {
func (x *MxArray) Reset() {
*x = MxArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[89]
mi := &file_mxaccess_gateway_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -7889,7 +8000,7 @@ func (x *MxArray) String() string {
func (*MxArray) ProtoMessage() {}
func (x *MxArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[89]
mi := &file_mxaccess_gateway_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -7902,7 +8013,7 @@ func (x *MxArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use MxArray.ProtoReflect.Descriptor instead.
func (*MxArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{89}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90}
}
func (x *MxArray) GetElementDataType() MxDataType {
@@ -8086,7 +8197,7 @@ type MxSparseArray struct {
func (x *MxSparseArray) Reset() {
*x = MxSparseArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[90]
mi := &file_mxaccess_gateway_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8098,7 +8209,7 @@ func (x *MxSparseArray) String() string {
func (*MxSparseArray) ProtoMessage() {}
func (x *MxSparseArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[90]
mi := &file_mxaccess_gateway_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8111,7 +8222,7 @@ func (x *MxSparseArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use MxSparseArray.ProtoReflect.Descriptor instead.
func (*MxSparseArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{90}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91}
}
func (x *MxSparseArray) GetElementDataType() MxDataType {
@@ -8145,7 +8256,7 @@ type MxSparseElement struct {
func (x *MxSparseElement) Reset() {
*x = MxSparseElement{}
mi := &file_mxaccess_gateway_proto_msgTypes[91]
mi := &file_mxaccess_gateway_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8157,7 +8268,7 @@ func (x *MxSparseElement) String() string {
func (*MxSparseElement) ProtoMessage() {}
func (x *MxSparseElement) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[91]
mi := &file_mxaccess_gateway_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8170,7 +8281,7 @@ func (x *MxSparseElement) ProtoReflect() protoreflect.Message {
// Deprecated: Use MxSparseElement.ProtoReflect.Descriptor instead.
func (*MxSparseElement) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{91}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{92}
}
func (x *MxSparseElement) GetIndex() uint32 {
@@ -8196,7 +8307,7 @@ type BoolArray struct {
func (x *BoolArray) Reset() {
*x = BoolArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[92]
mi := &file_mxaccess_gateway_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8208,7 +8319,7 @@ func (x *BoolArray) String() string {
func (*BoolArray) ProtoMessage() {}
func (x *BoolArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[92]
mi := &file_mxaccess_gateway_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8221,7 +8332,7 @@ func (x *BoolArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use BoolArray.ProtoReflect.Descriptor instead.
func (*BoolArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{92}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{93}
}
func (x *BoolArray) GetValues() []bool {
@@ -8240,7 +8351,7 @@ type Int32Array struct {
func (x *Int32Array) Reset() {
*x = Int32Array{}
mi := &file_mxaccess_gateway_proto_msgTypes[93]
mi := &file_mxaccess_gateway_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8252,7 +8363,7 @@ func (x *Int32Array) String() string {
func (*Int32Array) ProtoMessage() {}
func (x *Int32Array) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[93]
mi := &file_mxaccess_gateway_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8265,7 +8376,7 @@ func (x *Int32Array) ProtoReflect() protoreflect.Message {
// Deprecated: Use Int32Array.ProtoReflect.Descriptor instead.
func (*Int32Array) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{93}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{94}
}
func (x *Int32Array) GetValues() []int32 {
@@ -8284,7 +8395,7 @@ type Int64Array struct {
func (x *Int64Array) Reset() {
*x = Int64Array{}
mi := &file_mxaccess_gateway_proto_msgTypes[94]
mi := &file_mxaccess_gateway_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8296,7 +8407,7 @@ func (x *Int64Array) String() string {
func (*Int64Array) ProtoMessage() {}
func (x *Int64Array) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[94]
mi := &file_mxaccess_gateway_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8309,7 +8420,7 @@ func (x *Int64Array) ProtoReflect() protoreflect.Message {
// Deprecated: Use Int64Array.ProtoReflect.Descriptor instead.
func (*Int64Array) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{94}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{95}
}
func (x *Int64Array) GetValues() []int64 {
@@ -8328,7 +8439,7 @@ type FloatArray struct {
func (x *FloatArray) Reset() {
*x = FloatArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[95]
mi := &file_mxaccess_gateway_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8340,7 +8451,7 @@ func (x *FloatArray) String() string {
func (*FloatArray) ProtoMessage() {}
func (x *FloatArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[95]
mi := &file_mxaccess_gateway_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8353,7 +8464,7 @@ func (x *FloatArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use FloatArray.ProtoReflect.Descriptor instead.
func (*FloatArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{95}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{96}
}
func (x *FloatArray) GetValues() []float32 {
@@ -8372,7 +8483,7 @@ type DoubleArray struct {
func (x *DoubleArray) Reset() {
*x = DoubleArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[96]
mi := &file_mxaccess_gateway_proto_msgTypes[97]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8384,7 +8495,7 @@ func (x *DoubleArray) String() string {
func (*DoubleArray) ProtoMessage() {}
func (x *DoubleArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[96]
mi := &file_mxaccess_gateway_proto_msgTypes[97]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8397,7 +8508,7 @@ func (x *DoubleArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use DoubleArray.ProtoReflect.Descriptor instead.
func (*DoubleArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{96}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{97}
}
func (x *DoubleArray) GetValues() []float64 {
@@ -8416,7 +8527,7 @@ type StringArray struct {
func (x *StringArray) Reset() {
*x = StringArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[97]
mi := &file_mxaccess_gateway_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8428,7 +8539,7 @@ func (x *StringArray) String() string {
func (*StringArray) ProtoMessage() {}
func (x *StringArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[97]
mi := &file_mxaccess_gateway_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8441,7 +8552,7 @@ func (x *StringArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use StringArray.ProtoReflect.Descriptor instead.
func (*StringArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{97}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{98}
}
func (x *StringArray) GetValues() []string {
@@ -8460,7 +8571,7 @@ type TimestampArray struct {
func (x *TimestampArray) Reset() {
*x = TimestampArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[98]
mi := &file_mxaccess_gateway_proto_msgTypes[99]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8472,7 +8583,7 @@ func (x *TimestampArray) String() string {
func (*TimestampArray) ProtoMessage() {}
func (x *TimestampArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[98]
mi := &file_mxaccess_gateway_proto_msgTypes[99]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8485,7 +8596,7 @@ func (x *TimestampArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use TimestampArray.ProtoReflect.Descriptor instead.
func (*TimestampArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{98}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{99}
}
func (x *TimestampArray) GetValues() []*timestamppb.Timestamp {
@@ -8504,7 +8615,7 @@ type RawArray struct {
func (x *RawArray) Reset() {
*x = RawArray{}
mi := &file_mxaccess_gateway_proto_msgTypes[99]
mi := &file_mxaccess_gateway_proto_msgTypes[100]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8516,7 +8627,7 @@ func (x *RawArray) String() string {
func (*RawArray) ProtoMessage() {}
func (x *RawArray) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[99]
mi := &file_mxaccess_gateway_proto_msgTypes[100]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8529,7 +8640,7 @@ func (x *RawArray) ProtoReflect() protoreflect.Message {
// Deprecated: Use RawArray.ProtoReflect.Descriptor instead.
func (*RawArray) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{99}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{100}
}
func (x *RawArray) GetValues() [][]byte {
@@ -8549,7 +8660,7 @@ type ProtocolStatus struct {
func (x *ProtocolStatus) Reset() {
*x = ProtocolStatus{}
mi := &file_mxaccess_gateway_proto_msgTypes[100]
mi := &file_mxaccess_gateway_proto_msgTypes[101]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -8561,7 +8672,7 @@ func (x *ProtocolStatus) String() string {
func (*ProtocolStatus) ProtoMessage() {}
func (x *ProtocolStatus) ProtoReflect() protoreflect.Message {
mi := &file_mxaccess_gateway_proto_msgTypes[100]
mi := &file_mxaccess_gateway_proto_msgTypes[101]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -8574,7 +8685,7 @@ func (x *ProtocolStatus) ProtoReflect() protoreflect.Message {
// Deprecated: Use ProtocolStatus.ProtoReflect.Descriptor instead.
func (*ProtocolStatus) Descriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{100}
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{101}
}
func (x *ProtocolStatus) GetCode() ProtocolStatusCode {
@@ -8979,9 +9090,10 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\x10DrainEventsReply\x124\n" +
"\x06events\x18\x01 \x03(\v2\x1c.mxaccess_gateway.v1.MxEventR\x06events\"C\n" +
"\x1cAcknowledgeAlarmReplyPayload\x12#\n" +
"\rnative_status\x18\x01 \x01(\x05R\fnativeStatus\"g\n" +
"\rnative_status\x18\x01 \x01(\x05R\fnativeStatus\"\x96\x01\n" +
"\x1dQueryActiveAlarmsReplyPayload\x12F\n" +
"\tsnapshots\x18\x01 \x03(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotR\tsnapshots\"\xb7\n" +
"\tsnapshots\x18\x01 \x03(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotR\tsnapshots\x12-\n" +
"\x12snapshot_truncated\x18\x02 \x01(\bR\x11snapshotTruncated\"\xb7\n" +
"\n" +
"\aMxEvent\x12:\n" +
"\x06family\x18\x01 \x01(\x0e2\".mxaccess_gateway.v1.MxEventFamilyR\x06family\x12\x1d\n" +
@@ -9046,7 +9158,7 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\x04mode\x18\x01 \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x04mode\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x18\n" +
"\ahresult\x18\x03 \x01(\x05R\ahresult\x12*\n" +
"\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xbd\x06\n" +
"\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"\xf5\x06\n" +
"\x13ActiveAlarmSnapshot\x120\n" +
"\x14alarm_full_reference\x18\x01 \x01(\tR\x12alarmFullReference\x126\n" +
"\x17source_object_reference\x18\x02 \x01(\tR\x15sourceObjectReference\x12&\n" +
@@ -9064,7 +9176,8 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"\vlimit_value\x18\r \x01(\v2\x1c.mxaccess_gateway.v1.MxValueR\n" +
"limitValue\x12\x1a\n" +
"\bdegraded\x18\x0e \x01(\bR\bdegraded\x12O\n" +
"\x0fsource_provider\x18\x0f \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x0esourceProvider\"\xd0\x01\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" +
"\x17AcknowledgeAlarmRequest\x122\n" +
"\x15client_correlation_id\x18\x02 \x01(\tR\x13clientCorrelationId\x120\n" +
"\x14alarm_full_reference\x18\x03 \x01(\tR\x12alarmFullReference\x12\x18\n" +
@@ -9082,20 +9195,23 @@ const file_mxaccess_gateway_proto_rawDesc = "" +
"session_id\"y\n" +
"\x13StreamAlarmsRequest\x122\n" +
"\x15client_correlation_id\x18\x01 \x01(\tR\x13clientCorrelationId\x12.\n" +
"\x13alarm_filter_prefix\x18\x02 \x01(\tR\x11alarmFilterPrefix\"\xbf\x02\n" +
"\x13alarm_filter_prefix\x18\x02 \x01(\tR\x11alarmFilterPrefix\"\x94\x03\n" +
"\x10AlarmFeedMessage\x12M\n" +
"\factive_alarm\x18\x01 \x01(\v2(.mxaccess_gateway.v1.ActiveAlarmSnapshotH\x00R\vactiveAlarm\x12-\n" +
"\x11snapshot_complete\x18\x02 \x01(\bH\x00R\x10snapshotComplete\x12M\n" +
"\n" +
"transition\x18\x03 \x01(\v2+.mxaccess_gateway.v1.OnAlarmTransitionEventH\x00R\n" +
"transition\x12S\n" +
"\x0fprovider_status\x18\x04 \x01(\v2(.mxaccess_gateway.v1.AlarmProviderStatusH\x00R\x0eproviderStatusB\t\n" +
"\x0fprovider_status\x18\x04 \x01(\v2(.mxaccess_gateway.v1.AlarmProviderStatusH\x00R\x0eproviderStatus\x12S\n" +
"\x0fsnapshot_status\x18\x05 \x01(\v2(.mxaccess_gateway.v1.AlarmSnapshotStatusH\x00R\x0esnapshotStatusB\t\n" +
"\apayload\"\xb7\x01\n" +
"\x13AlarmProviderStatus\x12:\n" +
"\x04mode\x18\x01 \x01(\x0e2&.mxaccess_gateway.v1.AlarmProviderModeR\x04mode\x12\x1a\n" +
"\bdegraded\x18\x02 \x01(\bR\bdegraded\x12\x16\n" +
"\x06reason\x18\x03 \x01(\tR\x06reason\x120\n" +
"\x05since\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\"\xbe\x02\n" +
"\x05since\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x05since\"3\n" +
"\x13AlarmSnapshotStatus\x12\x1c\n" +
"\ttruncated\x18\x01 \x01(\bR\ttruncated\"\xbe\x02\n" +
"\rMxStatusProxy\x12\x18\n" +
"\asuccess\x18\x01 \x01(\x05R\asuccess\x12A\n" +
"\bcategory\x18\x02 \x01(\x0e2%.mxaccess_gateway.v1.MxStatusCategoryR\bcategory\x12D\n" +
@@ -9331,7 +9447,7 @@ func file_mxaccess_gateway_proto_rawDescGZIP() []byte {
}
var file_mxaccess_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 10)
var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 101)
var file_mxaccess_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 102)
var file_mxaccess_gateway_proto_goTypes = []any{
(MxCommandKind)(0), // 0: mxaccess_gateway.v1.MxCommandKind
(AlarmProviderMode)(0), // 1: mxaccess_gateway.v1.AlarmProviderMode
@@ -9430,29 +9546,30 @@ var file_mxaccess_gateway_proto_goTypes = []any{
(*StreamAlarmsRequest)(nil), // 94: mxaccess_gateway.v1.StreamAlarmsRequest
(*AlarmFeedMessage)(nil), // 95: mxaccess_gateway.v1.AlarmFeedMessage
(*AlarmProviderStatus)(nil), // 96: mxaccess_gateway.v1.AlarmProviderStatus
(*MxStatusProxy)(nil), // 97: mxaccess_gateway.v1.MxStatusProxy
(*MxValue)(nil), // 98: mxaccess_gateway.v1.MxValue
(*MxArray)(nil), // 99: mxaccess_gateway.v1.MxArray
(*MxSparseArray)(nil), // 100: mxaccess_gateway.v1.MxSparseArray
(*MxSparseElement)(nil), // 101: mxaccess_gateway.v1.MxSparseElement
(*BoolArray)(nil), // 102: mxaccess_gateway.v1.BoolArray
(*Int32Array)(nil), // 103: mxaccess_gateway.v1.Int32Array
(*Int64Array)(nil), // 104: mxaccess_gateway.v1.Int64Array
(*FloatArray)(nil), // 105: mxaccess_gateway.v1.FloatArray
(*DoubleArray)(nil), // 106: mxaccess_gateway.v1.DoubleArray
(*StringArray)(nil), // 107: mxaccess_gateway.v1.StringArray
(*TimestampArray)(nil), // 108: mxaccess_gateway.v1.TimestampArray
(*RawArray)(nil), // 109: mxaccess_gateway.v1.RawArray
(*ProtocolStatus)(nil), // 110: mxaccess_gateway.v1.ProtocolStatus
(*durationpb.Duration)(nil), // 111: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 112: google.protobuf.Timestamp
(*AlarmSnapshotStatus)(nil), // 97: mxaccess_gateway.v1.AlarmSnapshotStatus
(*MxStatusProxy)(nil), // 98: mxaccess_gateway.v1.MxStatusProxy
(*MxValue)(nil), // 99: mxaccess_gateway.v1.MxValue
(*MxArray)(nil), // 100: mxaccess_gateway.v1.MxArray
(*MxSparseArray)(nil), // 101: mxaccess_gateway.v1.MxSparseArray
(*MxSparseElement)(nil), // 102: mxaccess_gateway.v1.MxSparseElement
(*BoolArray)(nil), // 103: mxaccess_gateway.v1.BoolArray
(*Int32Array)(nil), // 104: mxaccess_gateway.v1.Int32Array
(*Int64Array)(nil), // 105: mxaccess_gateway.v1.Int64Array
(*FloatArray)(nil), // 106: mxaccess_gateway.v1.FloatArray
(*DoubleArray)(nil), // 107: mxaccess_gateway.v1.DoubleArray
(*StringArray)(nil), // 108: mxaccess_gateway.v1.StringArray
(*TimestampArray)(nil), // 109: mxaccess_gateway.v1.TimestampArray
(*RawArray)(nil), // 110: mxaccess_gateway.v1.RawArray
(*ProtocolStatus)(nil), // 111: mxaccess_gateway.v1.ProtocolStatus
(*durationpb.Duration)(nil), // 112: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 113: google.protobuf.Timestamp
}
var file_mxaccess_gateway_proto_depIdxs = []int32{
111, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration
111, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration
110, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
112, // 0: mxaccess_gateway.v1.OpenSessionRequest.command_timeout:type_name -> google.protobuf.Duration
112, // 1: mxaccess_gateway.v1.OpenSessionReply.default_command_timeout:type_name -> google.protobuf.Duration
111, // 2: mxaccess_gateway.v1.OpenSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
9, // 3: mxaccess_gateway.v1.CloseSessionReply.final_state:type_name -> mxaccess_gateway.v1.SessionState
110, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
111, // 4: mxaccess_gateway.v1.CloseSessionReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
17, // 5: mxaccess_gateway.v1.MxCommandRequest.command:type_name -> mxaccess_gateway.v1.MxCommand
0, // 6: mxaccess_gateway.v1.MxCommand.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
18, // 7: mxaccess_gateway.v1.MxCommand.register:type_name -> mxaccess_gateway.v1.RegisterCommand
@@ -9494,30 +9611,30 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{
60, // 43: mxaccess_gateway.v1.MxCommand.get_worker_info:type_name -> mxaccess_gateway.v1.GetWorkerInfoCommand
61, // 44: mxaccess_gateway.v1.MxCommand.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsCommand
62, // 45: mxaccess_gateway.v1.MxCommand.shutdown_worker:type_name -> mxaccess_gateway.v1.ShutdownWorkerCommand
98, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
98, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
99, // 46: mxaccess_gateway.v1.WriteCommand.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 47: mxaccess_gateway.v1.Write2Command.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 48: mxaccess_gateway.v1.Write2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
99, // 49: mxaccess_gateway.v1.WriteSecuredCommand.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 50: mxaccess_gateway.v1.WriteSecured2Command.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 51: mxaccess_gateway.v1.WriteSecured2Command.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
1, // 52: mxaccess_gateway.v1.SubscribeAlarmsCommand.forced_mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode
43, // 53: mxaccess_gateway.v1.SubscribeAlarmsCommand.watch_list:type_name -> mxaccess_gateway.v1.AlarmSubtagTarget
44, // 54: mxaccess_gateway.v1.SubscribeAlarmsCommand.failover:type_name -> mxaccess_gateway.v1.AlarmFailoverConfig
50, // 55: mxaccess_gateway.v1.WriteBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteBulkEntry
98, // 56: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 56: mxaccess_gateway.v1.WriteBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
52, // 57: mxaccess_gateway.v1.Write2BulkCommand.entries:type_name -> mxaccess_gateway.v1.Write2BulkEntry
98, // 58: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 59: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
99, // 58: mxaccess_gateway.v1.Write2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 59: mxaccess_gateway.v1.Write2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
54, // 60: mxaccess_gateway.v1.WriteSecuredBulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecuredBulkEntry
98, // 61: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 61: mxaccess_gateway.v1.WriteSecuredBulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
56, // 62: mxaccess_gateway.v1.WriteSecured2BulkCommand.entries:type_name -> mxaccess_gateway.v1.WriteSecured2BulkEntry
98, // 63: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
98, // 64: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
111, // 65: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration
99, // 63: mxaccess_gateway.v1.WriteSecured2BulkEntry.value:type_name -> mxaccess_gateway.v1.MxValue
99, // 64: mxaccess_gateway.v1.WriteSecured2BulkEntry.timestamp_value:type_name -> mxaccess_gateway.v1.MxValue
112, // 65: mxaccess_gateway.v1.ShutdownWorkerCommand.grace_period:type_name -> google.protobuf.Duration
0, // 66: mxaccess_gateway.v1.MxCommandReply.kind:type_name -> mxaccess_gateway.v1.MxCommandKind
110, // 67: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
98, // 68: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue
97, // 69: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
111, // 67: mxaccess_gateway.v1.MxCommandReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
99, // 68: mxaccess_gateway.v1.MxCommandReply.return_value:type_name -> mxaccess_gateway.v1.MxValue
98, // 69: mxaccess_gateway.v1.MxCommandReply.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
64, // 70: mxaccess_gateway.v1.MxCommandReply.register:type_name -> mxaccess_gateway.v1.RegisterReply
65, // 71: mxaccess_gateway.v1.MxCommandReply.add_item:type_name -> mxaccess_gateway.v1.AddItemReply
66, // 72: mxaccess_gateway.v1.MxCommandReply.add_item2:type_name -> mxaccess_gateway.v1.AddItem2Reply
@@ -9542,24 +9659,24 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{
78, // 91: mxaccess_gateway.v1.MxCommandReply.session_state:type_name -> mxaccess_gateway.v1.SessionStateReply
79, // 92: mxaccess_gateway.v1.MxCommandReply.worker_info:type_name -> mxaccess_gateway.v1.WorkerInfoReply
80, // 93: mxaccess_gateway.v1.MxCommandReply.drain_events:type_name -> mxaccess_gateway.v1.DrainEventsReply
97, // 94: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
97, // 95: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
98, // 94: mxaccess_gateway.v1.SuspendReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
98, // 95: mxaccess_gateway.v1.ActivateReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
72, // 96: mxaccess_gateway.v1.BulkSubscribeReply.results:type_name -> mxaccess_gateway.v1.SubscribeResult
97, // 97: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
98, // 97: mxaccess_gateway.v1.BulkWriteResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
74, // 98: mxaccess_gateway.v1.BulkWriteReply.results:type_name -> mxaccess_gateway.v1.BulkWriteResult
98, // 99: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue
112, // 100: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp
97, // 101: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
99, // 99: mxaccess_gateway.v1.BulkReadResult.value:type_name -> mxaccess_gateway.v1.MxValue
113, // 100: mxaccess_gateway.v1.BulkReadResult.source_timestamp:type_name -> google.protobuf.Timestamp
98, // 101: mxaccess_gateway.v1.BulkReadResult.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
76, // 102: mxaccess_gateway.v1.BulkReadReply.results:type_name -> mxaccess_gateway.v1.BulkReadResult
9, // 103: mxaccess_gateway.v1.SessionStateReply.state:type_name -> mxaccess_gateway.v1.SessionState
83, // 104: mxaccess_gateway.v1.DrainEventsReply.events:type_name -> mxaccess_gateway.v1.MxEvent
91, // 105: mxaccess_gateway.v1.QueryActiveAlarmsReplyPayload.snapshots:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot
2, // 106: mxaccess_gateway.v1.MxEvent.family:type_name -> mxaccess_gateway.v1.MxEventFamily
98, // 107: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue
112, // 108: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp
97, // 109: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
112, // 110: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp
112, // 111: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp
99, // 107: mxaccess_gateway.v1.MxEvent.value:type_name -> mxaccess_gateway.v1.MxValue
113, // 108: mxaccess_gateway.v1.MxEvent.source_timestamp:type_name -> google.protobuf.Timestamp
98, // 109: mxaccess_gateway.v1.MxEvent.statuses:type_name -> mxaccess_gateway.v1.MxStatusProxy
113, // 110: mxaccess_gateway.v1.MxEvent.worker_timestamp:type_name -> google.protobuf.Timestamp
113, // 111: mxaccess_gateway.v1.MxEvent.gateway_receive_timestamp:type_name -> google.protobuf.Timestamp
84, // 112: mxaccess_gateway.v1.MxEvent.replay_gap:type_name -> mxaccess_gateway.v1.ReplayGap
85, // 113: mxaccess_gateway.v1.MxEvent.on_data_change:type_name -> mxaccess_gateway.v1.OnDataChangeEvent
86, // 114: mxaccess_gateway.v1.MxEvent.on_write_complete:type_name -> mxaccess_gateway.v1.OnWriteCompleteEvent
@@ -9568,68 +9685,69 @@ var file_mxaccess_gateway_proto_depIdxs = []int32{
89, // 117: mxaccess_gateway.v1.MxEvent.on_alarm_transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent
90, // 118: mxaccess_gateway.v1.MxEvent.on_alarm_provider_mode_changed:type_name -> mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent
7, // 119: mxaccess_gateway.v1.OnBufferedDataChangeEvent.data_type:type_name -> mxaccess_gateway.v1.MxDataType
99, // 120: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray
99, // 121: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray
100, // 120: mxaccess_gateway.v1.OnBufferedDataChangeEvent.quality_values:type_name -> mxaccess_gateway.v1.MxArray
100, // 121: mxaccess_gateway.v1.OnBufferedDataChangeEvent.timestamp_values:type_name -> mxaccess_gateway.v1.MxArray
3, // 122: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_kind:type_name -> mxaccess_gateway.v1.AlarmTransitionKind
112, // 123: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp
112, // 124: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp
98, // 125: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue
98, // 126: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue
113, // 123: mxaccess_gateway.v1.OnAlarmTransitionEvent.original_raise_timestamp:type_name -> google.protobuf.Timestamp
113, // 124: mxaccess_gateway.v1.OnAlarmTransitionEvent.transition_timestamp:type_name -> google.protobuf.Timestamp
99, // 125: mxaccess_gateway.v1.OnAlarmTransitionEvent.current_value:type_name -> mxaccess_gateway.v1.MxValue
99, // 126: mxaccess_gateway.v1.OnAlarmTransitionEvent.limit_value:type_name -> mxaccess_gateway.v1.MxValue
1, // 127: mxaccess_gateway.v1.OnAlarmTransitionEvent.source_provider:type_name -> mxaccess_gateway.v1.AlarmProviderMode
1, // 128: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode
112, // 129: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.at:type_name -> google.protobuf.Timestamp
112, // 130: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp
113, // 129: mxaccess_gateway.v1.OnAlarmProviderModeChangedEvent.at:type_name -> google.protobuf.Timestamp
113, // 130: mxaccess_gateway.v1.ActiveAlarmSnapshot.original_raise_timestamp:type_name -> google.protobuf.Timestamp
4, // 131: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_state:type_name -> mxaccess_gateway.v1.AlarmConditionState
112, // 132: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp
98, // 133: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue
98, // 134: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue
113, // 132: mxaccess_gateway.v1.ActiveAlarmSnapshot.last_transition_timestamp:type_name -> google.protobuf.Timestamp
99, // 133: mxaccess_gateway.v1.ActiveAlarmSnapshot.current_value:type_name -> mxaccess_gateway.v1.MxValue
99, // 134: mxaccess_gateway.v1.ActiveAlarmSnapshot.limit_value:type_name -> mxaccess_gateway.v1.MxValue
1, // 135: mxaccess_gateway.v1.ActiveAlarmSnapshot.source_provider:type_name -> mxaccess_gateway.v1.AlarmProviderMode
110, // 136: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
97, // 137: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
111, // 136: mxaccess_gateway.v1.AcknowledgeAlarmReply.protocol_status:type_name -> mxaccess_gateway.v1.ProtocolStatus
98, // 137: mxaccess_gateway.v1.AcknowledgeAlarmReply.status:type_name -> mxaccess_gateway.v1.MxStatusProxy
91, // 138: mxaccess_gateway.v1.AlarmFeedMessage.active_alarm:type_name -> mxaccess_gateway.v1.ActiveAlarmSnapshot
89, // 139: mxaccess_gateway.v1.AlarmFeedMessage.transition:type_name -> mxaccess_gateway.v1.OnAlarmTransitionEvent
96, // 140: mxaccess_gateway.v1.AlarmFeedMessage.provider_status:type_name -> mxaccess_gateway.v1.AlarmProviderStatus
1, // 141: mxaccess_gateway.v1.AlarmProviderStatus.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode
112, // 142: mxaccess_gateway.v1.AlarmProviderStatus.since:type_name -> google.protobuf.Timestamp
5, // 143: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory
6, // 144: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource
7, // 145: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType
112, // 146: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp
99, // 147: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray
100, // 148: mxaccess_gateway.v1.MxValue.sparse_array_value:type_name -> mxaccess_gateway.v1.MxSparseArray
7, // 149: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
102, // 150: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray
103, // 151: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array
104, // 152: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array
105, // 153: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray
106, // 154: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray
107, // 155: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray
108, // 156: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray
109, // 157: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray
7, // 158: mxaccess_gateway.v1.MxSparseArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
101, // 159: mxaccess_gateway.v1.MxSparseArray.elements:type_name -> mxaccess_gateway.v1.MxSparseElement
98, // 160: mxaccess_gateway.v1.MxSparseElement.value:type_name -> mxaccess_gateway.v1.MxValue
112, // 161: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp
8, // 162: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode
11, // 163: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest
13, // 164: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest
16, // 165: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest
15, // 166: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest
92, // 167: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest
94, // 168: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:input_type -> mxaccess_gateway.v1.StreamAlarmsRequest
10, // 169: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:input_type -> mxaccess_gateway.v1.QueryActiveAlarmsRequest
12, // 170: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply
14, // 171: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply
63, // 172: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply
83, // 173: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent
93, // 174: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply
95, // 175: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:output_type -> mxaccess_gateway.v1.AlarmFeedMessage
91, // 176: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:output_type -> mxaccess_gateway.v1.ActiveAlarmSnapshot
170, // [170:177] is the sub-list for method output_type
163, // [163:170] is the sub-list for method input_type
163, // [163:163] is the sub-list for extension type_name
163, // [163:163] is the sub-list for extension extendee
0, // [0:163] is the sub-list for field type_name
97, // 141: mxaccess_gateway.v1.AlarmFeedMessage.snapshot_status:type_name -> mxaccess_gateway.v1.AlarmSnapshotStatus
1, // 142: mxaccess_gateway.v1.AlarmProviderStatus.mode:type_name -> mxaccess_gateway.v1.AlarmProviderMode
113, // 143: mxaccess_gateway.v1.AlarmProviderStatus.since:type_name -> google.protobuf.Timestamp
5, // 144: mxaccess_gateway.v1.MxStatusProxy.category:type_name -> mxaccess_gateway.v1.MxStatusCategory
6, // 145: mxaccess_gateway.v1.MxStatusProxy.detected_by:type_name -> mxaccess_gateway.v1.MxStatusSource
7, // 146: mxaccess_gateway.v1.MxValue.data_type:type_name -> mxaccess_gateway.v1.MxDataType
113, // 147: mxaccess_gateway.v1.MxValue.timestamp_value:type_name -> google.protobuf.Timestamp
100, // 148: mxaccess_gateway.v1.MxValue.array_value:type_name -> mxaccess_gateway.v1.MxArray
101, // 149: mxaccess_gateway.v1.MxValue.sparse_array_value:type_name -> mxaccess_gateway.v1.MxSparseArray
7, // 150: mxaccess_gateway.v1.MxArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
103, // 151: mxaccess_gateway.v1.MxArray.bool_values:type_name -> mxaccess_gateway.v1.BoolArray
104, // 152: mxaccess_gateway.v1.MxArray.int32_values:type_name -> mxaccess_gateway.v1.Int32Array
105, // 153: mxaccess_gateway.v1.MxArray.int64_values:type_name -> mxaccess_gateway.v1.Int64Array
106, // 154: mxaccess_gateway.v1.MxArray.float_values:type_name -> mxaccess_gateway.v1.FloatArray
107, // 155: mxaccess_gateway.v1.MxArray.double_values:type_name -> mxaccess_gateway.v1.DoubleArray
108, // 156: mxaccess_gateway.v1.MxArray.string_values:type_name -> mxaccess_gateway.v1.StringArray
109, // 157: mxaccess_gateway.v1.MxArray.timestamp_values:type_name -> mxaccess_gateway.v1.TimestampArray
110, // 158: mxaccess_gateway.v1.MxArray.raw_values:type_name -> mxaccess_gateway.v1.RawArray
7, // 159: mxaccess_gateway.v1.MxSparseArray.element_data_type:type_name -> mxaccess_gateway.v1.MxDataType
102, // 160: mxaccess_gateway.v1.MxSparseArray.elements:type_name -> mxaccess_gateway.v1.MxSparseElement
99, // 161: mxaccess_gateway.v1.MxSparseElement.value:type_name -> mxaccess_gateway.v1.MxValue
113, // 162: mxaccess_gateway.v1.TimestampArray.values:type_name -> google.protobuf.Timestamp
8, // 163: mxaccess_gateway.v1.ProtocolStatus.code:type_name -> mxaccess_gateway.v1.ProtocolStatusCode
11, // 164: mxaccess_gateway.v1.MxAccessGateway.OpenSession:input_type -> mxaccess_gateway.v1.OpenSessionRequest
13, // 165: mxaccess_gateway.v1.MxAccessGateway.CloseSession:input_type -> mxaccess_gateway.v1.CloseSessionRequest
16, // 166: mxaccess_gateway.v1.MxAccessGateway.Invoke:input_type -> mxaccess_gateway.v1.MxCommandRequest
15, // 167: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:input_type -> mxaccess_gateway.v1.StreamEventsRequest
92, // 168: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:input_type -> mxaccess_gateway.v1.AcknowledgeAlarmRequest
94, // 169: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:input_type -> mxaccess_gateway.v1.StreamAlarmsRequest
10, // 170: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:input_type -> mxaccess_gateway.v1.QueryActiveAlarmsRequest
12, // 171: mxaccess_gateway.v1.MxAccessGateway.OpenSession:output_type -> mxaccess_gateway.v1.OpenSessionReply
14, // 172: mxaccess_gateway.v1.MxAccessGateway.CloseSession:output_type -> mxaccess_gateway.v1.CloseSessionReply
63, // 173: mxaccess_gateway.v1.MxAccessGateway.Invoke:output_type -> mxaccess_gateway.v1.MxCommandReply
83, // 174: mxaccess_gateway.v1.MxAccessGateway.StreamEvents:output_type -> mxaccess_gateway.v1.MxEvent
93, // 175: mxaccess_gateway.v1.MxAccessGateway.AcknowledgeAlarm:output_type -> mxaccess_gateway.v1.AcknowledgeAlarmReply
95, // 176: mxaccess_gateway.v1.MxAccessGateway.StreamAlarms:output_type -> mxaccess_gateway.v1.AlarmFeedMessage
91, // 177: mxaccess_gateway.v1.MxAccessGateway.QueryActiveAlarms:output_type -> mxaccess_gateway.v1.ActiveAlarmSnapshot
171, // [171:178] is the sub-list for method output_type
164, // [164:171] is the sub-list for method input_type
164, // [164:164] is the sub-list for extension type_name
164, // [164:164] is the sub-list for extension extendee
0, // [0:164] is the sub-list for field type_name
}
func init() { file_mxaccess_gateway_proto_init() }
@@ -9719,8 +9837,9 @@ func file_mxaccess_gateway_proto_init() {
(*AlarmFeedMessage_SnapshotComplete)(nil),
(*AlarmFeedMessage_Transition)(nil),
(*AlarmFeedMessage_ProviderStatus)(nil),
(*AlarmFeedMessage_SnapshotStatus)(nil),
}
file_mxaccess_gateway_proto_msgTypes[88].OneofWrappers = []any{
file_mxaccess_gateway_proto_msgTypes[89].OneofWrappers = []any{
(*MxValue_BoolValue)(nil),
(*MxValue_Int32Value)(nil),
(*MxValue_Int64Value)(nil),
@@ -9732,7 +9851,7 @@ func file_mxaccess_gateway_proto_init() {
(*MxValue_RawValue)(nil),
(*MxValue_SparseArrayValue)(nil),
}
file_mxaccess_gateway_proto_msgTypes[89].OneofWrappers = []any{
file_mxaccess_gateway_proto_msgTypes[90].OneofWrappers = []any{
(*MxArray_BoolValues)(nil),
(*MxArray_Int32Values)(nil),
(*MxArray_Int64Values)(nil),
@@ -9748,7 +9867,7 @@ func file_mxaccess_gateway_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mxaccess_gateway_proto_rawDesc), len(file_mxaccess_gateway_proto_rawDesc)),
NumEnums: 10,
NumMessages: 101,
NumMessages: 102,
NumExtensions: 0,
NumServices: 1,
},
+18
View File
@@ -117,6 +117,24 @@ 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.
`ActiveAlarmSnapshot.getFromTruncatedSnapshot()` reports that the record came
from a provider fetch which hit the per-fetch cap: the snapshot set may omit
active alarms, and the gateway suspended its absence-implies-cleared inference
for that poll. Treat the set as possibly incomplete rather than reconciling
deletions from it. It is set-level degraded status, not a comment on the
record's own fidelity, and is distinct from `getDegraded()` (the subtag
fallback provider).
`streamAlarms` also carries that completeness verdict at feed level, as a
message whose `getPayloadCase()` is `SNAPSHOT_STATUS` and whose
`getSnapshotStatus().getTruncated()` is true while the monitor's cached set
derives from a truncated fetch. One arrives at stream open (after the
`PROVIDER_STATUS` frame, before the cached `ACTIVE_ALARM` frames) so a late
joiner learns the current verdict, then one on every verdict change — including
the clearing frame sent when the gateway's alarm monitor restarts and drops a
truncated verdict. Track it if you need set completeness on a live feed without
polling `queryActiveAlarms`.
## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway
File diff suppressed because it is too large Load Diff
@@ -47,8 +47,9 @@ import java.util.function.Consumer;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
@@ -2282,7 +2283,8 @@ public final class MxGatewayCli implements Callable<Integer> {
/**
* Renders one {@link AlarmFeedMessage} in the CLI's plain-text output
* style, distinguishing the active-alarm snapshot, snapshot-complete
* sentinel, and transition cases of the message's {@code payload} oneof.
* sentinel, transition, provider-status, and snapshot-status cases of the
* message's {@code payload} oneof.
*/
private static String formatAlarmFeedMessage(AlarmFeedMessage message) {
return switch (message.getPayloadCase()) {
@@ -2307,6 +2309,10 @@ public final class MxGatewayCli implements Callable<Integer> {
"provider-status mode=%s degraded=%b reason=%s",
status.getMode().name(), status.getDegraded(), status.getReason());
}
case SNAPSHOT_STATUS -> {
AlarmSnapshotStatus status = message.getSnapshotStatus();
yield String.format("snapshot-status truncated=%b", status.getTruncated());
}
case PAYLOAD_NOT_SET -> "unknown";
};
}
+17
View File
@@ -115,6 +115,23 @@ messages from the gateway's central monitor), and
and ack target). Cancel the surrounding task or `aclose()` the 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
alarms, and the gateway suspended its absence-implies-cleared inference for that
poll. Treat the set as possibly incomplete rather than reconciling deletions
from it. It is set-level degraded status, not a comment on the record's own
fidelity, and is distinct from `degraded` (the subtag fallback provider).
`stream_alarms` also carries that completeness verdict at feed level, as a
message whose `WhichOneof("payload")` is `snapshot_status` and whose
`snapshot_status.truncated` is true while the monitor's cached set derives from
a truncated fetch. One arrives at stream open (after the `provider_status`
frame, before the cached `active_alarm` frames) so a late joiner learns the
current verdict, then one on every verdict change — including the clearing frame
sent when the gateway's alarm monitor restarts and drops a truncated verdict.
Track it if you need set completeness on a live feed without polling
`query_active_alarms`.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
File diff suppressed because one or more lines are too long
+26 -7
View File
@@ -28,11 +28,13 @@ tarball, where the rest of the mxaccessgw repo does not exist. The vendored
copies are shipped in the published `.crate` via `Cargo.toml`'s `include`
list, which is what makes the crate buildable standalone; they are build
inputs only, never a second source of truth. **Refresh rule:** any commit
that edits a Contracts proto (`mxaccess_gateway.proto`, `mxaccess_worker.proto`,
`galaxy_repository.proto`) must copy the changed file(s) into
`clients/rust/protos/` in that same commit — `scripts/check-codegen.ps1`
Check 3 fails the build on byte drift between the vendored copies and the
canonical Contracts protos. `tonic`/`prost` bindings are generated into
that edits *or adds* a Contracts proto (today `mxaccess_gateway.proto`,
`mxaccess_worker.proto`, `galaxy_repository.proto`) must copy the changed or
new file(s) into `clients/rust/protos/` in that same commit —
`scripts/check-codegen.ps1` Check 3 fails the build on byte drift between the
vendored copies and the canonical Contracts protos, and equally on a canonical
proto that has no vendored copy (which an in-repo build cannot catch, since
`build.rs` reads the canonical directory here). `tonic`/`prost` bindings are generated into
Cargo build output. `src/generated.rs` declares the Rust modules that
include those generated files. `src/generated` remains reserved for checked-in
generator output if the crate later changes to source-tree generation.
@@ -121,8 +123,25 @@ creates an authenticated `tonic` client and attaches `authorization: Bearer
`close_session_raw`, `invoke_raw`, `stream_events`, `query_active_alarms`,
`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. The
session helpers keep MXAccess handles visible:
shares the gateway's central alarm monitor with every other client.
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
alarms, and the gateway suspended its absence-implies-cleared inference for that
poll. Treat the set as possibly incomplete rather than reconciling deletions
from it. It is set-level degraded status, not a comment on the record's own
fidelity, and is distinct from `degraded` (the subtag fallback provider).
`AlarmFeedStream` also carries that completeness verdict at feed level, as an
`alarm_feed_message::Payload::SnapshotStatus` frame whose `truncated` field is
true while the monitor's cached set derives from a truncated fetch. One arrives
at stream open (after the `ProviderStatus` frame, before the cached
`ActiveAlarm` frames) so a late joiner learns the current verdict, then one on
every verdict change — including the clearing frame sent when the gateway's
alarm monitor restarts and drops a truncated verdict. Track it if you need set
completeness on a live feed without polling `query_active_alarms`.
The session helpers keep MXAccess handles visible:
```rust
let session = client.open_session(request).await?;
+28 -1
View File
@@ -2231,7 +2231,7 @@ fn event_value_to_json(value: &ProtoMxValue) -> Value {
}
/// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that
/// distinguishes the four `payload` oneof cases.
/// distinguishes the five `payload` oneof cases.
fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
match &message.payload {
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
@@ -2259,6 +2259,9 @@ fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
status.reason
)
}
Some(alarm_feed_message::Payload::SnapshotStatus(status)) => {
format!("snapshot-status truncated={}", status.truncated)
}
None => "(empty)".to_owned(),
}
}
@@ -2308,6 +2311,11 @@ fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value {
})),
}
}),
Some(alarm_feed_message::Payload::SnapshotStatus(status)) => json!({
"snapshotStatus": {
"truncated": status.truncated,
}
}),
None => Value::Null,
}
}
@@ -2961,4 +2969,23 @@ mod tests {
assert_eq!(provider["reason"], "alarmmgr unavailable");
assert_eq!(provider["since"]["seconds"], 1_777_995_000_i64);
}
#[test]
fn alarm_feed_snapshot_status_renders_in_summary_and_json() {
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AlarmFeedMessage, AlarmSnapshotStatus,
};
let message = AlarmFeedMessage {
payload: Some(alarm_feed_message::Payload::SnapshotStatus(
AlarmSnapshotStatus { truncated: true },
)),
};
let summary = super::alarm_feed_message_summary(&message);
assert_eq!(summary, "snapshot-status truncated=true");
let value = super::alarm_feed_message_to_json(&message);
assert_eq!(value["snapshotStatus"]["truncated"], true);
}
}
+45 -3
View File
@@ -726,6 +726,13 @@ message AcknowledgeAlarmReplyPayload {
// stream.
message QueryActiveAlarmsReplyPayload {
repeated ActiveAlarmSnapshot snapshots = 1;
// True when the provider fetch backing this reply came back holding the
// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
// active alarms, and the worker suspends its absence-implies-Clear inference
// for that poll so a reference missing from `snapshots` is not evidence the
// alarm cleared. Carried on the payload as well as per-record because a
// truncated fetch that filters down to zero records still has to say so.
bool snapshot_truncated = 2;
}
message MxEvent {
@@ -932,6 +939,16 @@ message ActiveAlarmSnapshot {
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
// wire (never UNSPECIFIED).
AlarmProviderMode source_provider = 15;
// True when the provider fetch that produced this snapshot hit the per-fetch
// cap: the snapshot set may omit active alarms, and the worker suspended its
// absence-implies-Clear inference for that poll. Says nothing about THIS
// record's fidelity the record is as accurate as any other; it flags that
// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
// boolean is the only additive way to carry set-level degraded status on that
// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
// Additive (proto3): clients that ignore it deserialize the stream unchanged.
bool from_truncated_snapshot = 16;
}
enum AlarmConditionState {
@@ -986,9 +1003,13 @@ message StreamAlarmsRequest {
string alarm_filter_prefix = 2;
}
// One message on the StreamAlarms feed. The stream opens with one
// `active_alarm` per currently-active alarm, then a single
// `snapshot_complete`, then a `transition` for every subsequent change.
// One message on the StreamAlarms feed. The stream opens with a
// `provider_status` (the current provider mode), then a `snapshot_status`
// (whether the cached active set may be incomplete), then one `active_alarm`
// per currently-active alarm, then a single `snapshot_complete`. After that
// come the live frames: a `transition` for every subsequent alarm change,
// interleaved with a further `provider_status` on each failover/failback and a
// further `snapshot_status` on each change of the truncation verdict.
message AlarmFeedMessage {
oneof payload {
// Part of the initial active-alarm snapshot (ConditionRefresh).
@@ -1001,6 +1022,10 @@ message AlarmFeedMessage {
// Provider-mode status. Emitted once on stream open and again on every
// failover/failback so late joiners learn the current mode immediately.
AlarmProviderStatus provider_status = 4;
// Snapshot-completeness status. Emitted once on stream open and again on
// every change of the truncation verdict, so late joiners learn whether the
// feed's active-alarm set may be incomplete.
AlarmSnapshotStatus snapshot_status = 5;
}
}
@@ -1011,6 +1036,23 @@ message AlarmProviderStatus {
google.protobuf.Timestamp since = 4;
}
// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
// (after the initial provider_status frame, before the cached active_alarm
// frames) so late joiners learn the current verdict, and again on every change
// of the truncation verdict when a reconcile reports a different verdict, and
// when the gateway's alarm monitor restarts and drops a truncated verdict with
// the cache generation it described (feed subscribers outlive that monitor
// session, so they are sent the clearing frame). Mirrors the per-record
// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
// consumers can reason about completeness without polling QueryActiveAlarms.
message AlarmSnapshotStatus {
// True while the monitor's cached active-alarm set derives from a truncated
// (capped) worker fetch the set may be missing alarms. Distinct from
// provider degradation (AlarmProviderStatus.degraded), which describes the
// fidelity of the records rather than the completeness of the set.
bool truncated = 1;
}
message MxStatusProxy {
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
// (a 16-bit signed value in the COM struct, widened to int32 on the
+11
View File
@@ -764,6 +764,17 @@ 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.
**Correction, 2026-08-18** (`docs/AlarmProbeFindings.md`). Both halves
of the paragraph above are narrower than they read. The -55 tracks the
*consumer*, not the overload: against the `SetXmlAlarmQuery`-applied
reader both the 6-arg and 8-arg forms return -55, and against the
ack-only consumer both return 0. And "works" means "returns 0" only —
a probe that raised a real alarm and then acked it six ways watched the
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.
+273
View File
@@ -0,0 +1,273 @@
# Alarm Probe Findings
`WnWrapAlarmConsumer` rested on two assumptions that no unit test can settle, because both
are properties of AVEVA's alarm provider rather than of our code. The second is now settled
and one half of the first is; the questions are stated here as they were asked:
1. **GUID identity.** The snapshot diff in `ComputeTransitions` keys on the alarm record's
`GUID`. If wnwrap mints a fresh GUID when an alarm changes state, a single
`UNACK_ALM → ACK_ALM` transition reads as one alarm disappearing and a different one
appearing — a spurious clear plus a spurious raise on every acknowledge.
2. **`ALARM_RECORDS/@COUNT` semantics.** `IsTruncatedFetch` treats a reply holding exactly
`maxAlmCnt` records as truncated, because `GetXmlCurrentAlarms2` exposes no explicit
"more available" flag. If the reply's `COUNT` attribute carries the *total* active count
rather than the records-in-reply count, truncation detection can become exact instead of
conservative, and the bounded staleness `ApplySnapshotUpdate` accepts goes away.
This document records what live probe runs against the dev rig (`DESKTOP-6JL3KKO`,
2026-08-17 and 2026-08-18) could and could not establish, so the next attempt starts from
the blocker rather than rediscovering it.
## Outcome
| Question | Status |
|---|---|
| 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 at all |
| `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".
## First attempt (2026-08-17): plain `Write`
### Why The Rig Could Not Raise An Alarm
The rig is otherwise healthy, which is what makes the blocker specific rather than a
general "nothing works":
- `aaEngine`, `alarmmgr`, `NmxSvc`, and `wnwrapServerEx` are all running.
- `TestArea` (area of `TestMachine_001``_003`) and the objects themselves are deployed
(`deployed_version` non-null in the `ZB` Galaxy Repository) and on scan — the probe's
advised `ScanState` subtags report true, and every advised alarm attribute delivers an
initial value, so the MXAccess read path is live.
- The wnwrap consumer subscribes cleanly: `InitializeConsumer`, `RegisterConsumer`,
`Subscribe(\\DESKTOP-6JL3KKO\Galaxy!TestArea)`, and `SetXmlAlarmQuery` all return 0, and
`GetXmlCurrentAlarms2` returns well-formed XML on every poll.
What fails is the *write* that would set the alarm condition. Every `Write` to the alarm
UDAs completes with a security failure:
```
WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=0 category=SecurityError detectedBy=RespondingAutomationObject detail=1008 text=]
```
The status comes back from the responding automation object, not from the proxy, so the
request reaches the engine and the engine refuses it. The advised value confirms the
refusal is total rather than transient: neither the alarm UDA nor its `.InAlarm` /`.Acked`
subtags report any change after a write attempt, across six write attempts in one session
(raise, clear, re-raise, cleanup). The attributes carry a security classification that a
plain `Write` cannot satisfy.
The 2026-05-01 capture that answered the `ALM → RTN` leg did not hit this, because the
alarm condition was driven from *inside* the engine by a System Platform script rather than
from an external MXAccess client. That script is not running now, and the values sat idle
for the whole probe session.
### Unblocking
Any one of these makes both questions answerable, in rough order of cost. The second one
is what the 2026-08-18 run did, and it worked:
- Re-enable the System Platform script that flips `TestMachine_001.TestAlarm001`
(referenced throughout `AlarmClientDiscovery.md`). It writes from inside the engine, so
the attribute's security classification does not apply.
- Drive the write through `AuthenticateUser` + `WriteSecured` with a Galaxy account
permitted on that classification. The worker already implements both verbs; the probe
used plain `Write`, which is the wrong verb for a secured attribute.
- Reclassify the test UDAs to free access in the IDE and redeploy `TestMachine_001``_003`.
Three separate objects are wired to the same alarm UDA name, so once writes land, a
`maxAlmCnt` of 1 or 2 forces truncation against three active alarms and answers the `COUNT`
question in the same run.
### Evidence
Snapshot payload, identical at every cap (1, 2, and 1024) and at every poll across the
~100-second session:
```xml
<?xml version="1.0"?><ALARM_RECORDS COUNT="0"></ALARM_RECORDS>
```
Two things follow from the empty case alone. `COUNT` is present on the root element in
every reply, so the attribute exists as a candidate signal rather than something wnwrap
omits. And `COUNT` agrees with the element count here — but trivially, since both are zero,
which is exactly the case that cannot discriminate the two hypotheses.
The probe used for the run was a throwaway file in the windev CI clone
(`C:\build\mxaccessgw-ci`), deleted afterwards; the clone is back to a clean tree at
`origin/main`. Nothing in this repository changed to run it. The reusable, Skip-gated
harness it was modelled on is
`src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs`.
## Second attempt (2026-08-18): secured-write verb
The findings above named the fix in their own "Unblocking" list, and it holds:
`AuthenticateUser` + `WriteSecured` raises the alarms that plain `Write` could not touch.
The rig's alarm attributes are not unreachable — they are secured, and the 2026-08-17 probe
used the wrong verb.
### Method
Three throwaway probes in the windev CI clone (`C:\build\mxaccessgw-ci`, detached at
`ab3ff16`), each an x86 net48 xUnit fact running on a dedicated STA with a
`PeekMessage`/`DispatchMessage` pump so MXAccess events deliver:
1. secured write + both GUID legs + the capped-fetch question,
2. the acknowledge surface, exhaustively,
3. the acknowledge question re-asked through an independent observer.
All three used `LMXProxyServerClass` directly for the write path and a
`wwAlarmConsumerClass` pair — a reader with `SetXmlAlarmQuery` applied and an ack-only
consumer without it — mirroring `WnWrapAlarmConsumer`'s own two-consumer arrangement. The
subscription was `\\DESKTOP-6JL3KKO\Galaxy!TestArea`, matching the 2026-08-17 run.
`MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER` / `_PASSWORD` are set at neither machine nor
user scope on the box, so the probes used the default rig identity, `Administrator` with an
empty password. All three files were deleted afterwards and the clone is a clean tree.
### The secured write lands
Same session, same item, one after the other — the control reproduces 2026-08-17 exactly
and the secured verb succeeds:
```
Write 'TestMachine_001.TestAlarm001' <- True
WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=0 category=SecurityError detectedBy=RespondingAutomationObject detail=1008 text=]
observed 'TestMachine_001.TestAlarm001' = False
AuthenticateUser -> userId=1
WriteSecured 'TestMachine_001.TestAlarm001' <- True currentUserId=1 verifierUserId=0
DATA-CHANGE hItem=1 value=True quality=192
WRITE-COMPLETE hLMX=1 hItem=1 statuses=[success=-1 category=Ok detectedBy=RespondingAutomationObject detail=0 text=]
observed 'TestMachine_001.TestAlarm001' = True
```
`AuthenticateUser("Administrator", "")` resolves to user id 1, matching the value
`WorkerLiveMxAccessSmokeTests` records. No verifier is needed: `verifierUserId=0` is
accepted. All six subsequent raise/clear writes across the three objects behaved the same
way, so the path is reliable rather than a one-off.
### Q2 — `ALARM_RECORDS/@COUNT` reports the reply, not the total
With all three `TestMachine_00{1,2,3}.TestAlarm001` alarms active, the same subscription
fetched at three caps back to back:
```
FETCH three-active cap=1024 ALARM_RECORDS/@COUNT=3 elementCount=3
FETCH three-active cap=1 ALARM_RECORDS/@COUNT=1 elementCount=1
FETCH three-active cap=2 ALARM_RECORDS/@COUNT=2 elementCount=2
```
`@COUNT` tracked the cap, not the population. It equals the element count in every reply,
including the two that are provably truncated — the galaxy held three active alarms while
`@COUNT` read 1 and 2. The attribute therefore carries no "more available" information, and
the hypothesis that it might report the total is refuted rather than merely untested.
`IsTruncatedFetch` stays exactly as written, and this is now settled rather than deferred:
there is no exact truncation signal to switch to. Tightening it is **not** a future task.
The conservative rule keeps its original justification — at the cap, treating a complete
fetch as truncated costs one poll of staleness, while treating a truncated fetch as
complete broadcasts clears for every alarm past the cap.
### Q1 — clear-then-re-raise mints a new GUID
`TestMachine_001.TestAlarm001` was raised, cleared, and re-raised through `WriteSecured`,
with a full snapshot fetch after each leg:
```
after-raise TAG=TestMachine_001.TestAlarm001 GUID=72B84A6E7BA74D42B611EB5393626F79 STATE=UNACK_ALM VALUE=true
after-clear-001 TAG=TestMachine_001.TestAlarm001 GUID=72B84A6E7BA74D42B611EB5393626F79 STATE=UNACK_RTN VALUE=false
after-reraise TAG=TestMachine_001.TestAlarm001 GUID=2394FEAA94774154AAA97C3AC29CA51E STATE=UNACK_ALM VALUE=true
```
Two facts, one confirming and one new. The `ALM → RTN` leg holds its GUID, reconfirming the
2026-05-01 capture against a write-driven rather than script-driven transition. The re-raise
does not: the record comes back under a GUID that has never been seen before.
The two records do **not** coexist — the snapshot carries one record per tag, and the
re-raise replaces the returned record rather than joining it. Both fetches above are the
full `cap=1024` reply, byte-identical in length (1613) at `@COUNT=3`/`elementCount=3`, the
three records being one per `TestMachine_00{1,2,3}`; `72B84A6E…` is simply absent from the
later one. So a single poll spanning the re-raise sees the old GUID disappear and the new
one appear together.
That is the correct reading for `ComputeTransitions` rather than a problem for it. The old
GUID leaving the active set is a real Clear and the new GUID is a real Raise, because they
are two alarm instances. The diff needed no change.
### Q1 — the acknowledge leg is still unobserved, for a new reason
The blocker moved. The rig can now be driven into an alarm; it cannot be driven out of
`UNACK_ALM`. Every acknowledge surface `wwAlarmConsumerClass` exposes was tried against a
freshly raised alarm, each followed by 16 seconds of polling:
| Attempt | Result |
|---|---|
| `AlarmAckByName` 6-arg on the ack-only consumer, node = machine | `rc=0`, state unchanged |
| same, node empty | `rc=0`, state unchanged |
| same, name as `Galaxy!TestArea.TestMachine_001.TestAlarm001` | `rc=0`, state unchanged |
| same, operator name `Administrator` | `rc=0`, state unchanged |
| `AlarmAckByName` 6-arg on the reader consumer (`SetXmlAlarmQuery` applied) | `rc=-55`, state unchanged |
| `AlarmAckByName` 8-arg (v2) on the ack-only consumer | `rc=0`, state unchanged |
The `rc=-55` on the `SetXmlAlarmQuery`-applied consumer reproduces the 2026-05-01 finding
that motivated the two-consumer split, so the ack-only consumer was correctly provisioned;
the `rc=0` returns are the ones that go nowhere.
A third probe removed the last doubt by watching the alarm extension's own `.Acked`
attribute over MXAccess — an observer entirely independent of the wnwrap snapshot:
```
VALUES after-raise: 'TestMachine_001.TestAlarm001.Acked' = False
AlarmAckByName -> rc=0
VALUES after-wnwrap-ack: 'TestMachine_001.TestAlarm001.Acked' = False
XML after-wnwrap-ack: STATE=UNACK_ALM OPRNAME= OPRNODE=
```
Nothing moves: not the extension attribute, not the snapshot `STATE`, not `OPERATOR_NAME`.
`rc=0` from wnwrap means the call was accepted, not that an acknowledgement was applied.
Acknowledging by writing the extension attribute directly is not an alternative — the
attribute is not writable, and says so with an operational rather than a security failure,
for both verbs:
```
WriteSecured 'TestMachine_001.TestAlarm001.Acked' <- true
WRITE-COMPLETE statuses=[success=0 category=MxCategoryOperationalError detectedBy=MxSourceRespondingAutomationObject detail=1007]
Write 'TestMachine_001.TestAlarm001.Acked' <- true
WRITE-COMPLETE statuses=[success=0 category=MxCategoryOperationalError detectedBy=MxSourceRespondingAutomationObject detail=1007]
```
`detail=1007` from the responding automation object, unchanged by authentication, is a
read-only attribute rather than a permission refusal — the `1008` `SecurityError` the alarm
UDA itself returns is what a permission refusal looks like on this rig. So the ack has no
MXAccess-side entry point and the wnwrap-side entry point is inert.
#### Remaining unblock paths for the acknowledge leg
- Acknowledge from the System Platform side — the IDE's alarm client, InTouch, or an
ArchestrA graphic bound to the alarm — and watch the snapshot from a running probe. This
proves whether wnwrap's `STATE` ever reports `ACK_ALM` at all, which is the actual
question; the ack API being inert may be a wnwrap defect layered on top of a snapshot
that would report the state correctly.
- Check whether `alarmmgr` on this rig is configured with an alarm-acknowledgement security
requirement that the wnwrap consumer, which passes an operator *name* string and no
authenticated identity, cannot meet. If so, ack over wnwrap is not merely untested here
but unavailable by configuration, and the gateway's `AcknowledgeByName` path needs the
same treatment on any customer galaxy configured that way.
- If neither lands, the acknowledge leg stays assumed. It is worth restating that this is a
documentation gap, not a correctness one: a re-minted GUID on acknowledge would produce a
spurious Clear plus a spurious Raise, which is the same shape the now-observed re-raise
behaviour produces and which `ComputeTransitions` already handles as two instances.
### Rig state left behind
The three `TestMachine_00{1,2,3}.TestAlarm001` UDAs are back to `false` and their
`.InAlarm` subtags read `false`, but each leaves a `UNACK_RTN` record in the wnwrap
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.
+20 -3
View File
@@ -260,9 +260,10 @@ Examples:
```bash
mxgateway apikey init-db
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes invoke:read,invoke:write
mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*"
mxgateway apikey create-key --key-id ops.temp --display-name "Temp contractor" --scopes invoke:read --expires 90d
mxgateway apikey create-key --key-id team-a.svc --display-name "Team A service" --scopes session:open,invoke:read --dashboard-tags team-a
mxgateway apikey create-key --key-id ops.audit --display-name "Audit window" --scopes metadata:read --expires 2027-01-01T00:00:00Z
mxgateway apikey list-keys --json
mxgateway apikey revoke-key --key-id ops.alice
@@ -272,8 +273,24 @@ mxgateway apikey rotate-key --key-id ops.alice
Constraint flags are optional. `--read-subtree`, `--write-subtree`,
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
`--max-write-classification` accepts one integer. `--read-alarm-only` and
`--read-historized-only` are boolean flags. Existing rows with null constraints
remain fully unconstrained after migration.
`--read-historized-only` are boolean flags. `--dashboard-tags` takes a
comma-separated list (`--dashboard-tags team-a,team-b`) and is repeatable; its
segments are trimmed and de-duplicated ordinal-ignore-case, and an empty segment
is rejected rather than dropped so a stray comma cannot silently persist a grant
the operator did not write. It is the **only constraint flag** that splits its
value on commas (`--scopes`, which is not a constraint, is the other flag that
does): the repeatable subtree and glob flags each take exactly one value per
occurrence, so `--read-subtree "Area1/*,Area2/*"` is a single literal pattern
containing a comma, not two patterns. Repeat the flag instead. Existing rows with null constraints remain fully
unconstrained after migration; rows written before `--dashboard-tags` existed
deserialize as untagged, unchanged in every other respect.
`--dashboard-tags` is *not* a data-access constraint — it only labels the key for
dashboard event visibility, and sessions the key opens inherit it. See
[Authorization](./Authorization.md#constraint-enforcement).
`list-keys` prints the tags as a trailing tab-separated column (`-` when
untagged); the values are operator-chosen labels, not key material.
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
so they remain safe to embed in the token format and in URL paths used by
+42
View File
@@ -178,6 +178,48 @@ Supported constraints are:
| `browse_subtrees` | Contained-path globs used to filter Galaxy browse results and deploy-event counts. |
| `read_alarm_only` | Read/subscription commands must target objects with alarm-bearing attributes. |
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. |
`dashboard_tags` is the one member of the blob that constrains nothing on the
gRPC data path. No read, write, browse, or subscribe check consults it, and
`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag
neither widens nor narrows what a key may read or write. It rides in the same
serialized blob only to avoid an auth-store schema migration
(`docs/plans/2026-07-10-dashboard-session-acl-tst15.md` §3.1).
Its sole purpose is dashboard event visibility. A session records the tags of the
API key that opened it (`GatewaySession.Tags`, immutable for the session's life,
compared ordinal-ignore-case). The tags come from the owning key, never from the
client's `OpenSession` request, so a client cannot label its own session with
another tenant's tag. A key with no tags opens untagged sessions.
Tags are set at key creation with
`apikey create-key --dashboard-tags team-a,team-b` (repeatable; segments are
trimmed and de-duplicated ordinal-ignore-case). The dashboard API Keys page sets
them too: its create form has a **Dashboard tags** field alongside the data-access
constraints, split on the same separators the other constraint fields use and
de-duplicated ordinal-ignore-case as the CLI does. The two differ on one point: a
stray separator drops an empty segment silently on the form rather than failing
the command, because a browser form has no exit code to fail with.
That page's constraints column names `dashboard_tags` like any other member. It
has to: `IsEmpty` counts the tags, so a key whose only recorded policy is a
dashboard tag is not unconstrained, and leaving it out of the summary rendered
that key with the same empty cell as a key with no policy at all.
The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15).
`IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe
seams — the SignalR `EventsHub.SubscribeSession` join and the in-process
`IDashboardSessionEventSubscriber.Subscribe` behind the session-details page — so
per-session event visibility is enforced at runtime: a Viewer observes a session
only when the session's tags intersect the tags their LDAP groups are granted
through `MxGateway:Dashboard:GroupToTag`. Administrators bypass the intersection,
and a session with no tags is visible to Administrators only unless
`MxGateway:Dashboard:UntaggedSessionVisibility` is set to `AllViewers`.
That enforcement is still *visibility*, not data access. The ACL decides which
sessions' mirrored events a dashboard principal may observe; it does not widen or
narrow what any API key may read, write, browse, or subscribe to over gRPC.
Glob matching is anchored, case-insensitive, and supports `*` and `?`.
Subtree and tag glob lists are alternatives: matching either list allows that
+2 -1
View File
@@ -206,7 +206,8 @@ publish` ship them inside the `.crate`, making the crate buildable standalone
with no access to the rest of the mxaccessgw repo. Any Contracts proto change
must refresh `clients/rust/protos/` in the same commit; `scripts/check-codegen.ps1`
Check 3 byte-compares the vendored copies against the canonical protos and
fails on drift. Because the vendored protos make a standalone build possible,
fails on drift — in both directions, so a newly added canonical proto that
was never vendored fails there rather than at a consumer's standalone build. Because the vendored protos make a standalone build possible,
`cargo package`/`cargo publish` run **with** verification (no `--no-verify`) —
a `cargo package` that cannot build from the vendored tree alone would mean
the vendored copies are stale, and verification is what catches that before
+74 -10
View File
@@ -199,12 +199,68 @@ Consequences, and how this sits with the existing failover/reconcile design:
goes to the worker's console/stderr, which is captured on dev hosts but is
not a metric, not a dashboard tile, and not part of any session-status or
alarm-feed payload, so a production deployment can truncate indefinitely
without anyone noticing. Surfacing truncation as a **structural** degraded
status (a field on the alarm-provider mode/status surface the dashboard and
`StreamAlarms` consumers already read) is filed as a follow-up; until it
lands, the log line is the only signal. A galaxy that truncates persistently
without anyone noticing. The structural signal that fixes this landed
separately — see the next decision. A galaxy that truncates persistently
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
### Alarms — truncation is reported per record on the snapshot stream, as a status frame on the live feed
Decision (2026-08-17): the truncated-fetch verdict above is carried to clients as
`QueryActiveAlarmsReplyPayload.snapshot_truncated` on the worker IPC reply and as
`ActiveAlarmSnapshot.from_truncated_snapshot` on **every record** of the public
`QueryActiveAlarms` stream, with a matching `IGatewayAlarmService.SnapshotTruncated`
driving a dashboard banner. Both fields are additive proto3 booleans.
A per-record boolean is an odd shape for what is set-level status, so the reason
matters: `rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream
ActiveAlarmSnapshot)` returns a *bare* message stream. There is no envelope, no
header message, and no trailing summary to hang a set-level field off. Adding one
would mean either a new wrapper message (breaking every existing client's stream
element type) or a trailing metadata convention (invisible to clients that stop
reading early). Stamping the flag identically on each record is the only carrier
that is additive on the wire: clients that ignore the field deserialize exactly
as before. Consumers should read it as "the set this record belongs to may be
incomplete", never as a statement about the record's own fidelity — that is what
`degraded` / `source_provider` mean, and the two are independent. The reply
payload carries the flag as well because a prefix filter (or an empty galaxy) can
leave zero records, and a truncated fetch with nothing to report still has to say
so.
The **detection heuristic is unchanged, and now permanently**: `IsTruncatedFetch`
remains `fetchedRecordCount >= maxAlarmsPerFetch`. The 2026-08-18 probe settled
what the earlier run could not: with three alarms active, `ALARM_RECORDS/@COUNT`
read 1 at a cap of 1 and 2 at a cap of 2, so it reports the records in the reply
rather than the total active count (`docs/AlarmProbeFindings.md`). There is no
exact signal to switch to — `@COUNT` cannot distinguish a capped fetch from a
complete one — so the heuristic is the design rather than a placeholder waiting
on evidence.
The flag is **not latched**. It is replaced by each fetch's verdict, so the first
sub-cap fetch clears it, and `GatewayAlarmMonitor.ClearCache` drops it with the
cache generation it describes. A caveat that never turns off is a caveat
operators learn to ignore.
The **live feed carries the verdict as set-level status**, not per record.
`StreamAlarms` has an envelope — `AlarmFeedMessage` — so the shape forced on
`QueryActiveAlarms` above is not forced here: the feed gets a fifth oneof case,
`snapshot_status` (`AlarmSnapshotStatus.truncated`), alongside `provider_status`.
The two carriers are therefore deliberately different shapes for the same verdict,
and each is the only additive option on its own surface. Emission is
**edge-triggered**, for the same reason the flag is not latched: a status frame
repeated on every reconcile is noise a consumer filters out, and a filtered-out
signal is no signal. The exception is the open-time frame, which is
unconditional — a late joiner cannot distinguish "not truncated" from "this
gateway does not send the frame" by silence, so it is told explicitly. It is
ordered after `provider_status` and before the cached `active_alarm` frames so a
consumer applying the snapshot as it streams holds the caveat while it applies
the records it qualifies. A monitor restart's `ClearCache` emits the clearing
frame as well: feed subscribers outlive the monitor's worker session, so a silent
re-seed would leave them caveating a set that is no longer truncated.
This is gateway metadata about **our** fetch mechanics, not a claim about MXAccess
behaviour, so it is not a parity deviation: no event is synthesized and no
MXAccess-observable semantics change.
## Session-Resilience Epic Scope
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
@@ -218,11 +274,19 @@ decisions rather than one open backlog.
shipped as **TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client
`ReplayGap` handling) shipped as **CLI-15** for four of five clients
(.NET/Go/Rust/Python); the Java client is the only remainder.
- **Phase 4 (per-session dashboard ACL)** — scoped, not yet built. Tracked as archreview
**TST-15**. The Viewer-default decision is settled: admin-sees-all, Viewer strictly
scoped to sessions it owns or is granted — matching the gRPC owner-binding decision in
[Session Reconnect](#session-reconnect) above, for consistency between the gRPC and
dashboard surfaces.
- **Phase 4 (per-session dashboard ACL)** — shipped 2026-08-17 (branch
`feat/deferred-closeout`), tracked as archreview **TST-15**. The Viewer default is
admin-sees-all, Viewer strictly scoped — but the scope is the session **tag**, not
session ownership. The dashboard authenticates LDAP users while sessions are owned by
API keys, two disjoint identity domains, so there is no "sessions it owns" branch to
write: `GatewaySession.Tags` is inherited from the owning key's `DashboardTags`, a
dashboard group grants tags via `MxGateway:Dashboard:GroupToTag`, and
`IDashboardSessionAcl.CanViewSession` allows a Viewer iff the two sets intersect.
Administrators bypass the intersection; an untagged session is Admin-only under the
default `MxGateway:Dashboard:UntaggedSessionVisibility=AdminOnly`. The decision is
taken at both subscribe seams (`EventsHub.SubscribeSession` and the in-process
`IDashboardSessionEventSubscriber.Subscribe`), never per event. See
`docs/plans/2026-07-10-dashboard-session-acl-tst15.md` and `docs/Authorization.md`.
- **Phase 5 (orphan-worker reattach)** — deferred, not planned. It would reverse the
"Gateway restart does not reattach orphan workers" invariant (see CLAUDE.md), adding a
stable gateway-instance id, an adoption-manifest SQLite store, a worker phone-home
@@ -232,7 +296,7 @@ decisions rather than one open backlog.
if it does** until that task actually lands.
`docs/plans/2026-06-15-session-resilience.md.tasks.json` remains the sole resume state
for the still-pending Phase 4 tasks (16-19) and the deferred Phase 5 tasks (20-28) — one
for the Phase 4 tasks (16-19, now shipped) and the deferred Phase 5 tasks (20-28) — one
authority, no mirror.
## Authentication
+14 -4
View File
@@ -58,9 +58,13 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"RecentSessionLimit": 200,
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwAdmin": "Administrator",
"GwReader": "Viewer"
}
},
"GroupToTag": {
"GwReader": [ "team-a" ]
},
"UntaggedSessionVisibility": "AdminOnly"
},
"Protocol": {
"WorkerProtocolVersion": 1,
@@ -188,8 +192,10 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. Security-relevant because the per-session hub ACL that would scope a Viewer to specific sessions does not exist yet: with no per-session scoping, this redaction is currently the only thing standing between a low-trust Viewer and other sessions' tag values, so setting this `true` exposes every session's tag values to every authenticated dashboard viewer. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror**`DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub**`AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for the value text of each **successfully read** tag; a failed read keeps its `-` placeholder (there was no value to suppress, and the row's error is left untouched, so the two cannot contradict each other). Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. |
| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. |
| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
@@ -198,6 +204,10 @@ and `RecentSessionLimit` must be greater than or equal to zero.
`GroupToRole` values are validated at startup; invalid role names fail
validation. Emptiness is allowed (a closed deployment that admits no LDAP
users) but practical deployments populate at least one Admin group.
`GroupToTag` is validated for shape only — non-blank group keys, non-null tag
lists, non-blank tags — and is not cross-checked against `GroupToRole`, because
role grants and visibility grants are deliberately separate concerns.
`UntaggedSessionVisibility` must be `AdminOnly` or `AllViewers`.
### Authorization policies
+48 -14
View File
@@ -273,8 +273,8 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
| Hub | Path | Producer | Payload | Routing |
|---|---|---|---|---|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. |
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. The per-session ACL that would scope a Viewer to specific sessions is still outstanding for this seam and the in-process one alike (SEC-25 / remediation roadmap item 12); the value redaction is the near-term hardening that closes the value-leak seam independently of that ACL. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition` / `provider_status` / `snapshot_status`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. `SubscribeSession` is gated by `IDashboardSessionAcl` (SEC-25 / TST-15): a denied caller gets a `HubException`, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a *permitted* subscriber sees. |
### Default cadences
@@ -517,12 +517,17 @@ tree to a flat list of matching attributes.
Right-clicking an attribute (or double-clicking it) adds it to the subscription
panel. The panel shows each subscribed tag's live value, MXAccess data type,
quality and source timestamp, refreshed every two seconds. The subscription
panel is the explicit opt-in tag-value surface: it always shows values
regardless of `Dashboard:ShowTagValues`, which governs the diagnostic
session/worker views and the per-session event mirror — both its hub and
in-process audiences (values are redacted from the mirrored events when the flag
is false).
quality and source timestamp, refreshed every two seconds — but the value column
obeys `Dashboard:ShowTagValues` like every other dashboard value surface. With
the flag false (the default) `DashboardLiveDataService` hands the page
`[redacted]` in place of the formatted value of each **successfully read** tag —
a failed read keeps its `-` placeholder, since there was no value to suppress —
while data type, quality, source
timestamp and any read error are untouched, so the panel still answers "is this
tag advising and healthy" without disclosing the value. The substitution happens
at the service, not in the page: one decision point, and the value never enters
the render tree. Set the flag true to make the panel the explicit opt-in
tag-value surface it was originally designed as.
### Alarms page
@@ -649,6 +654,11 @@ Show read-only effective configuration:
- auth mode,
- SQLite auth database path with sensitive parts redacted if needed,
- dashboard enabled state,
- the LDAP group mappings that decide what a signed-in user may see —
`Dashboard:GroupToRole` (group → `Administrator` / `Viewer`) and
`Dashboard:GroupToTag` (group → the session tags it grants),
- `Dashboard:UntaggedSessionVisibility`, which decides who sees a session whose
owning API key carries no dashboard tags,
- protocol version.
Do not show API key secrets or pepper values.
@@ -696,9 +706,26 @@ The in-process page feeds carry no authentication of their own, and need none:
`MapRazorComponents<App>()` applies `RequireAuthorization(ViewerPolicy)` to the
component endpoints, so a page can only run inside a circuit whose principal is
already an authorized Viewer. The hub-token flow below therefore covers only the
remote hub surface. Neither seam scopes a Viewer to particular sessions — SEC-25
(the per-session ACL) is outstanding for both, and the mirror's value redaction
remains the near-term mitigation, unchanged by the move in-process.
remote hub surface.
Neither policy scopes a Viewer to particular sessions — that is
`IDashboardSessionAcl`'s job (SEC-25 / TST-15), consulted by both subscribe seams:
`EventsHub.SubscribeSession` for remote hub clients and the session-details page's
in-process subscribe, which renders an inline denial instead of subscribing. The
decision is: authenticated Administrator → allow (checked before the session is
looked up, so an Administrator naming a session that just closed is still allowed);
unknown session id → deny; untagged session → `Dashboard:UntaggedSessionVisibility`
(`AdminOnly` by default); otherwise allow iff the session's tags intersect the
caller's granted tags, ordinal-ignore-case. A session's tags are inherited from its
owning API key's `--dashboard-tags` constraint and are immutable for the session's
life, so a subscribe-time decision cannot go stale while the subscription lives and
no per-event re-check is needed. A Viewer's grant comes from
`Dashboard:GroupToTag` applied to their LDAP groups, stamped as
`zb:dashboardtag` claims at cookie login and re-resolved (not copied) at hub-token
mint, so the token's 5-minute lifetime bounds how long a revoked grant survives. A
principal carrying no tag claims — anonymous localhost included — is an
empty-grant Viewer. The mirror's value redaction is an independent layer: it bounds
what a permitted subscriber sees, not who may subscribe.
Two environmental bypasses still apply, both scoped to **read-only** access:
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
@@ -781,9 +808,16 @@ carry no server-side revocation state (no jti denylist). A token captured before
logout remains valid until it expires, and a role change or key revocation does
not take effect on an already-issued token until then. The 5-minute lifetime is
the deliberate mitigation: it bounds that exposure window without the cost of a
revocation store. Server-side revocation is deferred until per-session hub ACLs
land (see the per-session-ACL note), at which point tokens gain session/role
binding and a denylist becomes worthwhile.
revocation store. It now bounds a stale *tag* grant the same way: the token carries
the tags resolved from the caller's LDAP groups at mint time, so removing a
`GroupToTag` entry takes effect for token-authenticated hub connections within one
lifetime. That 5-minute staleness bound covers token-authenticated connections only:
a cookie principal carries the `zb:dashboardtag` claims stamped at login for the
cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a
revoked `GroupToTag` grant takes effect at the user's next login, not within five
minutes. That is where the per-session ACL's revocation need landed — a jti
denylist stays deferred, since the short lifetime already bounds every grant the
token carries.
## Configuration
+1 -1
View File
@@ -751,7 +751,7 @@ secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
connections additionally accept a 30-minute data-protected bearer minted at
connections additionally accept a 5-minute data-protected bearer minted at
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
to bypass the cookie requirement and defaults to `true`.
+5
View File
@@ -689,6 +689,11 @@ committed client descriptor set (Check 1), the C# `Generated/` (Check 2), the Ru
protos (Check 3), or the Go/Python client bindings (Check 4, IPC-25) no longer match the current
`.proto` sources — the codegen drift class this repo has hit repeatedly (stale client
descriptors, net48 `CS0246` on unregenerated protos, silently stale Go/Python worker bindings).
Check 3 sweeps both directions: a vendored copy that drifted from (or has no) canonical proto
fails, and so does a canonical proto with no vendored copy at all. The second direction matters
because `clients/rust/build.rs` prefers the canonical directory whenever it exists, so an
unvendored proto builds fine in-repo and only breaks the standalone crate build a consumer runs
from the published tarball — drift no in-repo build can surface.
Check 4 regenerates the Go and Python bindings with their pinned generators (`protoc-gen-go`
v1.36.11 / `protoc-gen-go-grpc` 1.6.2, `grpcio-tools` 1.80.0) and fails on any diff; a missing
generator fails the check rather than skipping it. The **primary** guard for the
+36 -5
View File
@@ -94,7 +94,7 @@ An accepted gRPC command payload can still be too large for the worker pipe: the
### `StreamAlarms`
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with a `provider_status` and a `snapshot_status` `AlarmFeedMessage` (the current provider mode and snapshot-completeness verdict), then one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear — interleaved with a further `provider_status` on each failover/failback and a further `snapshot_status` on each change of the truncation verdict. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
### `QueryActiveAlarms`
@@ -114,10 +114,41 @@ message AlarmProviderStatus {
}
```
The gateway emits `provider_status` once when a client first subscribes
(immediately after the initial snapshot and before the first live transition)
and again on every failover or failback. A late-joining client therefore
always learns the current provider mode without waiting for the next switch.
The gateway emits `provider_status` once when a client first subscribes — as
the **first** frame on the stream, before the `snapshot_status` frame and
before any cached `active_alarm`and again on every failover or failback. A
late-joining client therefore learns the current provider mode before it sees a
single alarm, without waiting for the next switch.
#### Snapshot completeness on the alarm feed
`AlarmFeedMessage` has a fifth `payload` case, `snapshot_status`, carrying
an `AlarmSnapshotStatus` message:
```protobuf
message AlarmSnapshotStatus {
bool truncated = 1; // the cached active set may be missing alarms
}
```
It is the feed-level twin of the per-record
`ActiveAlarmSnapshot.from_truncated_snapshot` flag: `truncated` is true while the
monitor's cached active-alarm set derives from a capped worker fetch. Read it as
"this set may be incomplete", never as a statement about record fidelity — that
is what `degraded` / `source_provider` mean, and the two are independent.
Emission mirrors `provider_status` but with one ordering rule of its own. The
gateway emits `snapshot_status` once when a client subscribes, **after** the
open-time `provider_status` and **before** the cached `active_alarm` frames, so a
consumer applying the snapshot as it streams has the completeness caveat in hand
while it applies it. The open-time frame is unconditional — an explicit
`truncated = false` is what distinguishes a complete set from a gateway that
never sends the frame. Afterwards it is emitted only on a *change* of verdict:
when a reconcile flips the verdict either way, and when a monitor restart drops a
truncated verdict with the cache generation it describes (feed subscribers
outlive the monitor's worker session, so they see that clear). Clients that do
not know the case see an unset `payload` oneof and ignore the message, exactly as
before.
`AlarmProviderMode` is an enum with three values:
+11 -3
View File
@@ -6,7 +6,7 @@ The sessions subsystem owns the in-memory representation of an active gateway-to
A session is the gateway-side handle that callers use to invoke worker commands, stream worker events, and tear the worker down. The subsystem is split between the per-session state machine (`GatewaySession`), an in-memory directory (`SessionRegistry`), the orchestrator that opens and closes sessions (`SessionManager`), the worker construction step (`SessionWorkerClientFactory`), and a hosted service that drains sessions during host shutdown (`SessionShutdownHostedService`).
All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`.
All three interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`.
## Key Types
@@ -47,9 +47,17 @@ public void TransitionTo(SessionState nextState)
`Closed` is terminal, `Faulted` only allows a transition to `Closed`, and `Closing` only allows a transition to `Closed` or `Faulted`. This guards against late callbacks (worker exit, heartbeat timeout) re-animating a session that is already tearing down or torn down — once `CloseAsync` has set `Closing` under `_syncRoot`, no `TransitionTo(Ready)` from another thread can walk the session back to `Ready`. Both close-related writes (`Closing` and `Closed`) go through `_syncRoot` exactly like every other state write; `_closeLock` only serializes concurrent close attempts.
#### Session tags and dashboard event visibility
`GatewaySession.Tags` is an immutable, ordinal-ignore-case set of dashboard visibility tags, stamped once at construction from the `ownerDashboardTags` argument and never mutated for the session's life. The values come from the owning API key's `ApiKeyConstraints.DashboardTags` (set with `apikey --dashboard-tags`), which `MxAccessGatewayService` reads off the authenticated caller and passes to the tagged `OpenSessionAsync` overload. They are never read from the client's wire request, so a client cannot label its own session with another tenant's tag. A session whose owner key declared no tags — and every session opened through the tagless `OpenSessionAsync` overload, which unit-test fakes inherit by default — is untagged.
Tags gate **visibility only**: which sessions' event metadata a dashboard user may observe. They are not a data-access constraint, so they neither widen nor narrow what the owning key can read or write, and they play no part in the gRPC event stream, whose attach check is owner-key identity (see [Reconnect and replay](#reconnect-and-replay)).
`IDashboardSessionAcl.CanViewSession` is the single decision both dashboard subscribe seams consult — `EventsHub.SubscribeSession` for remote hub clients and the session-details page's in-process subscribe. An authenticated Administrator is allowed first, before the session is even looked up; otherwise an unknown session id is denied, an untagged session follows `MxGateway:Dashboard:UntaggedSessionVisibility` (`AdminOnly` by default), and a tagged session is allowed only when its tags intersect the caller's granted tags. A Viewer's grant comes from `MxGateway:Dashboard:GroupToTag` applied to their LDAP groups; a principal carrying no tag claims — anonymous localhost included — is an empty-grant Viewer and sees no tagged session. Because `Tags` is immutable, the decision taken at subscribe time cannot go stale while the subscription lives, so there is no per-event re-check. See `docs/GatewayDashboardDesign.md`.
### SessionManager (ISessionManager)
`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `ReadEventsAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`.
`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `CloseSessionAsync`, `KillWorkerAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`.
`CloseSessionAsync` and `KillWorkerAsync` are both end-of-life paths but differ in what they offer the worker:
@@ -191,7 +199,7 @@ The order — fault, deregister, dispose, release slot, record metric, log, reth
### Run
While `Ready`, callers reach the worker through `SessionManager.InvokeAsync` or `ReadEventsAsync`. Both delegate to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`.
While `Ready`, callers reach the worker through `SessionManager.InvokeAsync`, which delegates to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. Events do not travel this path: every consumer attaches to the session's `SessionEventDistributor` instead (see below), so the manager exposes no event-read member. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`.
Event streaming uses `AttachEventSubscriber` which returns a disposable lease. When `allowMultipleSubscribers` is false (single-subscriber mode) a second attach throws `EventSubscriberAlreadyActive`; this prevents two gRPC streams from racing on the same worker event channel. When it is true, up to `MaxEventSubscribersPerSession` concurrent external subscribers are allowed and the next attach throws `EventSubscriberLimitReached`. The count-check-and-increment is atomic under the session lock, so concurrent attaches can never exceed the cap. The gateway-owned internal dashboard mirror subscriber is registered directly on the distributor and does not count toward the cap. Active event subscribers keep the session lease from expiring until the stream is disposed.
+27 -7
View File
@@ -171,13 +171,33 @@ oversized event) surfaces from the batch's awaited completions as that frame's
`WorkerFrameProtocolException`; the remaining completions are still observed
so none faults unobserved.
The completion is the frame's delivery point, not necessarily the instant its
caller returns. A caller that loses the race for the write lock only observes
its own completion after the winning drainer releases the lock, so its return
remains bounded by that drain pass even though its control frame was flushed
and completed at the class boundary inside it. The boundary flush is what
makes the delivery point honest; unparking a lock-race loser from the winner's
pass would be a separate change to the enqueue-then-contend shape.
The completion is the frame's delivery point, and a `WriteAsync` caller now
returns at it. The boundary flush alone only made the delivery point honest:
a caller that lost the race for the write lock still sat in the lock wait
until the winning drainer released it, so its awaited task was charged for the
whole event backlog its control frame had just been flushed ahead of. To close
that, `WriteAsync` awaits its own frame's completion *racing* the lock
acquisition instead of the acquisition alone. Whichever settles first decides:
- **Completion first** — the winning drainer wrote and flushed this frame at
the class boundary, so the caller returns immediately. The lock acquisition
it leaves outstanding is *detached*, not dropped: a continuation drains
whatever is queued and then releases, so the lock is never acquired and
silently held, and a frame enqueued between the previous drainer's last
dequeue and its release is still written by someone. Draining an empty queue
is a no-op, so the common case is acquire-nothing-release.
- **Lock first** — the caller drains the pass itself, exactly as before.
- **Cancellation** — the wait ends without the lock (`SemaphoreSlim` hands no
count to a wait it cancels, so the detached continuation releases nothing on
that path) and the tombstone rules below apply unchanged. A token that fires
*after* the frame's completion won the race changes nothing: the frame was
delivered, and the caller returns normally.
`WriteBatchAsync` deliberately keeps the plain wait-then-drain shape. A batch
caller's result is its whole set of completions and the last of those resolves
at the end-of-pass flush — the instant before the drainer releases the lock —
so racing the acquisition would buy it nothing while adding one detached
acquisition per call.
Cancellation of a `WriteAsync`/`WriteBatchAsync` call that is still waiting
for the write lock when its token fires tombstones the queued frame: the
@@ -1,6 +1,8 @@
# Dashboard EventsHub per-session ACL (TST-15 / SEC-25 · session-resilience epic Phase 4)
Status: **Design** — approved-to-implement pending the schema-touch call in §9.
Status: **Implemented** — branch `feat/deferred-closeout`, 2026-08-17, commits
`693a78d` + `7ec0b35`. As-built notes in §12; the sections above are the design as
approved, kept for the rationale they record.
Findings: TST-15 (`Medium`, P2), SEC-25 (`Low`, P2). Epic tasks: 1619 of
`docs/plans/2026-06-15-session-resilience.md`.
Depends on: TST-02 (owner-scoped gRPC attach, shipped P0), SEC-25 near-term
@@ -85,7 +87,8 @@ public sealed record ApiKeyConstraints(
At `OpenSession`, the resolved `ApiKeyIdentity.EffectiveConstraints.DashboardTags`
is copied onto the new `GatewaySession.Tags`. The `apikey` admin CLI gains
`--dashboard-tags team-a,team-b` on `create`/`update`.
`--dashboard-tags team-a,team-b` on `create-key` (there is no `update`
subcommand — see `docs/Authentication.md`).
Semantic note: `ApiKeyConstraints` today scopes *data-access* authorization
(read/write subtrees, globs, classification). A dashboard *visibility* tag is a
@@ -295,3 +298,74 @@ to defer heavy revocation.
- **Staleness bound.** A revoked tag grant takes effect within one token lifetime
(≤5 min) for token-auth connections and immediately for a fresh cookie login.
```
## 12. As-built notes (enforcement landed 2026-08)
### 12.1 §4's "no second seam" no longer held — the ACL gates two
This design was written against a dashboard whose only route to a session's event
feed was the SignalR hub, which is why §4 concludes "gating at join is sufficient —
there is no second seam to guard". The 2026-08 in-process feed refactor invalidated
that premise: server-rendered pages stopped opening a loopback SignalR connection to
`/hubs/events` and now read the mirror directly through
`IDashboardSessionEventSubscriber.Subscribe(sessionId)` (`DashboardEventBroadcaster`
implements both the publish and subscribe interfaces). `SessionDetailsPage` is that
second consumer, and it never touches `SubscribeSession`, so a hub-only gate would
have left the page as an ungated path to the same events.
The shipped enforcement therefore puts the *same* `IDashboardSessionAcl` decision in
front of both subscribe calls:
- `EventsHub.SubscribeSession` — denies with `HubException("Not authorized for this
session.")` before the group join **and** before the `EventsHubViewerRegistry`
registration, so a denied caller neither receives events nor turns the mirror on.
- `SessionDetailsPage` — resolves the circuit principal via
`AuthenticationStateProvider` and checks the ACL *before* `Subscribe(SessionId)`.
A denial creates no subscription, starts no pump, and registers no viewer; the
events panel renders "Not authorized for this session's events." in place of its
empty state. The gate wraps only whether the subscription is created — the page's
generation/`ReferenceEquals` guards, `MarkDisconnectedAsync`, and the
`DisposeAsync`/`DetachEventsAsync` coupling are untouched.
Both seams remain subscribe-time-only. Session tags are immutable for the session's
life (§3), so a joined group or a live in-process subscription cannot go stale, and
no per-event check is needed on either path.
### 12.2 Where the grant is stamped
`zb:dashboardtag` claims are added at both principal-construction sites:
`DashboardAuthenticator.CreatePrincipal` (cookie login, so a circuit carries its
grant without a token round-trip) and `HubTokenService.Issue` (hub bearer). Both
resolve the grant from the caller's `mxgateway:ldap_group` claims through
`DashboardGroupTagMapping` + `Dashboard:GroupToTag` rather than copying tag claims
already on the principal — re-resolving at mint is what makes the token's 5-minute
lifetime an actual staleness bound on a changed grant, as §4 claims. Tags are
stamped for Administrators too; they are simply moot, because the ACL's admin bypass
is checked first.
### 12.3 Deviations worth knowing
- `CanViewSession` takes a **nullable** `ClaimsPrincipal`. `HubCallerContext.User` is
nullable, and null denies — the fail-closed reading.
- The admin bypass additionally requires `Identity.IsAuthenticated`, matching
`DashboardSessionAdminService.CanManage`. A role claim on an unauthenticated
identity does not bypass.
- Tag *values* are never logged at either seam; only the identifiers and the
allow/deny outcome are observable.
- **The page gate needed a re-entrancy guard.** Making `AttachEvents` async (it now
awaits the authentication state) introduced a suspension point the synchronous
version did not have, and with it a window: on a rapid A → B navigation the
suspended A continuation resumes, re-reads the live `SessionId` — now B's — and
attaches B a second time, overwriting the fields that hold B's first subscription.
That subscription is then unreachable: never disposed, its `EventsHubViewerRegistry`
entry never released (so the mirror keeps cloning events for it), its pump never
cancelled. Not an ACL bypass — the newer attach had already cleared the same session
— but a resource leak the ACL work created. `OnParametersSetAsync` now claims a
monotonic `_attachGeneration` synchronously before its first await, and
`AttachEventsAsync` re-checks it after the await and before any field write or
`Subscribe`; a stale attach returns without attaching (it owns nothing, and tearing
down would destroy the newer attach's subscription). `DetachEventsAsync` needs no
guard: it captures and nulls the live fields synchronously before it awaits.
- The admin-bypass-before-lookup ordering means an Administrator naming a session id
the registry does not have is **allowed**, not denied. Deliberate, and pinned by a
test so a "look the session up first" refactor cannot flip it silently.
+14 -1
View File
@@ -309,6 +309,15 @@ Push the branch to origin, then on windev (`ssh windev`, clone `C:\build\mxacces
| SEC-25 per-session dashboard event ACL | Security roadmap item; Task 6 deliberately preserves the current posture. |
| `MxAccessWriteCompletionCache` clone | Different lifecycle than the value cache; consciously kept (Task 12.5). |
Closure (2026-08-17, [`docs/plans/2026-08-17-deferred-closeout.md`](2026-08-17-deferred-closeout.md),
branch `feat/deferred-closeout`): the probes were **attempted** — the rig's alarm-condition
writes are refused (`SecurityError` from the responding object), so GUID identity is confirmed
for the active→returned leg only and `@COUNT` stays unverified; evidence and unblock paths in
`docs/AlarmProbeFindings.md`. The truncation degraded-status signal **shipped** (additive proto
fields, worker→gateway→dashboard, all five clients regenerated). SEC-25 **shipped** (per-session
event ACL on both dashboard subscribe seams; design doc marked Implemented). The
`MxAccessWriteCompletionCache` clone row remains consciously kept.
---
## As-built notes (execution record)
@@ -321,7 +330,8 @@ is worth keeping, this is the record.
through `ISessionManager.ReadEventsAsync`. That interface member itself has zero
production call sites — only test fakes implement and exercise it. Deleting it is a
mechanical but wide change (~15 test-fake touches), so it is recorded as a follow-up
rather than done here.
rather than done here. Removed by `docs/plans/2026-08-17-deferred-closeout.md` Task 1,
2026-08-17.
**Task 5 — dashboard event feed, two review rounds.** Review caught two races that
the first cut did not have. First, subscription lifetime: subscriptions are now
@@ -348,6 +358,9 @@ boundary, so the priority class governs the frame's delivery point rather than o
its byte order. Getting the awaited-latency win too requires unparking the lock-race
loser from the winner's pass — a change to the write-lock shape, recorded as a
follow-up. One extra `FlushFileBuffers` per mixed pass is the accepted cost.
That lock-parking was closed by `docs/plans/2026-08-17-deferred-closeout.md`
Task 2, 2026-08-17: `WriteAsync` races its own frame's completion against the
lock acquisition and detaches the wait it abandons.
**Task 11 — teardown ordering and unconditional fault observation.** Teardown disposes
the session-owned transport first, then observes the read that dispose abandoned.
+469
View File
@@ -0,0 +1,469 @@
# Deferred Closeout Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (Opus implementers per user instruction; reviewer chain per each task's Classification).
**Goal:** Close every item the two perf-remediation plans left recorded-but-open: the dead
`ISessionManager.ReadEventsAsync` chain, the frame-writer lock-parking latency follow-up,
SEC-25 (per-session dashboard event ACL, design already approved in
`docs/plans/2026-07-10-dashboard-session-acl-tst15.md`), the structural alarm-truncation
degraded-status signal (proto change), and the wnwrap live-alarm probes (GUID identity,
`ALARM_RECORDS/@COUNT` semantics) that need windev state.
**Architecture:** Same two-phase posture as the prior plans. Gateway-side work builds and
tests on macOS via `NonWindows.slnx`; worker-side work (frame writer, alarm consumer,
worker command executor) is edited on the Mac and verified on windev. **This plan touches
`.proto` contracts** (Task 8) — contracts regeneration and all five clients rebuild are in
scope (Task 9), unlike the prior two plans. The windev probe task (Task 3) is gated on
external state (live alarms) and may legitimately end "blocked — recorded".
**Tech stack:** .NET 10 gateway / .NET Framework 4.8 x86 worker / protobuf contracts /
five language clients / Blazor Server dashboard / GLAuth LDAP.
**Branch:** `feat/deferred-closeout` off `main` (`ac3f04f`).
---
## Ground rules for every implementer subagent
- NEVER run `git stash`, `git reset`, `git clean`, or `git checkout <sha/branch>`. Commit
with explicit pathspecs only — never `git add -A` / `git commit -a`.
- Build/test mutual exclusion: before any `dotnet build`/`dotnet test`, acquire the lock via
`mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock`
(retry with backoff while it fails); `rmdir` it on ALL exit paths, including failures.
- `TreatWarningsAsErrors=true`, `Nullable=enable` — new warnings break the build; fix, don't suppress.
- Follow `docs/style-guides/CSharpStyleGuide.md`: file-scoped namespaces, `sealed` by
default, `Async` suffix, MXAccess-aligned names.
- Update affected docs in the same commit as the source change.
- MXAccess parity is the contract; never synthesize events.
- Never log secrets, API keys, credentials, or tag values.
- The `Files:` block is the scope contract. If the task can't be done inside it, that's a
plan defect — surface it, don't silently expand scope.
- On macOS build `NonWindows.slnx`; the x86 Worker and full `slnx` only build on windev.
---
## Task 1: Remove the dead `ISessionManager.ReadEventsAsync` chain
**Classification:** standard
**Estimated implement time:** ~6 min
**Parallelizable with:** Task 2, Task 3, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/ISessionManager.cs:42` (remove member)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs:191-197` (remove implementation)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (~:1517-1545 remove `ReadEventsAsync`; ~:767-776 rewrite the "keep the two bodies in step" comment on `MapWorkerEventsAsync` — with the twin gone it now claims the single worker-event read path directly)
- Modify: every test fake implementing `ISessionManager` (grep `ISessionManager` under `src/ZB.MOM.WW.MxGateway.Tests/` — the as-built note in `docs/plans/2026-08-15-deferred-remediation.md` estimated ~15 touches; remove the member from each fake and any tests that exercised it *through the interface*)
- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 3 — ReadEventsAsync retained" (append one line: removed by this plan, date)
**Spec:** `ISessionManager.ReadEventsAsync` has zero production call sites — only test fakes
implement and exercise it. Remove the interface member, `SessionManager`'s forwarder, and
`GatewaySession.ReadEventsAsync` (verify with grep first that `MapWorkerEventsAsync` and the
fakes are truly the only remaining references — if a production caller appears, STOP and
report; do not force it). **`IWorkerClient.ReadEventsAsync` / `WorkerClient.ReadEventsAsync`
stay** — that is the live worker-channel claim; most grep hits are that member, read carefully.
Tests that existed solely to exercise the pass-through die with it; tests that used a fake's
`ReadEventsAsync` as a convenience seam get rewired to the distributor path or removed if
redundant — judgment call, state it in the commit body.
**Steps:** grep callers → edit → `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`
`dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter "FullyQualifiedName~GatewaySession"`,
`~SessionManager`, `~EventStreamService` → commit
`refactor(sessions): remove the dead ISessionManager.ReadEventsAsync chain`.
---
## Task 2: Frame-writer lock-parking — awaited control-frame completion unparked from the winner's pass
**Classification:** high-risk
**Estimated implement time:** ~10 min
**Parallelizable with:** Task 1, Task 3, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerFrameWriter.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/` frame-writer tests (windev-run; write them now)
- Modify: `docs/WorkerFrameProtocol.md` (completion-latency contract paragraph)
- Modify: `docs/plans/2026-08-15-deferred-remediation.md` as-built note "Task 10" (append: lock-parking closed by this plan)
**Spec:** Today `WriteAsync` enqueues the frame, then unconditionally contends for
`_writeLock`; a caller that loses the race stays parked in `WaitAsync` until the winning
drainer releases the lock — even though the winner writes *and flushes* the loser's control
frame mid-pass and completes its per-frame completion source at that moment
(`WorkerFrameWriter.cs:112-116` documents this parking as the deliberate residual). Close it:
after enqueueing, the caller awaits **its own frame's completion** racing the lock
acquisition — when the completion resolves first (frame written+flushed by the winner), the
caller returns immediately; its abandoned lock-wait must not leak drain responsibility.
Shape (implementer refines, invariants below are the contract):
1. Await `Task.WhenAny(frame.Completion.Task, lockWaitTask)`.
2. Completion first → detach: register a continuation on `lockWaitTask` that, on acquisition,
drains any queued frames if present and releases — the lock is never acquired-and-dropped,
and a frame enqueued between the winner's last dequeue and its release still gets drained
(the existing "drain everything you can see, then release" loop already covers most of
this; the continuation is the backstop for the abandoned waiter).
3. Lock first → drain as today.
4. Cancellation during the combined wait keeps the existing tombstone semantics
(`WorkerFrameWriter.cs:104-110`): tombstone only if a drainer hasn't claimed the frame;
a claimed frame completes normally and cancellation is *not* surfaced for it.
**Invariants that must hold (write a test for each):** every enqueued frame is eventually
written or tombstoned (no stranded frame when the completion-first path abandons its lock
wait); completions still resolve only after write+flush; batch API (`WriteBatchAsync`)
semantics unchanged; no double-drain / double-release; a control-frame caller racing a long
event batch observes its completion before the batch drain finishes (the latency win this
task exists for — assert with a gated slow-stream fake).
**Steps:** edit → macOS `dotnet build` of the shared-source projects is NOT possible for the
worker — verify compile on windev (`dotnet build src\ZB.MOM.WW.MxGateway.Worker\... -p:Platform=x86`)
via `ssh windev` before commit if feasible, else mark the commit "edited, windev-pending" and
Task 10/11 gates it → commit
`perf(worker): unpark awaited control-frame writers from the winning drain pass`.
---
## Task 3: windev live-alarm probes — GUID identity and `ALARM_RECORDS/@COUNT` semantics
**Classification:** standard (investigation; no gateway code — findings doc only)
**Estimated implement time:** ~15 min wall (timeboxed; may end "blocked")
**Parallelizable with:** Task 1, Task 2, Task 4, Task 5
**Files:**
- Read (on windev): `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/WnWrapConsumerProbeTests.cs` (Skip-gated probe; flip `Skip=null` locally on windev, never commit the flip)
- Create: `docs/AlarmProbeFindings.md` (findings record, or the explicit "blocked" record)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` — comments ONLY, and only if a finding confirms/refutes a documented assumption (no behavior change in this task)
**Spec:** Two questions only a live rig answers:
1. **GUID identity semantics:** is the alarm record GUID stable for one alarm *instance*
across polls and across state transitions (UNACK→ACK→RTN)? `ComputeTransitions` keys the
diff on GUID (`latestSnapshot: Dictionary<Guid, ...>`); if wnwrap mints a new GUID on a
state change, a transition would read as clear+new instead.
2. **`ALARM_RECORDS/@COUNT`:** when the fetch is capped (`maxAlmCnt` < actives), does the
reply's `COUNT` attribute carry the *total* active count (a usable "more available"
signal) or just the records-in-reply count? If it carries the total,
`IsTruncatedFetch` can become exact instead of the ≥cap heuristic — feed that finding to Task 8.
**Steps:**
1. `ssh windev` (lands in PowerShell; CI clone `C:\build\mxaccessgw-ci`; first build after a
pull may fail on stale Contracts obj — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin`
and rebuild, it is not a regression). Pull the branch.
2. Determine whether live alarms exist or can be raised: check provider state; try driving a
known alarmed attribute over its limit via a gateway write or a Galaxy test object.
Timebox 15 minutes. If no alarm can be made active: write `docs/AlarmProbeFindings.md`
recording exactly what was tried and that both questions remain open, commit, STOP.
3. With ≥1 live alarm: run the probe (`WnWrapConsumerProbeTests`, Skip flipped locally, cap
`maxAlmCnt` low, e.g. 12, to force truncation), ack the alarm, let it RTN, capture the
XML across the transitions. Record: GUID per state, `COUNT` vs records-in-reply under the
forced cap.
4. Write findings + implications (for `ComputeTransitions` and `IsTruncatedFetch`) into
`docs/AlarmProbeFindings.md`; adjust `WnWrapAlarmConsumer` comments where an assumption is
now confirmed/refuted. Any *behavioral* fix the findings demand is reported to the
orchestrator (feeds Task 8, or a follow-on task if it's transition-identity surgery) — not
done here.
5. Commit `docs(alarms): wnwrap live-probe findings — GUID identity, ALARM_RECORDS COUNT` .
---
## Task 4: SEC-25 groundwork — `DashboardTags` on the API key, `Tags` on the session
**Classification:** high-risk
**Estimated implement time:** ~8 min
**Parallelizable with:** Task 2, Task 3, Task 5 (NOT Task 1 — both touch `GatewaySession.cs` and session fakes; run after Task 1 lands)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs` (add `IReadOnlyList<string> DashboardTags`, default empty)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs` (round-trip the new field; absent-in-JSON → empty — old rows keep deserializing)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on `create-key` — there is no `update` subcommand; shown in list output)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (new `public IReadOnlySet<string> Tags { get; }`, set at construction from the owner key's effective constraints; empty = untagged)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs` (`OpenSession` path copies `ApiKeyIdentity.EffectiveConstraints.DashboardTags` onto the session)
- Test: serializer round-trip incl. legacy-JSON-without-field; CLI parse; session tag inheritance via the fake-worker harness
- Modify: `docs/Authorization.md` (`DashboardTags` is dashboard-visibility-only, never a data-access constraint)
**Spec:** Implements §3 of `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. The §9
open call is settled per the design's own recommendation: **the tag rides in the existing
`ApiKeyConstraints` JSON blob — no SQLite schema migration.** Tags are immutable per session,
assigned once at `OpenSession` from the owning key. No enforcement in this task — the field
and its plumbing only. Case handling: preserve tag strings as entered; comparisons later
(Task 6) are ordinal-ignore-case — note that on the property doc.
**Steps:** edit → build NonWindows.slnx → targeted tests (`~ApiKeyConstraint`, `~ApiKeyAdmin`,
`~SessionManager`) → commit
`feat(security): DashboardTags on API-key constraints; sessions inherit owner tags (SEC-25 groundwork)`.
---
## Task 5: SEC-25 config — `GroupToTag`, `UntaggedSessionVisibility`, validator, mapper
**Classification:** standard
**Estimated implement time:** ~6 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/``DashboardOptions` (`Dictionary<string,string[]> GroupToTag` ordinal-ignore-case; `UntaggedSessionVisibility` enum `AdminOnly`(default)`|AllViewers`) + `GatewayOptionsValidator` (keys non-empty, tag arrays non-null/non-empty entries, enum known — mirror the `GroupToRole` validation shape)
- Create: group→tag mapper as a sibling of `DashboardGroupRoleMapping` (union of tags over the principal's LDAP groups; unknown group → nothing)
- Test: validator accept/reject cases; mapper union/unknown-group/case-insensitivity
- Modify: `docs/GatewayConfiguration.md` (`Dashboard:GroupToTag`, `Dashboard:UntaggedSessionVisibility` rows)
**Spec:** §3.2 of the design doc, exactly. No coupling to `GroupToRole` — a group may appear
in one, the other, or both.
**Steps:** edit → build → `--filter "FullyQualifiedName~GatewayOptionsValidator"` + mapper
tests → commit `feat(dashboard): GroupToTag / UntaggedSessionVisibility config (SEC-25)`.
---
## Task 6: SEC-25 enforcement — ACL service, token/cookie tag claims, both subscribe seams gated
**Classification:** high-risk
**Estimated implement time:** ~10 min
**Parallelizable with:** none (needs Tasks 4 + 5)
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/` `IDashboardSessionAcl` + implementation (`CanViewSession(ClaimsPrincipal, string sessionId)`; DI singleton)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs` (payload gains `string[]? Tags`; mint stamps resolved granted tags as `zb:dashboardtag` claims; validate rehydrates them)
- Modify: dashboard cookie principal creation (`DashboardAuthenticator.CreatePrincipal` per the design doc — grep for the exact site) so cookie-authenticated circuits carry the same tag claims
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs:42-60` (`SubscribeSession` checks the ACL; deny → `HubException`, no group join; **remove the `TODO(per-session-acl)`**)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor` (in-process seam gate: resolve the circuit principal via `AuthenticationStateProvider`, check `IDashboardSessionAcl.CanViewSession` **before** `Subscribe(SessionId)`; deny → render "Not authorized for this session's events." in place of the event panel, no subscription created)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs` (register the ACL)
- Test: `DashboardSessionAclTests` (admin bypass; session-not-found deny; untagged under both visibility values; tag intersection match/non-match; principal-with-no-claims = empty-grant Viewer); `HubTokenService` tag round-trip; `EventsHub` deny-does-not-join; SessionDetailsPage gate (deny renders banner, allow subscribes — follow the existing bUnit/component test idiom in `Gateway/Dashboard/`)
- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` — add an as-built note: the 2026-08 in-process feed refactor created a **second seam** (`IDashboardSessionEventSubscriber` used by SessionDetailsPage); "gating at join is sufficient — no second seam" (§4) no longer held, so the same ACL now gates both.
**Spec:** §§2, 4, 4.1 of the design doc, with one deviation the doc predates: the dashboard
now also consumes session events **in-process** (SessionDetailsPage → `Subscribe(sessionId)`),
so enforcement lands in two places — the hub `SubscribeSession` (remote surface) and the
page-side check before the in-process subscribe. ACL decision order: Admin role → allow;
session unknown → deny; untagged → `UntaggedSessionVisibility == AllViewers`; else
`session.Tags ∩ grantedTags ≠ ∅` (ordinal-ignore-case). Anonymous localhost = Viewer with
empty grant (§4.1) — this *tightens* SEC-02's loopback posture by design; `DisableLogin`
auto-login carries both roles → admin bypass, unchanged. Fail closed everywhere. Never log
tag grants alongside credentials.
**Steps:** edit → build → `--filter "~DashboardSessionAcl"`, `~EventsHub`, `~HubTokenService`,
`~SessionDetailsPage` → commit
`feat(dashboard): per-session event ACL on both subscribe seams (SEC-25 / TST-15)`.
---
## Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status
**Classification:** standard
**Estimated implement time:** ~7 min
**Parallelizable with:** Task 8, Task 9
**Files:**
- Test: extend `DashboardLdapLiveTests` (gated `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`): map the existing GLAuth groups via config — `gw-viewer`'s group granted `team-a` through `Dashboard:GroupToTag`, a `team-a`-tagged session allows, a `team-b`-tagged session denies; `multi-role` (Admin) allows both. **Use the existing GLAuth users/groups (`glauth.md`) — no GLAuth server change.**
- Modify: `docs/Sessions.md` (session-tag model, dashboard event visibility), `gateway.md` dashboard section, `CLAUDE.md` dashboard-auth paragraph (Viewer is tag-scoped; Admin sees all; anonymous localhost = empty-grant Viewer), `glauth.md` (test-grant mapping used by the live tests — config-side only), `docs/GatewayDashboardDesign.md` (remove/replace the two "SEC-25 outstanding" passages at ~:277 and ~:699 — the ACL now exists; describe it)
- Modify: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` header — Status: Implemented (this plan, date)
- Modify: `archreview` tracking rows for TST-15/SEC-25 if a `Not started` row exists (mark Done with commit ref)
**Spec:** §§78 of the design doc. Live tests are opt-in-gated exactly like the existing
`LiveLdapFactAttribute` suite; they must skip cleanly where GLAuth is unreachable.
**Steps:** edit → build → run unit portions + (if GLAuth reachable from this Mac,
`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` run; else record skipped per `docs/GatewayTesting.md`) →
commit `test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented`.
---
## Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard
**Classification:** high-risk
**Estimated implement time:** ~10 min
**Parallelizable with:** Task 7 (after Task 3's findings are in hand, or Task 3 recorded "blocked")
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto``ActiveAlarmSnapshot` gains `bool from_truncated_snapshot` (next free field number); `QueryActiveAlarmsReplyPayload` gains `bool snapshot_truncated`. Additive only; comment style per the file.
- Regenerate: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` (never hand-edit `Generated/`)
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (expose the retained truncated state, e.g. `bool LastSnapshotTruncated`, maintained where `ApplySnapshotUpdate` already receives `truncated`) + `IMxAccessAlarmConsumer.cs` + the `QueryActiveAlarms` reply builder in `MxAccessCommandExecutor.cs`/`AlarmDispatcher.cs` (stamp both new fields)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` + `IGatewayAlarmService.cs` (+ its implementation) — propagate degraded state to the dashboard snapshot model (`DashboardActiveAlarm.cs` or a sibling flag on the alarm snapshot the service exposes)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` — the public `QueryActiveAlarms` stream carries `from_truncated_snapshot` through
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor` — degraded banner ("Alarm snapshot may be incomplete — provider returned a capped fetch") driven by the service flag; reuse the existing alert-banner idiom, do not disturb the poll-loop structure landed in `dcbec97`
- Test: worker XML tests (`WnWrapAlarmConsumerXmlTests` idiom) for the exposed state; gateway fake-worker test that a truncated reply surfaces the flag end-to-end; AlarmsPage banner test
- Modify: `gateway.md` (alarm surface paragraph), `docs/DesignDecisions.md` (one entry: why per-record flag + reply-payload flag, wire-compatibility, absence-inference already suppressed worker-side since the truncation cliff fix)
**Spec:** The truncation cliff fix (perf plan Task 23) made transitions safe but *silent*:
only a worker-stderr warning says the snapshot is degraded. Give it a structural signal:
worker stamps truncation state into the `QueryActiveAlarms` reply; gateway propagates it to
the public stream (per-record flag — the RPC returns a bare `stream ActiveAlarmSnapshot`
with no envelope, so a per-record boolean is the only additive carrier) and to the dashboard
alarm service; AlarmsPage shows the degraded banner. **If Task 3 found that
`ALARM_RECORDS/@COUNT` carries the true total, also replace the `IsTruncatedFetch` ≥cap
heuristic with the exact comparison in the same commit** (parse `@COUNT`, compare to records
delivered; keep the heuristic as fallback when the attribute is absent) — cite the findings
doc. If Task 3 was blocked, keep the heuristic untouched and say so in the commit body.
MXAccess parity note: this flag describes *our* fetch mechanics, not provider behavior — it
is additive gateway metadata, not a parity deviation.
**Steps:** proto edit → regenerate → worker+gateway edits → build NonWindows.slnx + gateway
tests (`~Alarm`, `~AlarmsPage`) — worker compile/tests defer to the Task 11 windev gate →
commit `feat(alarms): structural degraded-status signal for truncated alarm snapshots`.
---
## Task 9: Client regeneration + rebuild for the new alarm fields
**Classification:** standard
**Estimated implement time:** ~8 min
**Parallelizable with:** Task 7 (needs Task 8's proto committed)
**Files:**
- Regenerate per each client's README: `clients/dotnet`, `clients/python`, `clients/rust`, `clients/java`, Go (`clients/go` / `mxgw-go` layout — follow its README)
- Modify: each client's README alarm section IF it documents the snapshot shape (one line: `from_truncated_snapshot` means the provider fetch was capped and absent-implies-cleared inference was suspended)
- Test: `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx` + tests; `python -m pytest` in `clients/python`; `cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --all-targets -- -D warnings` in `clients/rust`; `gradle test` in `clients/java`; `gofmt`/`go build ./...`/`go test ./...` in `clients/go`
**Spec:** Additive proto fields — codegen carries them; no typed wrapper work unless a client
already wraps `ActiveAlarmSnapshot` in a typed model (then add the field there too, following
how `ReplayGap` surfacing was done per-client). All five clients must build and test green.
Use the build lock around each build/test.
**Steps:** regenerate → build/test each client → commit
`chore(clients): regenerate for alarm truncation fields; READMEs note the degraded flag`.
---
## Task 10: Phase gate — full gateway suite on macOS
**Classification:** high-risk (gate)
**Estimated implement time:** ~5 min wall
**Parallelizable with:** none (after Tasks 1, 49)
Full `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` (with the
build lock) + `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx`. Any failure: fix
forward with a scoped commit, re-run the failed filter, then re-run the full suite once.
---
## Task 11: windev gate — full Windows verification
**Classification:** high-risk (gate)
**Estimated implement time:** ~15 min wall
**Parallelizable with:** none (last verification)
On windev (`ssh windev`, PowerShell, clone `C:\build\mxaccessgw-ci`; expect the stale
Contracts-obj first-build quirk — clear `src\ZB.MOM.WW.MxGateway.Contracts\obj,bin` and
rebuild, not a regression):
```powershell
git pull # the feat/deferred-closeout branch
dotnet build src\ZB.MOM.WW.MxGateway.slnx
dotnet build src\ZB.MOM.WW.MxGateway.Worker\ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
dotnet test src\ZB.MOM.WW.MxGateway.Worker.Tests\ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86
dotnet test src\ZB.MOM.WW.MxGateway.Tests\ZB.MOM.WW.MxGateway.Tests.csproj
```
This is the first verification of Task 2's writer restructure and Task 8's worker-side
changes — expect iteration; fix on the Mac, commit, re-run the failed leg. Live MXAccess
smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) if provider state allows; else record why
skipped.
---
## Task 12: Wrap-up — closure notes, umbrella check, final review
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (last)
- Append closure lines to `docs/plans/2026-08-15-deferred-remediation.md`'s out-of-scope
table (each row: resolved by this plan + date, or — for the probe row if Task 3 blocked —
"probes attempted <date>, blocked on live alarms, findings doc records the attempt").
- Check `../scadaproj/CLAUDE.md`'s MxAccessGateway entry: the proto *contents* changed
(additive fields) — update only if the index records a fact that changed (expected: no
change needed; verify, don't assume).
- Update `.tasks.json`; update auto-memory (`perf-remediation-branch.md` or successor).
- Dispatch the final integration code review (Opus) over `git diff main..feat/deferred-closeout`.
Merge remains the user's decision.
---
## Explicitly out of scope
| Item | Why |
|---|---|
| gRPC "all-sessions admin scope" (epic Task 16's gRPC half) | Stays with the session-resilience epic; the design doc itself scopes it out of TST-15. |
| Session-list row filtering by tag on SessionsPage | The approved design gates *event subscription* only; list-metadata visibility is a separate call. |
| jti-denylist token revocation | Rejected in the design (§10.3); 5-min token lifetime bounds grant staleness. |
| Behavioral rework of `ComputeTransitions` identity if the GUID probe refutes stability | Needs its own reviewed design against real captures; Task 3 records the evidence, a follow-on plan acts on it. |
| `MxAccessWriteCompletionCache` clone | Consciously kept (prior plan Task 12.5); unchanged posture. |
---
## As-built notes (execution record, 2026-08-17)
All 12 tasks completed on `feat/deferred-closeout`; every implement went through its
classification's review chain to Approved. Verification: macOS build 0W/0E + gateway
1123/1123; windev full slnx 0W/0E, worker x86 523/523 (11 standing opt-in skips),
gateway 1123/1123, live MXAccess smoke 8/8 — green first try, no stale-obj quirk.
- **Task 3 ended blocked, with evidence.** The rig refuses the alarm-condition writes
(`SecurityError` from the responding automation object — the test UDAs need
`AuthenticateUser`+`WriteSecured` or the in-engine flip script that drove the
2026-05-01 capture). `docs/AlarmProbeFindings.md` records what was tried, the
partial GUID answer (active→returned leg confirmed), and the unblock paths.
- **Commit `693a78d` contains two tasks' work.** A concurrent implementer's `git commit`
without pathspecs swept Task 6's staged ACL files into Task 8's alarm commit. Nothing
was lost; both halves were reviewed separately and their fix rounds (`7ec0b35`,
`b9fb0dd`) are clean single-task commits. Process rule tightened mid-run: pathspecs
on the commit itself, not just the add.
- **Task 8's atomicity fix went structural.** Review found the flag/snapshot pairing
relied on STA serialization while claiming a read-order guarantee; the fix made
`SnapshotActiveAlarms(out bool truncated)` the *only* accessor, so the unpaired read
is unexpressible.
- **Task 6 review caught a real regression** (async attach re-entrancy under rapid
navigation) — closed with a generation guard and a mutation-verified interleaving test.
- **Live-LDAP ACL tests ran green against the shared GLAuth** (7/7, plus 7/7 clean-skip
without the env gate). `multi-role` sits in both GLAuth groups, so the Admin-bypass
test asserts the sessions a dropped bypass would actually lose (team-b + untagged).
- **Task 9 found and fixed pre-existing drift**: `clients/rust/protos/mxaccess_gateway.proto`
had diverged from Contracts (masked by the in-repo build path); refreshed byte-identical,
and the client protoset descriptors were regenerated.
Follow-ups recorded here — all closed or narrowed on 2026-08-18 (branch
`feat/followup-closeout`, plan `docs/plans/2026-08-17-followup-closeout.md`).
Each bullet states what it said when written, then its closure:
- `IGatewayAlarmService.StreamAsync` / `AlarmFeedMessage` did not carry the truncation
signal — live central-feed consumers (lmxopcua, ScadaBridge) could not see snapshot
degradation. **Closed 2026-08-18 — shipped.** `AlarmSnapshotStatus` with
`snapshot_status = 5` on the feed: gateway-side edge emission plus open-time priming
(`fccf753`, `312e072`), rolled out to all five clients (`c748361`, `4d4f443`).
- "No guard keeps `clients/rust/protos/` in sync with Contracts." **Was stale when
written.** `scripts/check-codegen.ps1` Check 3 already existed and already byte-compared
by SHA-256 in CI. The real gap was narrower — the sweep ran in one direction only, so a
canonical proto with no vendored copy passed. **Closed 2026-08-18** by `711793f`: Check 3
now sweeps both directions and a canonical proto missing its vendored copy fails.
- `EffectiveDashboardConfiguration` (dashboard settings page) didn't display
`GroupToTag` / `UntaggedSessionVisibility`, though it showed `GroupToRole`.
**Closed 2026-08-18 — shipped** (`094f2ff`; comparer propagation fixed in `ff16c69`).
- ApiKeysPage's `ConstraintText` neither offered tag input nor listed `DashboardTags`,
and since `IsEmpty` counts tags, a tags-only key rendered `-` where a truly
unconstrained key rendered `unconstrained`. **Closed 2026-08-18 — shipped** (`c037d99`),
with the create form de-duplicating typed tags ordinal-ignore-case to match the CLI
(`fab600d`).
- `AlarmsHubPublisher` broadcast alarm transitions with `current_value`/`limit_value`
unredacted — the `ShowTagValues` redaction covered only the events hub mirror.
**Closed 2026-08-18** together with the `/browse` residual: `ShowTagValues` now gates
all three seams (`eff17d1`, `1ea6f60`, `e50ec49`).
- The alarm probes' remaining questions (ack-leg GUID stability, `@COUNT` semantics).
**Narrowed 2026-08-18** by a second probe attempt that used the secured-write verb the
first attempt lacked (`90331b6`, `540a118`):
- `@COUNT`**answered.** It reports records in the reply, not matching records in the
provider, so exact truncation detection through it is permanently refuted; the
heuristic in `IsTruncatedFetch` stays as the only available signal.
- clear-then-re-raise GUID — **answered.** The re-raise carries a new GUID that replaces
the record rather than coexisting with it, which makes the existing `Clear`+`Raise`
handling correct as written.
- ack leg — **still blocked**, now with a sharper cause: the ack returns `rc=0` and is
accepted but inert, so the GUID never moves. Remaining unblock paths are recorded in
`docs/AlarmProbeFindings.md`.
---
## Execution notes for the orchestrator
- Branch `feat/deferred-closeout` off `main` before Task 1.
- Opus implementers per user instruction; reviewer chain per Classification
(high-risk = spec-reviewer serial then code-reviewer; standard = parallel pair; small = code-reviewer only).
- Waves: **Wave 1:** 1, 2, 3, 5 · **Wave 2:** 4 (after 1) · **Wave 3:** 6 (after 4+5), 8 (after 3 findings/blocked) ·
**Wave 4:** 7, 9 · then 10 → 11 → 12. Per-task `Parallelizable with` fields are the contract.
- Each implementer gets its full task text + the ground rules block. `Files:` is the scope contract.
- Task 3 and Task 11 run against windev over `ssh windev` (PowerShell); psbridge is available
as fallback transport.
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-08-17-deferred-closeout.md",
"tasks": [
{ "id": 1, "subject": "Task 1: Remove dead ISessionManager.ReadEventsAsync chain", "status": "completed" },
{ "id": 2, "subject": "Task 2: Frame-writer lock-parking — unpark awaited control-frame completion", "status": "completed" },
{ "id": 3, "subject": "Task 3: windev live-alarm probes — GUID identity, ALARM_RECORDS/@COUNT", "status": "completed" },
{ "id": 4, "subject": "Task 4: SEC-25 groundwork — DashboardTags on key, Tags on session", "status": "completed", "blockedBy": [1] },
{ "id": 5, "subject": "Task 5: SEC-25 config — GroupToTag, UntaggedSessionVisibility, validator, mapper", "status": "completed" },
{ "id": 6, "subject": "Task 6: SEC-25 enforcement — ACL, token/cookie tag claims, both seams gated", "status": "completed", "blockedBy": [4, 5] },
{ "id": 7, "subject": "Task 7: SEC-25 closure — live-LDAP tests, docs sweep, design-doc status", "status": "completed", "blockedBy": [6] },
{ "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "completed", "blockedBy": [3] },
{ "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "completed", "blockedBy": [8] },
{ "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "completed", "blockedBy": [1, 4, 5, 6, 7, 8, 9] },
{ "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "completed", "blockedBy": [2, 10] },
{ "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "completed", "blockedBy": [11] }
],
"lastUpdated": "2026-08-17T00:00:00Z"
}
+512
View File
@@ -0,0 +1,512 @@
# Follow-up Closeout Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> to implement this plan task-by-task in this session.
**Goal:** Close the follow-ups recorded in `docs/plans/2026-08-17-deferred-closeout.md`
(as-built notes, "Follow-ups recorded, not started"): the feed-level alarm-truncation
signal on `StreamAlarms`/`AlarmFeedMessage`, the `ShowTagValues` redaction gaps
(alarms hub + `/browse` live values — TST-16 residual), the codegen guard's
one-directional Rust check (the recorded "no guard" note is stale — Check 3 exists),
the dashboard settings page's missing `GroupToTag`/`UntaggedSessionVisibility` rows,
the ApiKeysPage `DashboardTags` display/input gaps, and a bounded retry of the wnwrap
alarm probes using the secured-write verb the first attempt didn't use.
**Architecture:** Same two-phase posture as the prior three plans. Gateway-side work
builds and tests on macOS via `NonWindows.slnx`; windev (`ssh windev`, PowerShell, CI
clone `C:\build\mxaccessgw-ci`) runs the probe retry and final full-matrix verification.
**This plan touches `.proto` contracts** (Task 1) — contracts regeneration and the
five-client rollout (Task 2) follow the exact pattern Task 9 of the previous plan used.
The gateway-side truncation edge needs **no worker change**: `GatewayAlarmMonitor`
already receives `snapshot_truncated` on every reconcile; the feed frame is raised
gateway-side (per-reconcile fidelity, matching the operator-facing caveat's semantics).
**Tech stack:** .NET 10 gateway / protobuf contracts / five language clients / Blazor
Server dashboard / PowerShell codegen scripts / windev live rig.
**Branch:** `feat/followup-closeout` off `main` (`ab3ff16`).
---
## Ground rules for every implementer subagent
- NEVER run `git stash`, `git reset`, `git clean`, or `git checkout <sha/branch>`.
Commit with explicit **pathspecs on the commit itself**:
`git commit -m "..." -- <paths>` — never `git add -A`, `git commit -a`, or a bare
`git commit` after add (a concurrent task's staged files would be swept in).
- Build/test mutual exclusion: before any `dotnet build`/`dotnet test`, acquire the lock via
`mkdir /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/f36938ae-bbca-4245-b5c9-fac512d69e22/scratchpad/buildlock`
(retry with backoff while it fails); `rmdir` it on ALL exit paths, including failures.
- `TreatWarningsAsErrors=true`, `Nullable=enable` — new warnings break the build; fix, don't suppress.
- Follow `docs/style-guides/CSharpStyleGuide.md`: file-scoped namespaces, `sealed` by
default, `Async` suffix, MXAccess-aligned names.
- Update affected docs in the same commit as the source change.
- MXAccess parity is the contract; never synthesize events. The feed truncation frame is a
gateway-status frame (like `provider_status`), not a synthesized MXAccess event.
- Never log secrets, API keys, credentials, or tag values.
- The `Files:` block is the scope contract. If the task can't be done inside it, that's a
plan defect — surface it, don't silently expand scope.
- On macOS build `NonWindows.slnx`; the x86 Worker and full `slnx` only build on windev.
- `git commit` hitting `index.lock` contention → wait 5 s and retry.
---
## Task 1: Feed-level alarm-truncation signal (proto + gateway)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs` (doc comment only)
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Alarms/` (existing `GatewayAlarmMonitor` test class(es))
- Docs: `gateway.md`, `docs/Grpc.md`, `docs/DesignDecisions.md`
- Build output: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/` (regenerated, committed)
Do NOT touch `docs/GatewayConfiguration.md` or `docs/GatewayDashboardDesign.md` (owned
by Task 3 this wave) or any `clients/**` path (Task 2).
**Spec.**
Proto (`mxaccess_gateway.proto`) — additive only:
1. New message next to `AlarmProviderStatus` (`:1025-1030`):
```proto
// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
// (after the initial provider_status frame, before the cached active_alarm frames)
// so late joiners learn the current verdict, and again on every change of the
// truncation verdict observed at reconcile. Mirrors the per-record
// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
// consumers (lmxopcua, ScadaBridge) can reason about completeness without
// polling QueryActiveAlarms. Additive in v1; absent frames mean "not truncated"
// only for streams opened against gateways that emit the frame at open.
message AlarmSnapshotStatus {
// True while the monitor's cached active-alarm set derives from a truncated
// (capped) worker fetch — the set may be missing alarms. Distinct from
// provider degradation (AlarmProviderStatus.degraded).
bool truncated = 1;
}
```
2. New oneof case in `AlarmFeedMessage` (`:1006-1023`, next free field 5):
`AlarmSnapshotStatus snapshot_status = 5;`
Gateway (`GatewayAlarmMonitor.cs`):
- `ApplyReconcile` (`:632-694`) currently records `_snapshotTruncated = snapshotTruncated`
at `:691` with a comment saying truncation "needs no special handling here". Change:
detect the edge (old value ≠ new value) and, when it changes, broadcast
`new AlarmFeedMessage { SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated } }`
via `BroadcastToAll` (`:720-732` — the provider-status precedent at `:531`). Match the
existing locking discipline exactly: compute/record under `_sync` the way
`ApplyProviderModeChangeAsync` (`:513-566`) does, broadcast the way it does. Update the
now-stale comment at `:627-631`.
- Late-joiner priming in `StreamAsync` (`:748-800`): after the `provider_status` frame
(`:782`) and before the cached `active_alarm` frames (`:786`), yield one
`snapshot_status` frame carrying the current `_snapshotTruncated`, read under the same
lock that snapshots `_alarms` (`:763-780`) so the flag and the alarm set are a
consistent pair.
- `ClearCache` (`:742`) resets `_snapshotTruncated` to false — decide and document
whether the reset emits a frame (it should: a monitor restart that drops a truncated
verdict is a completeness change subscribers must see; emit via the same edge path).
- `MxAccessGatewayService.StreamAlarms` is a pass-through — no change.
- `IGatewayAlarmService.SnapshotTruncated` doc comment (`:41-59`): note the new
feed-level frame so the two surfaces cross-reference.
Tests (extend the existing GatewayAlarmMonitor test class(es), matching their fake/
reconcile-driving idiom):
1. Reconcile flipping truncated false→true emits exactly one `snapshot_status` frame
(truncated=true) to an attached subscriber; a second identical reconcile emits none.
2. true→false emits the clearing frame.
3. A subscriber attaching while truncated=true receives, in order: `provider_status`,
`snapshot_status(truncated=true)`, cached `active_alarm`s, `snapshot_complete`.
4. A subscriber attaching while truncated=false receives `snapshot_status(truncated=false)`
at open (the baseline frame is unconditional).
Docs, same commit:
- `gateway.md` §"truncated-snapshot visibility" (`:243-262`): add the feed-level frame,
emission rules (open + edges), and ordering.
- `docs/Grpc.md` `:105-120` (provider_status oneof case rules): add the fifth case with
its emission rules; `:95-99` StreamAlarms handler contract.
- `docs/DesignDecisions.md` `:206-230` ("truncation is reported per record…", dated
2026-08-17): extend the entry — per-record stays for `QueryActiveAlarms` (bare stream,
no envelope); the live feed now carries the set-level signal as a status frame.
**Step 1:** Write the four monitor tests first (they fail: no `SnapshotStatus` case).
Building the Tests project will fail to compile until the proto field exists — so add
the proto change, rebuild Contracts (regenerates `Generated/`), then confirm the tests
fail for behavioral reasons (no frame emitted), not compile errors.
**Step 2:** Implement the monitor changes; run
`dotnet test src/ZB.MOM.WW.MxGateway.Tests/... --filter "FullyQualifiedName~GatewayAlarmMonitor"` → all pass.
**Step 3:** Full macOS build (`NonWindows.slnx`) 0W/0E + run the alarm-area test filter.
**Step 4:** Update the three docs.
**Step 5:** Commit with pathspecs (proto, Generated, monitor, interface, tests, 3 docs).
---
## Task 2: Five-client rollout of `snapshot_status`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7 (windev-only; disjoint files)
**Blocked by:** Task 1, Task 3, Task 4
**Files:**
- Modify: `clients/rust/protos/mxaccess_gateway.proto` (byte-identical refresh from Contracts)
- Regenerate: `clients/proto/descriptors/mxaccessgw-client-v1.protoset`
(via `scripts/publish-client-proto-inputs.ps1`), Go + Python generated bindings
(per-client `generate-proto.ps1`)
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs:1552-1565`
(feed renderer — add BOTH the missing `ProviderStatus` case and the new `SnapshotStatus` case)
- Modify: `clients/go/cmd/mxgw-go/main.go:1101-1114` (`formatAlarmFeedMessage` — same two cases)
- Modify: `clients/rust/crates/mxgw-cli/src/main.rs:2233-2264` and `:2266+`
(`alarm_feed_message_summary` / `alarm_feed_message_to_json` — add `SnapshotStatus` arm)
- Modify: READMEs — `clients/dotnet/README.md` (~`:152-158`), `clients/python/README.md`
(~`:118-123`), `clients/rust/README.md` (~`:126`), `clients/go/README.md` (~`:148-154`),
`clients/java/README.md` (~`:120-126`): one paragraph each on the feed-level frame
- Modify: `docs/GatewayDashboardDesign.md:276` (AlarmsHub payload-case row: add the new case)
**Spec.** Follow the exact rollout Task 9 of the prior plan used (recorded at
`docs/plans/2026-08-17-deferred-closeout.md:300-318`): regenerate everything from
Contracts, refresh the Rust vendored proto byte-identically, then verify with
`pwsh scripts/check-codegen.ps1` (all four checks green — note Task 4 may have added a
reverse-direction sweep to Check 3 by the time this runs; it must pass too).
Python/Java CLIs render generic protobuf-JSON — README paragraph only, no code.
Java's `build/resources/**` proto copies are untracked build output — do not commit.
**Steps:** regenerate → renderer cases → build/test each touched client per the
CLAUDE.md verification matrix (dotnet client slnx + tests; `gofmt`+`go build`+`go test`;
`cargo fmt`+`check`+`test`+`clippy -D warnings`; python `pytest`; java `gradle test`) →
`pwsh scripts/check-codegen.ps1` green → READMEs + dashboard-design row → commit with pathspecs.
---
## Task 3: Complete `ShowTagValues` coverage — alarms hub + `/browse` (TST-16 residual)
**Classification:** high-risk (security posture)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 4, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs`
- Modify (if value formatting sits there): `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardTagValue.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmsHubPublisherTests.cs` (new)
- Test: existing `DashboardLiveDataService` test class (extend), else new file alongside
- Docs: `docs/GatewayConfiguration.md:195` (the residual sentence),
`docs/GatewayDashboardDesign.md` (AlarmsHub row `:276` — redaction note; do NOT edit the
payload-case list, Task 2 owns that), `archreview/remediation/00-tracking.md:231`
(TST-16 residual row), `archreview/2026-07-12/60-testing-docs-gaps.md` (residual lines)
Do NOT touch `gateway.md` (owned by Task 1 this wave) or `SettingsPage.razor`/
`EffectiveDashboardConfiguration` (Task 5).
**Spec.**
Seam A — `AlarmsHubPublisher` (`:14-68`): today it forwards the raw `AlarmFeedMessage`
verbatim (`:40-43`), leaking `current_value`/`limit_value` from BOTH the `transition`
arm (proto `:882`, `:886`) and the `active_alarm` snapshot arm (proto `:931-932`) to any
`/hubs/alarms` browser client. Inject the dashboard options (mirror how
`DashboardEventBroadcaster.cs:53` captures `_showTagValues`); when `ShowTagValues` is
false, deep-`Clone()` the message and clear the four value fields before `SendAsync`.
**Never mutate the source message** — it is fanned out to gRPC `StreamAlarms` subscribers
and the AlarmsPage status loop (`DashboardEventBroadcaster.RedactValues` at `:247-259`
is the exact pattern, including the clone-only rule). When `ShowTagValues` is true,
forward as today. Frames with no value fields (`snapshot_complete`, `provider_status`,
and Task 1's `snapshot_status` if already merged — handle via default: clone only when
the arm carries values) pass through untouched.
Seam B — `/browse` live values: `BrowsePage.razor:133,:144,:147` renders
`value?.ValueText` unconditionally, fed by `DashboardLiveDataService.cs:99-101`
`DashboardTagValue.ValueText` (`DashboardTagValue.cs:41-45`,
`DashboardMxValueFormatter.FormatValue`). Redact **at the service boundary** (per the
original TST-16 recommendation, `archreview/remediation/60-testing-docs-gaps.md:346-352`):
when `ShowTagValues` is false, `DashboardLiveDataService` produces `ValueText` as the
literal `"[redacted]"` instead of the formatted value; quality/timestamp columns stay.
No `BrowsePage.razor` change should be needed — if one turns out to be, that's a plan
defect to surface. Keep the redaction decision in one place; don't duplicate the check
in the page.
Tests:
- `AlarmsHubPublisherTests` (new): reuse the `CapturingHubContext` shape from
`DashboardEventBroadcasterTests.cs:23` and the `DashboardSnapshotPublisherTests`
BackgroundService-driving template (fake stream service, internal ctor if needed).
Cases: ShowTagValues=false redacts both arms' value fields but keeps metadata;
source message not mutated; ShowTagValues=true passes values through;
valueless frames forwarded intact.
- `DashboardLiveDataService`: ShowTagValues=false → `ValueText == "[redacted]"`;
true → formatted value.
Docs: rewrite `docs/GatewayConfiguration.md:195` — the flag now covers the events hub
mirror, the alarms hub, and `/browse`; state the "[redacted]" rendering. Mark the TST-16
residual rows closed in both archreview files (match their existing status wording).
**Steps:** tests first (fail) → implement both seams → targeted filters
(`~AlarmsHubPublisher`, `~DashboardLiveDataService`, plus `~DashboardEventBroadcaster`
regression) → macOS build 0W/0E → docs → commit with pathspecs.
---
## Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 3, Task 5, Task 6
**Files:**
- Modify: `scripts/check-codegen.ps1:75-95` (Check 3)
- Docs: `docs/GatewayTesting.md:687-698` (Check 3 description),
`docs/ClientPackaging.md:199-213` (one-line note), `clients/rust/README.md:25-38`
(refresh-rule note if wording changes)
**Spec.** The recorded follow-up ("no guard keeps `clients/rust/protos/` in sync") is
stale — Check 3 (`:75-95`) already SHA-256-compares vendored↔canonical and runs in CI
(`.gitea/workflows/ci.yml:76-78`). The real gap: `:79` iterates only the **vendored**
dir, so a newly added canonical proto with no vendored copy passes silently and would
break a standalone crate build later. Add the reverse sweep: iterate
`$canonicalProtoDir`; any canonical `*.proto` with no same-named vendored counterpart →
append to `$failures` with a copy-to instruction, same reporting style as `:81-89`.
Keep all-checks-always-run behavior. Do NOT edit the closeout plan's follow-ups block —
Task 9 owns that file.
**Steps:** edit script → verify locally: `pwsh scripts/check-codegen.ps1` runs Check 3
both directions green (macOS pwsh is fine for Check 3/hashing; if Checks 2/4 can't run
locally, run Check 3's logic standalone and say so) → prove the new failure mode by
temporarily copying a scratch proto into the canonical dir under the scratchpad — NOT
into the repo — or by dry-running the loop against a temp dir pair → docs → commit with
pathspecs.
---
## Task 5: Settings page — show `GroupToTag` and `UntaggedSessionVisibility`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 3, Task 4, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/EffectiveDashboardConfiguration.cs:3-10`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayConfigurationProvider.cs:58-65`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SettingsPage.razor`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayConfigurationProviderTests.cs` (new)
- Test: page-render test alongside the existing dashboard render tests
(`AlarmsPageTruncationBannerTests.cs` is the template)
**Spec.** Add `GroupToTag` (`IReadOnlyDictionary<string, string[]>` or matching shape)
and `UntaggedSessionVisibility` to the `EffectiveDashboardConfiguration` record; populate
in `GatewayConfigurationProvider` (`:58-65`) from `DashboardOptions.GroupToTag` (`:78`)
and `.UntaggedSessionVisibility` (`:85`). Neither is a secret — no masking. Rendering:
`GroupToTag` row next to "Dashboard role mapping" (`SettingsPage.razor:50-67`), same
`<ul><li><code>group</code> → tag1, tag2</li></ul>` idiom with the `(none configured)`
empty case; `UntaggedSessionVisibility` as a scalar row in the Dashboard cluster
(`:77-80`). Tests: provider projection test (new ground — assert both members copied,
including the empty-dictionary case); render test asserting a configured mapping and the
visibility value appear in the emitted markup (HtmlRenderer idiom, no bUnit).
**Steps:** tests first → implement → `--filter` the two new test classes → macOS build
0W/0E → commit with pathspecs. (No doc change: `docs/GatewayConfiguration.md` already
documents both options; the settings page is self-describing.)
---
## Task 6: ApiKeysPage — `DashboardTags` in ConstraintText + create-form input
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 3, Task 4, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor`
- Test: new render/behavior test alongside the dashboard render tests
- Docs: `docs/Authorization.md` (dashboard API-key management section, if it describes
the create form's constraint fields)
**Spec.** Two defects, one page:
(a) `ConstraintText` (`:504-533`) never lists `DashboardTags`, so a tags-only key falls
through to `string.Join` of an empty list → `""``DashboardDisplay.Text` renders `-`,
while a truly unconstrained key says `unconstrained` — two spellings of one meaning,
except they're NOT the same meaning: the tags-only key IS constrained
(`ApiKeyConstraints.IsEmpty` counts tags, `ApiKeyConstraints.cs:62-71`). Fix by listing
it: `AddList(parts, "dashboard_tags", constraints.DashboardTags)` alongside the other
snake_case labels (~`:516`). `AddList` (`:535-541`) preserves order — keep that.
(b) The create form's Constraints section (`:97-133`) has no dashboard-tags input, so a
tags-only key can't be created from the dashboard at all. Add a textarea matching the
subtree/glob fields exactly: bind to a new `string DashboardTags` on `ApiKeyCreateModel`
(`:551-589`), clear it in `Reset()` (`:575-588`), split with the existing `ParseList`
(`:543-549`), and attach in `TryBuildCreateRequest` (`:415-455`) via object-initializer
on the `new ApiKeyConstraints(...)` at `:444-452` (init-only property):
`new ApiKeyConstraints(...) { DashboardTags = ParseList(CreateModel.DashboardTags) }`.
Help text: comma/newline-separated, matched case-insensitively against
`Dashboard:GroupToTag` grants; empty = untagged (visibility per
`UntaggedSessionVisibility`). Mirrors the CLI's `apikey create --dashboard-tags`.
Tests (HtmlRenderer page-render idiom): tags-only key renders `dashboard_tags: …` (not
`-`); unconstrained key still renders `unconstrained`; create-model round-trip —
`TryBuildCreateRequest` with a tags input yields constraints whose `DashboardTags`
matches (if the method is private, follow whatever access pattern the page's existing
tests use; an `internal`-for-testing hook is acceptable only if the repo already does
that elsewhere — otherwise drive through the rendered form or refactor minimally).
**Steps:** tests first → implement → `--filter` new test class → macOS build 0W/0E →
docs (only if `Authorization.md` enumerates the form fields) → commit with pathspecs.
---
## Task 7: Wnwrap alarm-probe retry — secured-write path (windev)
**Classification:** standard (investigation; may legitimately end blocked again)
**Estimated implement time:** ~10 min wall (windev round-trips)
**Parallelizable with:** Task 2
**Blocked by:** none (but runs in Wave 2 to keep windev free for it)
**Files:**
- Modify: `docs/AlarmProbeFindings.md` (append the attempt record + findings)
- Modify (only if findings answer the questions): comment-level updates in
`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:425-435, :490-505, :655-670`
- NO probe code committed; NO `Skip=` flips committed; throwaway harness lives only in
the windev CI clone and is deleted after.
**Spec.** The 2026-08-17 attempt failed because it used plain `Write` against classified
alarm UDAs (`SecurityError detail=1008`). The worker and live harness already implement
the right verb pair end-to-end (`WorkerLiveMxAccessSmokeTests.cs:445-583`
`AuthenticateUser` then `WriteSecured`; on this rig `AuthenticateUser("Administrator","")`
is known to resolve to user id 1, `:649`). Retry on windev (`ssh windev`, CI clone):
1. Pull the branch; build what the probe needs (worker x86 + integration tests).
2. Drive `AuthenticateUser("Administrator","")``WriteSecured(true)` against
`TestMachine_001.TestAlarm001` (env overrides
`MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER`/`_PASSWORD` exist if a real Galaxy
account is available — never echo credential values). A throwaway variant of
`WnWrapConsumerProbeTests` (`Worker.Tests/Probes/`, `Skip=null` locally only,
`GROUP` fixed to the findings run's `TestArea`, `MaxAlarmsPerFetch` droppable to 12)
is the vehicle.
3. If the secured write lands: answer **Q1** — GUID stability across `UNACK→ACK`
(`AlarmAckByGUID`) and across clear-then-re-raise; and **Q2**`ALARM_RECORDS/@COUNT`
total-active vs records-in-reply under `maxAlmCnt` 12 with all three TestMachine
alarms active. Record both in `AlarmProbeFindings.md`; update the three
`WnWrapAlarmConsumer` comment blocks to "observed" with the answer. **Do not change
`IsTruncatedFetch` behavior in this task** — if Q2 says `@COUNT` is total-active,
record that an exact-detection follow-up is now unblocked; the heuristic change is
its own reviewed task later.
4. If still refused: append the attempt (verb used, identity resolved, status line —
no secrets), and record which unblock paths remain (flip script re-enable /
real Galaxy account / reclassification). That outcome completes this task.
**Steps:** windev session → probe → findings written → clean up throwaway files on
windev → commit (docs + any comment updates) with pathspecs from the Mac tree.
---
## Task 8: Windev full verification
**Classification:** small (no review — verification gate)
**Estimated implement time:** ~15 min wall
**Blocked by:** Task 1, Task 2, Task 3, Task 4, Task 5, Task 6, Task 7
Pull branch into `C:\build\mxaccessgw-ci`, then: full `slnx` build 0W/0E → worker x86
tests → gateway tests → live MXAccess smoke (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`,
`~WorkerLiveMxAccessSmokeTests`) → `pwsh scripts/check-codegen.ps1` all green.
Known quirk: first build after pull may fail CS2001/CS0016 on stale Contracts obj —
clear obj/bin and rebuild, not a regression. Record all counts.
---
## Task 9: Bookkeeping — follow-ups closure + plan record
**Classification:** trivial
**Estimated implement time:** ~2 min
**Blocked by:** Task 8
**Files:**
- Modify: `docs/plans/2026-08-17-deferred-closeout.md` (follow-ups block `:419-434`):
annotate each closed item with its closing commit; REWRITE the stale Rust-guard bullet
to record that Check 3 already existed and only the reverse sweep was missing; leave
anything genuinely still open (e.g. probe questions if Task 7 ended blocked;
`IsTruncatedFetch` exactness if Q2 unblocked it) accurately stated.
- Modify: `docs/plans/2026-08-17-followup-closeout.md` (this file): as-built notes.
- Modify: `docs/plans/2026-08-17-followup-closeout.md.tasks.json`: statuses.
---
## Execution notes for the orchestrator
- Branch `feat/followup-closeout` off `main` before Task 1.
- Opus implementers per user instruction; reviewer chain per Classification
(high-risk = spec-reviewer serial then code-reviewer; standard = parallel pair;
small = code-reviewer only; trivial = none).
- Waves: **Wave 1:** 1, 3, 4, 5, 6 (files disjoint, including docs — ownership lines in
each task are the contract) · **Wave 2:** 2 (after 1+3+4), 7 (windev) · **Wave 3:** 8 →
final integration review → 9.
- Each implementer gets its full task text + the ground rules block.
- Doc-file ownership this wave matters more than usual: `gateway.md`→Task 1,
`GatewayConfiguration.md`+`GatewayDashboardDesign.md`→Task 3 (Task 2 later adds the
payload-case row), closeout-plan follow-ups→Task 9 only.
- Task 7/8 run against windev over `ssh windev` (PowerShell); psbridge is fallback.
- Do not merge to `main` without user instruction.
---
## As-built notes (execution record, 2026-08-18)
All 9 tasks completed on `feat/followup-closeout`; every classification-driven review
chain resolved **Approved**. The final integration review came back *Ready with
reservations* — all of its non-blocking findings were fixed in `c3c603f`, leaving no
open review item.
Verification: macOS `NonWindows.slnx` 0W/0E; gateway filtered suites green (dashboard
276/276, alarm suites green); `scripts/check-codegen.ps1` 4/4 on macOS. **The full
five-language client matrix ran locally for the first time** — dotnet 133 passed /1
skipped, Go clean, Rust 108, Python 168 passed /1 skipped, Java 131. Windev at `90331b6`:
full `slnx` 0W/0E (one transient MSB4166 node crash, clean on retry), worker x86 523
passed / 11 skipped plus **one pre-existing deterministic failure that reproduces on
`main`** (`WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`),
gateway 1151/1151 on rerun (the known windev load flake), live MXAccess smoke 8/8. The
comment- and prose-only commits landed after `90331b6` are unverified on windev by
design; they need only a cheap tip re-build there.
- **A session-limit outage interrupted four reviewers mid-run.** All four resumed
cleanly on retry; no review was lost or silently truncated.
- **Task 2 surfaced two plan defects, both absorbed rather than deferred.** The spec
claimed the Java CLI renders alarm frames as generic JSON; it is in fact an exhaustive
`switch` that does not compile without an arm for the new case. The spec's work list
also omitted the Java generated-bindings tree.
- **A reviewer's Minor-2 was refuted, and the doc defect behind it fixed instead.** The
finding assumed alarm records coexist after a re-raise; fetch evidence from the probe
rig shows the new GUID replaces the record. The prose that implied coexistence was
corrected rather than the code.
Follow-ups recorded, not started:
- `WorkerPipeSessionTests.RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply`
fails deterministically on windev **and on `main`** — pre-existing, needs its own
investigation.
- `check-codegen.ps1` Check 4 is unrunnable on Windows: the `protoc-gen-go` version banner
carries a `.exe` suffix that the exact-string compare in
`clients/go/generate-proto.ps1:10,55` does not tolerate.
- Windev has `protoc-gen-go-grpc` 1.6.1 against the repo's pinned 1.6.2.
- `clients/java`'s `checkGeneratedClean` is dead under Gradle 9 (`Project.exec` was
removed); it needs `ExecOperations` injection to work again.
- `SettingsPage` renders every `EffectiveDashboardConfiguration` member except
`RecentFaultLimit` / `RecentSessionLimit` (pre-existing, predates this branch).
- The ack-leg probe stays blocked; unblock paths are in `docs/AlarmProbeFindings.md`.
- The dashboard `AlarmsPage` truncation banner is still poll-driven — it could consume
the new `snapshot_status` feed frame instead.
- **A closed code-review finding regressed, or was never applied.** Server-012
(`code-reviews/Server/findings.md:405-412`) is recorded *Resolved 2026-05-18* and claims
it corrected two scope lists to the canonical `*:*` strings — the `CLAUDE.md`
Build/Test/Run `apikey` sample and the `CLAUDE.md` Authentication-section scope list.
Neither correction was present when this branch looked; both were re-fixed here, along
with a third instance the finding never covered (`docs/Authentication.md`'s `ops.alice`
example). The bookkeeping is the follow-up: other `Server-0xx` entries marked Resolved
with documentation-only fixes should be spot-checked for the same pattern, since a
finding that reads Resolved is not otherwise re-examined.
Explicitly decided, not an omission: **`../scadaproj/CLAUDE.md` needs no update.** The
umbrella index records the *set* of `.proto` files this repo owns, and that set is
unchanged — Task 1 added a message and a field inside an existing proto, not a new
contract file.
@@ -0,0 +1,15 @@
{
"planPath": "docs/plans/2026-08-17-followup-closeout.md",
"tasks": [
{"id": 1, "subject": "Task 1: Feed-level alarm-truncation signal (proto + gateway)", "status": "completed", "commits": ["fccf753", "312e072"], "review": "Spec compliant; code review Approved, proto-comment Minor fixed"},
{"id": 2, "subject": "Task 2: Five-client rollout of snapshot_status", "status": "completed", "commits": ["c748361", "4d4f443"], "review": "Spec compliant; code review Approved, Java import + Rust render-test nits fixed; full five-toolchain matrix ran locally"},
{"id": 3, "subject": "Task 3: Complete ShowTagValues coverage — alarms hub + /browse", "status": "completed", "commits": ["eff17d1", "1ea6f60", "e50ec49"], "review": "Spec compliant; code review Approved, failed-read placeholder fix re-review Approved"},
{"id": 4, "subject": "Task 4: Codegen guard — reverse-direction Check 3 + stale-note correction", "status": "completed", "commit": "711793f", "review": "Approved (code review, no issues)"},
{"id": 5, "subject": "Task 5: Settings page — GroupToTag + UntaggedSessionVisibility rows", "status": "completed", "commits": ["094f2ff", "ff16c69"], "review": "Approved; comparer-propagation fix applied and re-verified"},
{"id": 6, "subject": "Task 6: ApiKeysPage — DashboardTags in ConstraintText + create-form input", "status": "completed", "commits": ["c037d99", "fab600d"], "review": "Spec compliant; code review Approved after dedupe/help-text fix, re-review Approved"},
{"id": 7, "subject": "Task 7: Wnwrap alarm-probe retry — secured-write path (windev)", "status": "completed", "commits": ["90331b6", "540a118"], "review": "Approved after ack-comment fix; Q2 answered (@COUNT = records-in-reply, exact detection refuted), clear/re-raise GUID answered (new instance), ack leg re-blocked (accepted-but-inert)"},
{"id": 8, "subject": "Task 8: Windev full verification", "status": "completed", "verifiedAt": "90331b6", "result": "slnx 0W/0E; worker x86 523 pass + 1 pre-existing deterministic failure (reproduces on main); gateway 1151/1151 on rerun; live smoke 8/8; check-codegen 1-3 pass, Check 4 blocked by pre-existing Windows .exe pin bug (substance validated out-of-band); final comment-only tip re-check pending after bookkeeping", "residual": "pending-external: cheap windev tip re-build covering the comment/prose-only commits landed after 90331b6"},
{"id": 9, "subject": "Task 9: Bookkeeping — follow-ups closure + plan record", "status": "completed", "commits": ["a5f843c", "self (follow-up bookkeeping commit, immediately after a5f843c)"], "review": "None (trivial classification); a5f843c closes the prior plan's follow-up block, appends the as-built record, and corrects the CLAUDE.md apikey sample; the follow-up commit closes two adjacent stale scope lists (CLAUDE.md Authentication section, docs/Authentication.md ops.alice example) and records the regressed Server-012 resolution as a follow-up"}
],
"lastUpdated": "2026-08-18"
}
+45 -1
View File
@@ -240,6 +240,36 @@ monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSub
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
flag, so a reply holding exactly the cap is treated as truncated. On such a
fetch `WnWrapAlarmConsumer` merges rather than replaces its retained snapshot,
which suppresses the absence-implies-Clear inference and keeps a capped poll
from broadcasting Clears for alarms it simply had no room to mention. That
suppression is reported structurally rather than only in a rate-limited worker
warning: the `QueryActiveAlarms` reply payload carries `snapshot_truncated`,
every `ActiveAlarmSnapshot` in it carries `from_truncated_snapshot`, and the
dashboard Alarms tab shows a warning banner while the flag is set. The live
`StreamAlarms` feed carries the same verdict at feed level as an
`AlarmSnapshotStatus` message (the `snapshot_status` oneof case), so a streaming
consumer learns it without polling `QueryActiveAlarms`. It is emitted on stream
open — after the `provider_status` frame and before the cached `active_alarm`
frames, so the caveat precedes the records it qualifies — and thereafter only on
a *change* of verdict (a status frame repeated every reconcile is one consumers
learn to ignore). The open-time frame is unconditional: an explicit
`truncated = false` is what separates a complete set from a gateway that never
sends the frame. A monitor restart drops the verdict with the cache generation it
describes, and because feed subscribers outlive the monitor's worker session that
drop is emitted as a clearing frame too. The flag
means "this active set may be incomplete", not "this record is unreliable" —
it is independent of the subtag-fallback `degraded` field above. It is not
latched: the first fetch that comes back under the cap is complete, restores
absence authority, and clears it. Detection remains the record-count heuristic;
the reply's `ALARM_RECORDS/@COUNT` attribute would have made the test exact only
if it reported the total active count, and the 2026-08-18 live probe observed it
reporting the records in the reply instead (see `docs/AlarmProbeFindings.md`), so
there is no exact alternative.
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
on from startup; `Auto` (default) enables failover and failback. Watch-list
@@ -259,12 +289,26 @@ and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
Login and logout
posts validate antiforgery tokens. SignalR hub connections accept either the
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
cookie or a 5-minute data-protected bearer minted at `/hubs/token`.
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
cookie requirement; remote requests always require an authenticated principal
with at least the Viewer role. Setting `MxGateway:Dashboard:Enabled` to
`false` leaves the dashboard and hub routes unmapped.
A dashboard role alone does not decide *which* sessions a user may watch:
`IDashboardSessionAcl` gates both event-subscribe seams — `EventsHub.SubscribeSession`
for remote hub clients and the session-details page's in-process subscribe — so
neither is the weaker path. An authenticated Administrator is allowed
unconditionally; every other caller may observe a session only when the session's
tags intersect the tags their LDAP groups grant through
`MxGateway:Dashboard:GroupToTag`. A session's tags are inherited from its owning
API key's `--dashboard-tags` constraint, never from the client's request, so a
client cannot label its own session with another tenant's tag. Untagged sessions
follow `MxGateway:Dashboard:UntaggedSessionVisibility`, which defaults to
`AdminOnly`; a principal with no tag claims — anonymous localhost included — is an
empty-grant Viewer and sees no tagged session. Tags gate visibility only and are
never a data-access grant.
### Worker Process
Runtime:
+23
View File
@@ -92,6 +92,29 @@ See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
### Dashboard visibility tags in the live tests
`DashboardLdapLiveTests` covers the per-session dashboard event ACL (SEC-25) against this
directory. **No GLAuth change was needed, and none was made** — the tag layer is entirely
config-side, so the fixture simply names groups that already exist:
| Fixture `MxGateway:Dashboard` setting | Value |
| --- | --- |
| `GroupToRole` | `GwAdmin``Administrator`, `GwReader``Viewer` |
| `GroupToTag` | `GwReader``team-a` |
| `UntaggedSessionVisibility` | `AdminOnly` (the shipped default, stated explicitly because the assertions read it) |
`team-a` and `team-b` are operator-chosen labels that exist only in the test's configuration
and on its in-memory sessions; nothing in the directory carries them. `gw-viewer` therefore
logs in as a Viewer granted `team-a` and is admitted to a `team-a`-tagged session but refused a
`team-b`-tagged one. `multi-role` is a member of **both** `GwAdmin` and `GwReader`, so this map
grants it `team-a` as well — its `team-a` allow would hold even without the Administrator
bypass, which is why the bypass is asserted on the `team-b` and untagged sessions instead.
What only a live bind proves here is that the group names `ILdapAuthService` returns from this
directory (short RDN values, not DNs) are the ones `GroupToTag` keys match; a fabricated
principal cannot show that.
## Two bind patterns
### 1. Direct bind (simplest)
+15 -2
View File
@@ -11,8 +11,11 @@
# (which breaks the net48 worker build with CS0246 — see docs/Contracts.md).
# 3. The Rust crate's vendored protos (clients/rust/protos/*.proto — build inputs that make the
# crate buildable outside the repo, CLI-02) are byte-identical to the canonical Contracts
# protos. A drift means a .proto was edited without refreshing the vendored copies, which would
# publish a stale wire contract to crate consumers while the in-repo build stays correct.
# protos, and the two directories hold the same set of files. A drift means a .proto was edited
# without refreshing the vendored copies, which would publish a stale wire contract to crate
# consumers while the in-repo build stays correct. The sweep runs both directions: a canonical
# proto with no vendored copy is just as broken (the published crate cannot build standalone),
# and it is invisible in-repo because build.rs prefers the canonical directory when it exists.
# 4. The committed Go and Python client bindings match a fresh regeneration (IPC-25). The two
# per-client generate-proto.ps1 scripts pin their generators (protoc-gen-go v1.36.11 /
# protoc-gen-go-grpc v1.6.2 for Go; grpcio-tools 1.80.0 for Python), so a clean checkout
@@ -88,6 +91,16 @@ try {
$failures.Add("Rust vendored proto drifted from canonical: clients/rust/protos/$($vendored.Name). Refresh it from src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($vendored.Name).")
}
}
# Reverse direction: a canonical proto that was never vendored passes the loop above (it only
# walks the vendored dir) but breaks a standalone crate build, because build.rs falls back to
# clients/rust/protos/ only outside the repo — in-repo it reads the canonical dir and stays green.
foreach ($canonicalProto in Get-ChildItem -Path $canonicalProtoDir -Filter '*.proto' -File) {
$vendoredCounterpart = Join-Path $vendoredProtoDir $canonicalProto.Name
if (-not (Test-Path $vendoredCounterpart)) {
$failures.Add("Canonical proto is not vendored for the Rust crate: $($canonicalProto.Name). Copy src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($canonicalProto.Name) to clients/rust/protos/$($canonicalProto.Name) (and add it to build.rs's input list).")
}
}
}
catch {
$failures.Add("Rust vendored proto check failed: $($_.Exception.Message)")
File diff suppressed because it is too large Load Diff
@@ -726,6 +726,13 @@ message AcknowledgeAlarmReplyPayload {
// stream.
message QueryActiveAlarmsReplyPayload {
repeated ActiveAlarmSnapshot snapshots = 1;
// True when the provider fetch backing this reply came back holding the
// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
// active alarms, and the worker suspends its absence-implies-Clear inference
// for that poll so a reference missing from `snapshots` is not evidence the
// alarm cleared. Carried on the payload as well as per-record because a
// truncated fetch that filters down to zero records still has to say so.
bool snapshot_truncated = 2;
}
message MxEvent {
@@ -932,6 +939,16 @@ message ActiveAlarmSnapshot {
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
// wire (never UNSPECIFIED).
AlarmProviderMode source_provider = 15;
// True when the provider fetch that produced this snapshot hit the per-fetch
// cap: the snapshot set may omit active alarms, and the worker suspended its
// absence-implies-Clear inference for that poll. Says nothing about THIS
// record's fidelity the record is as accurate as any other; it flags that
// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
// boolean is the only additive way to carry set-level degraded status on that
// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
// Additive (proto3): clients that ignore it deserialize the stream unchanged.
bool from_truncated_snapshot = 16;
}
enum AlarmConditionState {
@@ -986,9 +1003,13 @@ message StreamAlarmsRequest {
string alarm_filter_prefix = 2;
}
// One message on the StreamAlarms feed. The stream opens with one
// `active_alarm` per currently-active alarm, then a single
// `snapshot_complete`, then a `transition` for every subsequent change.
// One message on the StreamAlarms feed. The stream opens with a
// `provider_status` (the current provider mode), then a `snapshot_status`
// (whether the cached active set may be incomplete), then one `active_alarm`
// per currently-active alarm, then a single `snapshot_complete`. After that
// come the live frames: a `transition` for every subsequent alarm change,
// interleaved with a further `provider_status` on each failover/failback and a
// further `snapshot_status` on each change of the truncation verdict.
message AlarmFeedMessage {
oneof payload {
// Part of the initial active-alarm snapshot (ConditionRefresh).
@@ -1001,6 +1022,10 @@ message AlarmFeedMessage {
// Provider-mode status. Emitted once on stream open and again on every
// failover/failback so late joiners learn the current mode immediately.
AlarmProviderStatus provider_status = 4;
// Snapshot-completeness status. Emitted once on stream open and again on
// every change of the truncation verdict, so late joiners learn whether the
// feed's active-alarm set may be incomplete.
AlarmSnapshotStatus snapshot_status = 5;
}
}
@@ -1011,6 +1036,23 @@ message AlarmProviderStatus {
google.protobuf.Timestamp since = 4;
}
// Feed-level snapshot-completeness status. Emitted once on StreamAlarms open
// (after the initial provider_status frame, before the cached active_alarm
// frames) so late joiners learn the current verdict, and again on every change
// of the truncation verdict when a reconcile reports a different verdict, and
// when the gateway's alarm monitor restarts and drops a truncated verdict with
// the cache generation it described (feed subscribers outlive that monitor
// session, so they are sent the clearing frame). Mirrors the per-record
// ActiveAlarmSnapshot.from_truncated_snapshot caveat at feed level so live
// consumers can reason about completeness without polling QueryActiveAlarms.
message AlarmSnapshotStatus {
// True while the monitor's cached active-alarm set derives from a truncated
// (capped) worker fetch the set may be missing alarms. Distinct from
// provider degradation (AlarmProviderStatus.degraded), which describes the
// fidelity of the records rather than the completeness of the set.
bool truncated = 1;
}
message MxStatusProxy {
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
// (a 16-bit signed value in the COM struct, widened to int32 on the
@@ -1,11 +1,14 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
@@ -23,6 +26,18 @@ public sealed class DashboardLdapLiveTests
/// </summary>
private const string SharedDirectoryPassword = "password";
/// <summary>
/// Dashboard visibility tags (SEC-25) 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>
private const string TeamATag = "team-a";
private const string TeamBTag = "team-b";
private const string TeamASessionId = "session-team-a";
private const string TeamBSessionId = "session-team-b";
private const string UntaggedSessionId = "session-untagged";
/// <summary>
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
@@ -152,27 +167,162 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal);
}
/// <summary>
/// Verifies the SEC-25 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.
/// </summary>
/// <remarks>
/// The mapping under test is entirely config-side: no GLAuth entry, group, or membership was
/// added for it — the shared directory's existing GwReader group is simply named as a key in
/// this fixture's <c>GroupToTag</c> map. What only a live bind can prove is that the group
/// names <c>ILdapAuthService</c> actually returns from the shared directory (short RDN values,
/// not DNs) are the ones <c>GroupToTag</c> keys match, which a fabricated principal cannot show.
/// The denial half is the load-bearing assertion: before the ACL, every Viewer saw every session.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_ViewerWithGroupToTagGrant_SeesOnlyItsOwnTaggedSession()
{
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"gw-viewer",
SharedDirectoryPassword,
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.True(result.Principal.IsInRole(DashboardRoles.Viewer));
Assert.False(result.Principal.IsInRole(DashboardRoles.Admin));
Assert.Contains(result.Principal.Claims, claim =>
claim.Type == DashboardAuthenticationDefaults.DashboardTagClaimType
&& claim.Value == TeamATag);
IDashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
Assert.False(acl.CanViewSession(result.Principal, TeamBSessionId));
// Untagged sessions stay Admin-only under the shipped default, so the Viewer's grant does
// not silently widen to sessions whose owning key declared no tags.
Assert.False(acl.CanViewSession(result.Principal, UntaggedSessionId));
}
/// <summary>
/// Verifies that <c>multi-role</c> — an Administrator in the shared directory — reaches every
/// session regardless of tags.
/// </summary>
/// <remarks>
/// The bypass is proved by the two sessions the account's own grant does <em>not</em> cover.
/// <c>multi-role</c> is a member of GwReader as well as GwAdmin, so this fixture's
/// <c>GroupToTag</c> map grants it <c>team-a</c> — the <c>team-a</c> allow would therefore hold
/// even with the bypass removed and proves nothing on its own. <c>team-b</c> (a tag it does not
/// hold) and the untagged session (Admin-only under the shipped default) are the assertions
/// that fail if the Administrator branch is ever dropped.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_Administrator_BypassesTagCheckForEverySession()
{
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"multi-role",
SharedDirectoryPassword,
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.True(result.Principal.IsInRole(DashboardRoles.Admin));
IDashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
Assert.True(acl.CanViewSession(result.Principal, TeamBSessionId));
Assert.True(acl.CanViewSession(result.Principal, UntaggedSessionId));
}
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) =>
CreateAuthenticator(ldapOptions, AdminOnlyDashboardOptions());
private static DashboardAuthenticator CreateAuthenticator(
LibraryLdapOptions ldapOptions,
DashboardOptions dashboardOptions)
{
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
},
},
};
GatewayOptions gatewayOptions = new() { Dashboard = dashboardOptions };
return new DashboardAuthenticator(
new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
Options.Create(gatewayOptions),
NullLogger<DashboardAuthenticator>.Instance);
}
/// <summary>
/// The historical fixture map: GwAdmin is the only mapped group, so GwReader members are denied
/// login outright. Kept for the tests that assert that denial.
/// </summary>
private static DashboardOptions AdminOnlyDashboardOptions() => new()
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
},
};
/// <summary>
/// The SEC-25 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()
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
},
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwReader"] = [TeamATag],
},
};
private static DashboardSessionAcl CreateAcl() => new(
new FixedSessionManager(
[
CreateSession(TeamASessionId, [TeamATag]),
CreateSession(TeamBSessionId, [TeamBTag]),
CreateSession(UntaggedSessionId, tags: null),
]),
Options.Create(new GatewayOptions
{
// Explicit rather than defaulted: the untagged assertions above read this value.
Dashboard = new DashboardOptions
{
UntaggedSessionVisibility = UntaggedSessionVisibility.AdminOnly,
},
}));
private static GatewaySession CreateSession(string sessionId, string[]? tags) => new(
sessionId: sessionId,
backendName: "backend",
pipeName: $"pipe-{sessionId}",
nonce: "nonce",
clientIdentity: "client",
ownerKeyId: "key-1",
clientSessionName: "client-session",
clientCorrelationId: "correlation",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UnixEpoch,
ownerDashboardTags: tags);
/// <summary>
/// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
/// <c>MxGateway:Ldap</c> configuration section the same way production does in
@@ -227,4 +377,53 @@ public sealed class DashboardLdapLiveTests
return options;
}
/// <summary>
/// Registry double serving a fixed set of sessions. The ACL only ever calls
/// <see cref="TryGetSession"/>; the remaining members exist to satisfy the interface and are
/// never reached by these tests.
/// </summary>
/// <param name="sessions">The sessions this registry resolves.</param>
private sealed class FixedSessionManager(IReadOnlyList<GatewaySession> sessions) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessions.FirstOrDefault(candidate => candidate.SessionId == sessionId);
return session is not null;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -57,6 +57,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
// Whether the worker's most recent reconcile fetch was capped, guarded by _sync.
// Written only by ApplyReconcile (and cleared with the cache), so it describes the last full
// reconcile — not necessarily the current _alarms contents, which live transitions keep moving
// via ApplyTransition between passes. Read it as "as of the last reconcile, the worker's fetch
// was capped", which is the right granularity for a completeness caveat.
private bool _snapshotTruncated;
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
private volatile string? _lastError;
private GatewaySession? _session;
@@ -110,6 +117,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
}
}
/// <inheritdoc />
public bool SnapshotTruncated
{
get { lock (_sync) { return _snapshotTruncated; } }
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -416,7 +429,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
if (payload is not null)
{
ApplyReconcile(payload.Snapshots);
ApplyReconcile(payload.Snapshots, payload.SnapshotTruncated);
}
}
@@ -610,7 +623,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// suppressed. The dedup fires only on a positive marker match, so the contract stays
// at-least-once: consumers must still treat alarm state idempotently — apply a transition as
// "set the alarm to this state", never as an increment or a toggle.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
//
// Truncation (`snapshotTruncated`) needs no per-alarm handling here, and that is worth saying
// because the obvious worry — a capped fetch reading as a wave of Clears — is answered one
// level down. The worker merges rather than replaces its retained snapshot on a capped fetch,
// so the set arriving here still carries the alarms the capped reply had no room to mention.
// The flag is set-level status, not a delta: it is recorded, and a CHANGE of verdict is pushed
// to the feed as a snapshot_status frame (SetSnapshotTruncated) so live consumers learn the
// completeness caveat without polling QueryActiveAlarms.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots, bool snapshotTruncated)
{
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
foreach (ActiveAlarmSnapshot snapshot in snapshots)
@@ -669,10 +690,30 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_alarms[incoming.Key] = incoming.Value;
}
SetSnapshotTruncated(snapshotTruncated);
_currentAlarmsProjection = null;
}
}
// Caller holds _sync. Records the truncation verdict and, on a CHANGE of verdict, pushes the
// feed-level snapshot_status frame. Edge-triggered rather than per-reconcile: a status frame
// repeated every reconcile interval is one consumers learn to ignore. The verdict describes the
// whole cached set, not one alarm, so — like provider status — it goes to every subscriber
// regardless of alarm-filter prefix.
private void SetSnapshotTruncated(bool truncated)
{
if (_snapshotTruncated == truncated)
{
return;
}
_snapshotTruncated = truncated;
BroadcastToAll(new AlarmFeedMessage
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated },
});
}
// Caller holds _sync. Pushes a feed message to every matching subscriber;
// a subscriber that has fallen behind is completed with an error and dropped.
private void Broadcast(AlarmFeedMessage message, string reference)
@@ -716,6 +757,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
lock (_sync)
{
_alarms.Clear();
// The truncation verdict describes the cache generation being discarded, so it goes
// with it. Carrying it across a monitor restart would caveat an empty set as "may be
// incomplete" on evidence from a session that no longer exists. Dropping a truncated
// verdict IS a completeness change, and feed subscribers outlive the monitor's worker
// session, so this routes through the edge path and they see the clearing frame.
SetSnapshotTruncated(false);
_currentAlarmsProjection = null;
}
}
@@ -737,13 +784,16 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
ActiveAlarmSnapshot[] snapshot;
AlarmProviderStatus providerStatus;
bool snapshotTruncated;
lock (_sync)
{
// Register before snapshotting under the same lock so neither a
// transition nor a provider-mode change can slip between the snapshot
// and the live stream.
// and the live stream. The truncation verdict is read here too, so the
// caveat and the set it qualifies are a consistent pair.
_subscribers.Add(subscriber);
providerStatus = BuildProviderStatus();
snapshotTruncated = _snapshotTruncated;
snapshot = _alarms.Values
.Where(alarm => prefix.Length == 0
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
@@ -757,6 +807,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// learns the mode (and whether the feed is degraded) before any alarms.
yield return new AlarmFeedMessage { ProviderStatus = providerStatus };
// Then the completeness caveat, BEFORE the cached snapshot it qualifies: a consumer
// applying the snapshot as it streams needs to know whether the set may be missing
// alarms while it applies it, not after. Unconditional — an explicit false is what
// separates "the set is complete" from "this gateway never sends the frame".
yield return new AlarmFeedMessage
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated },
};
foreach (ActiveAlarmSnapshot alarm in snapshot)
{
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
@@ -39,10 +39,41 @@ public interface IGatewayAlarmService
IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; }
/// <summary>
/// Attaches to the central alarm feed. The returned stream yields one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
/// single <c>snapshot_complete</c> sentinel, then a <c>transition</c>
/// for every subsequent change.
/// True when the worker's most recent reconcile fetch hit the provider's
/// per-fetch cap, so the active-alarm set may be missing alarms. The
/// monitor is otherwise healthy — this is not a fault, it is a
/// completeness caveat, which is why it is separate from
/// <see cref="State"/> and <see cref="LastError"/>. Cleared by the first
/// reconcile whose fetch comes back under the cap.
/// <para>
/// Read it as "as of the last full reconcile, the fetch was capped", not as
/// a property of a particular <see cref="CurrentAlarms"/> array: the two are
/// separate reads, and live transitions keep moving the cached set between
/// reconciles. A consumer that reads both — the dashboard poll does — can
/// therefore straddle a reconcile, in which case its caveat describes the
/// adjacent generation and the banner is at worst one poll stale. That is
/// the intended granularity for a completeness hint; pairing them exactly
/// would need a combined accessor this seam deliberately does not have.
/// </para>
/// <para>
/// This is the polled read of the verdict. The same verdict is pushed to
/// the live feed as the <c>snapshot_status</c>
/// (<c>AlarmSnapshotStatus</c>) case of <see cref="AlarmFeedMessage"/> —
/// once at <see cref="StreamAsync"/> open and again on every change — so
/// a streaming consumer need not poll this property.
/// </para>
/// </summary>
bool SnapshotTruncated { get; }
/// <summary>
/// Attaches to the central alarm feed. The returned stream opens with a
/// <c>provider_status</c> and a <c>snapshot_status</c>
/// <see cref="AlarmFeedMessage"/> — the current provider mode and
/// completeness verdict, so the caveats precede the records they
/// qualify — then one message per currently-active alarm, then a single
/// <c>snapshot_complete</c> sentinel, then a <c>transition</c> for every
/// subsequent change (and a further status message on every provider-mode
/// or truncation-verdict change).
/// </summary>
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
/// <param name="cancellationToken">Token that ends the subscription.</param>
@@ -67,4 +67,20 @@ public sealed class DashboardOptions
/// Users with no matching group are rejected at login.
/// </summary>
public Dictionary<string, string> GroupToRole { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// LDAP group → dashboard visibility tags. A dashboard user's granted tag set
/// is the union over the groups they belong to; a session is observable on the
/// events hub when its tags intersect that grant. Independent of
/// <see cref="GroupToRole"/> — a group may appear in either map, both, or
/// neither. Visibility only: tags never gate data access.
/// </summary>
public Dictionary<string, string[]> GroupToTag { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Who may observe a session whose owning API key carries no dashboard tags.
/// Defaults to <see cref="Configuration.UntaggedSessionVisibility.AdminOnly"/>
/// so an upgrade tightens rather than loosens.
/// </summary>
public UntaggedSessionVisibility UntaggedSessionVisibility { get; init; } = UntaggedSessionVisibility.AdminOnly;
}
@@ -7,4 +7,6 @@ public sealed record EffectiveDashboardConfiguration(
int RecentFaultLimit,
int RecentSessionLimit,
bool ShowTagValues,
IReadOnlyDictionary<string, string> GroupToRole);
IReadOnlyDictionary<string, string> GroupToRole,
IReadOnlyDictionary<string, IReadOnlyList<string>> GroupToTag,
UntaggedSessionVisibility UntaggedSessionVisibility);
@@ -62,7 +62,16 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
ShowTagValues: value.Dashboard.ShowTagValues,
GroupToRole: value.Dashboard.GroupToRole),
GroupToRole: value.Dashboard.GroupToRole,
// Rebuilt rather than passed through because the value type widens from string[]
// to IReadOnlyList<string>; the source dictionary's own comparer is carried over
// (case-insensitive by default) so the projected map still matches LDAP group
// names in whatever case the directory returns them.
GroupToTag: value.Dashboard.GroupToTag.ToDictionary(
pair => pair.Key,
pair => (IReadOnlyList<string>)pair.Value,
value.Dashboard.GroupToTag.Comparer),
UntaggedSessionVisibility: value.Dashboard.UntaggedSessionVisibility),
Protocol: new EffectiveProtocolConfiguration(
value.Protocol.WorkerProtocolVersion,
value.Protocol.MaxGrpcMessageBytes));
@@ -410,6 +410,36 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
}
}
// GroupToTag is validated for shape only, and independently of GroupToRole:
// a group may grant a role, a tag, both, or neither. An empty map is legal —
// it yields Viewers with no tag grant, which (under the default AdminOnly)
// means they observe no session's events. That is the fail-closed posture.
foreach (KeyValuePair<string, string[]> entry in options.GroupToTag)
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
builder.Add("MxGateway:Dashboard:GroupToTag keys (LDAP group names) must be non-blank.");
}
if (entry.Value is null)
{
builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] must be a list of tags, not null.");
continue;
}
if (Array.Exists(entry.Value, string.IsNullOrWhiteSpace))
{
builder.Add($"MxGateway:Dashboard:GroupToTag['{entry.Key}'] tags must be non-blank.");
}
}
if (!Enum.IsDefined(options.UntaggedSessionVisibility))
{
builder.Add(
$"MxGateway:Dashboard:UntaggedSessionVisibility must be '{nameof(UntaggedSessionVisibility.AdminOnly)}' "
+ $"or '{nameof(UntaggedSessionVisibility.AllViewers)}'.");
}
AddIfNotPositive(
options.SnapshotIntervalMilliseconds,
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Who may observe the dashboard event stream of a session that carries no
/// dashboard tags. Tags gate dashboard event VISIBILITY only; they never widen
/// or narrow data access.
/// </summary>
public enum UntaggedSessionVisibility
{
/// <summary>
/// Default. An untagged session is visible only to a dashboard Administrator.
/// Fails closed: a deployment that has not populated
/// <see cref="DashboardOptions.GroupToTag"/> shows Viewers nothing.
/// </summary>
AdminOnly,
/// <summary>
/// An untagged session is visible to every dashboard Viewer. Opt-in for a
/// genuinely single-tenant deployment that wants the pre-ACL behaviour.
/// </summary>
AllViewers
}
@@ -34,6 +34,17 @@
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
}
@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
completeness of the set is in doubt, so this must not read as "alarms are broken". *@
@if (_snapshotTruncated)
{
<div class="alert alert-warning">
Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
the cap are not listed. Alarms already known stay listed rather than clearing. Raise
<code>MxGateway:Alarms:MaxAlarmsPerFetch</code> or narrow the subscription if this persists.
</div>
}
<section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
@@ -156,6 +167,7 @@
@code {
private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError;
private bool _snapshotTruncated;
private int? _workerPid;
private DateTimeOffset? _lastRefresh;
private int _unackedCount;
@@ -386,6 +398,7 @@
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
@@ -115,6 +115,20 @@ else
<label for="browseSubtrees" class="form-label small">Browse subtrees</label>
<textarea id="browseSubtrees" class="form-control form-control-sm" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
</div>
<div class="mb-2">
<label for="dashboardTags" class="form-label small">Dashboard tags</label>
<textarea id="dashboardTags" class="form-control form-control-sm" rows="2"
aria-describedby="dashboardTagsHelp"
@bind="CreateModel.DashboardTags" @bind:event="oninput"></textarea>
<div id="dashboardTagsHelp" class="form-text small">
Split on the same separators as the constraint fields above, and
de-duplicated case-insensitively — two spellings of a tag are one grant.
Matched case-insensitively against the viewer grants in
<code>Dashboard:GroupToTag</code>. Scopes dashboard event visibility only —
never what the key may read, write, or browse. Empty leaves the key's sessions
untagged, whose visibility follows <code>Dashboard:UntaggedSessionVisibility</code>.
</div>
</div>
<div class="mb-3">
<label for="maxWriteClassification" class="form-label small">Max write classification</label>
<input id="maxWriteClassification" class="form-control form-control-sm" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
@@ -238,7 +252,14 @@ else
GatewayScopes.Admin
];
private ApiKeyCreateModel CreateModel { get; } = new();
/// <summary>
/// Backing state for the create dialog. Internal rather than private so
/// <c>ApiKeysPageDashboardTagsTests</c> can drive the model-to-request mapping
/// directly — the assembly's established test seam (see <c>InternalsVisibleTo</c>
/// in <c>Properties/AssemblyInfo.cs</c>), because a create form behind a click is
/// unreachable from static rendering.
/// </summary>
internal ApiKeyCreateModel CreateModel { get; } = new();
private bool CanManageApiKeys { get; set; }
@@ -399,7 +420,8 @@ else
LastGeneratedApiKey = result.ApiKey;
}
private void OpenCreateDialog()
/// <summary>Opens the create dialog. Internal so a render test can reach the form's markup.</summary>
internal void OpenCreateDialog()
{
IsCreateDialogOpen = true;
}
@@ -412,7 +434,11 @@ else
}
}
private bool TryBuildCreateRequest(
/// <summary>Maps <see cref="CreateModel"/> onto a create request, or reports why it cannot.</summary>
/// <param name="request">The built request when this returns true.</param>
/// <param name="validationMessage">The reason the model is unusable when this returns false.</param>
/// <returns>True when the model produced a request.</returns>
internal bool TryBuildCreateRequest(
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
out string? validationMessage)
{
@@ -449,7 +475,12 @@ else
MaxWriteClassification: maxWriteClassification,
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly));
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly)
{
// Init-only rather than positional (it was bolted onto the record after the
// eight-member constructor shipped), so it is attached here instead.
DashboardTags = ParseDashboardTags(CreateModel.DashboardTags),
});
return true;
}
@@ -514,6 +545,11 @@ else
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
// Listed like the rest even though it restricts no data path: IsEmpty counts it, so a key
// whose only policy is a dashboard tag is not "unconstrained", and omitting it here left
// that key's cell empty — rendered as "-", the same cell a key with no policy at all gets.
AddList(parts, "dashboard_tags", constraints.DashboardTags);
if (constraints.MaxWriteClassification is { } max)
{
parts.Add($"max_write_classification={max}");
@@ -540,6 +576,18 @@ else
}
}
// Dashboard tags alone are de-duplicated ordinal-ignore-case, which is how the enforcement site
// compares them — two spellings are one grant, and ApiKeyAdminCommandLineParser.ParseDashboardTags
// collapses them the same way, first spelling winning. Persisting both would not change who can
// see what (GatewaySession holds its tags in a case-insensitive set) but the constraints column
// would report one grant twice, and a security grant that reads as two is the wrong kind of
// wrong on an audit surface. The other five list fields keep ParseList's verbatim behaviour:
// their globs are matched literally, so near-duplicates there are not necessarily the same rule.
private static IReadOnlyList<string> ParseDashboardTags(string? value)
{
return [.. ParseList(value).Distinct(StringComparer.OrdinalIgnoreCase)];
}
private static IReadOnlyList<string> ParseList(string? value)
{
return (value ?? string.Empty)
@@ -548,7 +596,7 @@ else
.ToArray();
}
private sealed class ApiKeyCreateModel
internal sealed class ApiKeyCreateModel
{
public string KeyId { get; set; } = string.Empty;
@@ -568,6 +616,8 @@ else
public string MaxWriteClassification { get; set; } = string.Empty;
public string DashboardTags { get; set; } = string.Empty;
public bool ReadAlarmOnly { get; set; }
public bool ReadHistorizedOnly { get; set; }
@@ -583,6 +633,7 @@ else
WriteTagGlobs = string.Empty;
BrowseSubtrees = string.Empty;
MaxWriteClassification = string.Empty;
DashboardTags = string.Empty;
ReadAlarmOnly = false;
ReadHistorizedOnly = false;
}
@@ -10,6 +10,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService
@inject IDashboardSessionEventSubscriber EventSubscriber
@inject IDashboardSessionAcl SessionAcl
<PageTitle>Dashboard Session</PageTitle>
@@ -114,7 +115,11 @@ else
<span>@(_eventsConnected ? "live" : "offline")</span>
</span>
</div>
@if (_recentEvents.Count == 0)
@if (!_eventsAuthorized)
{
<div class="empty-state">Not authorized for this session's events.</div>
}
else if (_recentEvents.Count == 0)
{
<div class="empty-state">
Waiting for events. The dashboard subscribes to this session's events directly, so
@@ -175,7 +180,21 @@ else
private CancellationTokenSource? _eventPumpCancellation;
private Task? _eventPumpTask;
private bool _eventsConnected;
// Renders the denial message in place of the events panel's empty state. Starts true so the
// panel reads as "waiting" until the gate has actually been evaluated for a session id;
// AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
private bool _eventsAuthorized = true;
private string? _subscribedSessionId;
// Identifies the attach currently entitled to publish subscription state. Bumped
// synchronously by OnParametersSetAsync before it awaits anything, so a suspended
// AttachEventsAsync continuation can tell that a newer parameter set overtook it — the
// same dispatcher-owned identity idea as the ReferenceEquals guards in PumpEventsAsync
// and MarkDisconnectedAsync, one level up. Without it, the await on the authentication
// state opens a window in which a rapid A -> B navigation lets the stale continuation
// re-read the live SessionId and attach B a second time, orphaning B's first
// subscription (never disposed, its viewer registration never released, its pump never
// cancelled) behind the fields it overwrites.
private int _attachGeneration;
private readonly LinkedList<MxEvent> _recentEvents = new();
private bool CanManage { get; set; }
@@ -199,11 +218,17 @@ else
{
if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
{
// Claimed before the first await, so every attach that follows carries a token
// that a later parameter set can invalidate. DetachEventsAsync needs no such
// guard: it captures and nulls the live fields synchronously before it awaits,
// so a resumed detach only unwinds what it already took ownership of.
int generation = ++_attachGeneration;
// Deliberately no ConfigureAwait(false): the resumption must stay on the
// renderer's dispatcher so the new subscription is published to
// _eventSubscription from the same thread the pump's guard reads it on.
await DetachEventsAsync();
AttachEvents();
await AttachEventsAsync(generation);
}
}
@@ -288,19 +313,47 @@ else
// IDashboardEventBroadcaster, and the subscription registers with
// EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for
// both audiences.
// ACL posture is unchanged from the hub path: any dashboard Viewer may watch
// any session (SEC-25 tracks the per-session ACL for both seams).
private void AttachEvents()
// ACL posture matches the hub path exactly: IDashboardSessionAcl gates this seam with the
// same decision EventsHub.SubscribeSession applies (SEC-25 / TST-15). The gate wraps only
// whether a subscription is created at all — the generation guards, the pump, and the detach
// coupling below it are untouched, so a denied page holds no subscription to leak and never
// registers a viewer, which keeps the broadcaster's mirror off for that session.
private async Task AttachEventsAsync(int generation)
{
if (string.IsNullOrWhiteSpace(SessionId))
{
return;
}
// Deliberately no ConfigureAwait(false): the decision and everything it publishes must
// land back on the renderer's dispatcher, which is where the fields below are owned.
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
// Checked before ANY field write and before Subscribe, because both are the damage: a
// newer parameter set may have run start-to-finish while this continuation was parked,
// and SessionId now reads as ITS session. Attaching here would not bypass the ACL (the
// newer attach already cleared the same session), but it would strand the live
// subscription — overwritten in place, so nothing ever disposes it or releases its
// viewer registration, and the mirror stays on for a session nobody is watching. A
// stale attach owns nothing, so it returns rather than detaching: tearing down here
// would destroy the newer attach's subscription.
if (generation != _attachGeneration)
{
return;
}
_subscribedSessionId = SessionId;
_eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);
if (!_eventsAuthorized)
{
// No subscription, no pump, no viewer registration — the panel renders the denial.
return;
}
_eventSubscription = EventSubscriber.Subscribe(SessionId);
_eventPumpCancellation = new CancellationTokenSource();
_eventsConnected = true;
_subscribedSessionId = SessionId;
// Deliberately not awaited: the pump runs for as long as the page watches this
// session and is cancelled and drained by DetachEventsAsync.
@@ -65,6 +65,27 @@ else
}
</td>
</tr>
<tr>
<th scope="row">Dashboard tag mapping</th>
@* Group and tag NAMES are configuration, not tag values — the redaction
rule does not reach them, and an operator asking why a Viewer sees no
sessions needs this map as much as the role map above it. *@
<td>
@if (Snapshot.Configuration.Dashboard.GroupToTag.Count == 0)
{
<span class="text-muted">(none configured)</span>
}
else
{
<ul class="mb-0">
@foreach (KeyValuePair<string, IReadOnlyList<string>> pair in Snapshot.Configuration.Dashboard.GroupToTag)
{
<li><code>@pair.Key</code> → @string.Join(", ", pair.Value)</li>
}
</ul>
}
</td>
</tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
@@ -78,6 +99,7 @@ else
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
<tr><th scope="row">Untagged session visibility</th><td>@Snapshot.Configuration.Dashboard.UntaggedSessionVisibility</td></tr>
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
</tbody>
</table>
@@ -57,7 +57,14 @@ public sealed record DashboardActiveAlarm(
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
/// <param name="SnapshotTruncated">
/// True when the provider fetch behind <paramref name="Alarms"/> hit its per-fetch cap, so the
/// list may be missing active alarms. Distinct from <paramref name="Error"/>: the query
/// succeeded and every row shown is real — only the set's completeness is in doubt, which the
/// page states as a caveat rather than a failure.
/// </param>
public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error,
int? WorkerProcessId);
int? WorkerProcessId,
bool SnapshotTruncated = false);
@@ -36,6 +36,16 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary>
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). 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>; read by <see cref="IDashboardSessionAcl"/>. A
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
/// default. Visibility only — it never grants data access.
/// </summary>
public const string DashboardTagClaimType = "zb:dashboardtag";
/// <summary>
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
@@ -1,7 +1,9 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -17,10 +19,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <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).
/// </param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
IGroupRoleMapper<string> roleMapper,
IOptions<GatewayOptions> options,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -70,7 +77,8 @@ public sealed class DashboardAuthenticator(
ldapResult.Username,
ldapResult.DisplayName,
ldapResult.Groups,
roles));
roles,
options.Value.Dashboard.GroupToTag));
}
/// <summary>
@@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator(
/// is role-based), so the shape change is non-breaking for dashboard consumers.
/// </param>
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
/// <param name="groupToTag">
/// The configured <c>Dashboard:GroupToTag</c> map. The tags it grants are stamped as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims so a
/// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
/// session-details page's in-process subscribe seam reads exactly these claims.
/// </param>
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable<string> groups,
IEnumerable<string> roles)
IEnumerable<string> roles,
IReadOnlyDictionary<string, string[]> groupToTag)
{
// Materialized because the groups are read twice below (group claims and tag mapping) and
// the source is only guaranteed to be enumerable.
string[] groupNames = groups as string[] ?? [.. groups];
List<Claim> claims =
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
@@ -120,9 +139,14 @@ public sealed class DashboardAuthenticator(
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
claims.AddRange(groups.Select(group => new Claim(
claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
// Dashboard event-visibility tags (SEC-25). 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)
.Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));
ClaimsIdentity claimsIdentity = new(
claims,
@@ -0,0 +1,59 @@
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Single source of truth for mapping a user's LDAP groups to the dashboard
/// visibility tags they are granted (<c>MxGateway:Dashboard:GroupToTag</c>).
/// Sibling of <see cref="DashboardGroupRoleMapping"/> and deliberately follows
/// the same group-matching rules (full DN first, leading-RDN fallback,
/// case-insensitive) so operators write one kind of group key for both maps.
/// Tags gate dashboard event VISIBILITY only; they are never a data-access
/// constraint.
/// </summary>
internal static class DashboardGroupTagMapping
{
/// <summary>
/// Maps the user's LDAP groups to the union of the tags those groups grant.
/// A group with no entry in the map contributes nothing; duplicate tags
/// across groups collapse (case-insensitively). Returns an empty set when no
/// group matches — an empty grant, which the ACL treats as "sees no tagged
/// session".
/// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToTag">The mapping from group names to granted tags.</param>
/// <returns>The distinct tags granted across all of the user's groups.</returns>
internal static IReadOnlySet<string> MapGroupsToTags(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string[]> groupToTag)
{
HashSet<string> tags = new(StringComparer.OrdinalIgnoreCase);
if (groupToTag.Count == 0)
{
return tags;
}
foreach (string group in groups)
{
string normalizedGroup = group.Trim();
if (!groupToTag.TryGetValue(normalizedGroup, out string[]? granted)
&& !groupToTag.TryGetValue(
DashboardGroupRoleMapping.ExtractFirstRdnValue(normalizedGroup),
out granted))
{
continue;
}
if (granted is null)
{
continue;
}
foreach (string tag in granted)
{
tags.Add(tag);
}
}
return tags;
}
}
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -11,6 +13,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// expires. All access is serialised through <see cref="_gate"/> so the
/// single backing worker only ever sees one in-flight command.
/// </summary>
/// <remarks>
/// This service is also where <c>MxGateway:Dashboard:ShowTagValues</c> is
/// applied to the Browse panel: with the flag false (the default) the
/// formatted value never leaves this boundary — the projected
/// <see cref="DashboardTagValue"/> carries
/// <see cref="DashboardTagValue.RedactedValueText"/> instead. Putting the
/// decision at the service rather than in the page keeps it to one place and
/// keeps a value out of the render tree entirely, rather than relying on
/// every current and future view to remember to suppress it.
/// </remarks>
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
{
private const string BackendName = "Galaxy";
@@ -38,6 +50,18 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
private readonly ILogger<DashboardLiveDataService> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// <c>MxGateway:Dashboard:ShowTagValues</c>. False (the default)
/// substitutes <see cref="DashboardTagValue.RedactedValueText"/> for every
/// successfully read value this service hands the Browse panel; quality,
/// data type, source timestamp, and any error still describe the real
/// read, so the panel remains a diagnostic surface without being a
/// value-disclosure one. Failed reads keep their existing "-" placeholder:
/// there was no value to suppress, so claiming one was withheld would
/// misreport the failure.
/// </summary>
private readonly bool _showTagValues;
// Least-recently-read-last advise set: the list holds every currently advised
// tag ordered most- to least-recently read, the dictionary indexes into it.
// Both are only ever touched under _gate, which already serialises all viewers.
@@ -53,15 +77,19 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
/// <summary>Initializes the live-data service.</summary>
/// <param name="sessionManager">Gateway session manager.</param>
/// <param name="alarmService">Gateway central alarm service.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
/// <param name="logger">Diagnostic logger.</param>
public DashboardLiveDataService(
ISessionManager sessionManager,
IGatewayAlarmService alarmService,
IOptions<GatewayOptions> options,
ILogger<DashboardLiveDataService> logger)
{
ArgumentNullException.ThrowIfNull(options);
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_showTagValues = options.Value.Dashboard.ShowTagValues;
}
/// <inheritdoc />
@@ -96,8 +124,20 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
.ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
.ConfigureAwait(false);
// The only place the /browse live-value gate is evaluated: the page
// renders whatever ValueText it is handed, so a second check in the
// view could only ever disagree with this one.
//
// A failed read is left alone: it has no value to suppress, and its
// ValueText is already the "-" placeholder. Substituting "[redacted]"
// there would tell the operator a value was withheld when the read
// never produced one — the Error column says why it failed, and the
// two must not contradict each other.
DashboardTagValue[] values = results
.Select(DashboardTagValue.FromBulkReadResult)
.Select(value => _showTagValues || !value.Ok
? value
: value with { ValueText = DashboardTagValue.RedactedValueText })
.ToArray();
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
}
@@ -127,7 +167,11 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
? null
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
return Task.FromResult(new DashboardAlarmQueryResult(
alarms,
error,
_alarmService.WorkerProcessId,
_alarmService.SnapshotTruncated));
}
// Promotes every already-advised tag in this read to the front of the recency
@@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
// Singleton and stateless: it reads the session registry and options per call, and is
// consulted from both subscribe seams (the EventsHub join and the session-details page's
// in-process subscription).
services.AddSingleton<IDashboardSessionAcl, DashboardSessionAcl>();
// Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus
// the /hubs/token endpoint: server-rendered pages read the in-process feeds, so
// nothing in this process builds a hub connection or needs a token for one.
@@ -0,0 +1,85 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
/// </summary>
/// <remarks>
/// <para>
/// Decision order (first match wins):
/// </para>
/// <list type="number">
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
/// is created for a phantom id.</description></item>
/// <item><description>Session carries no tags → allow only when
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
/// is an empty-grant Viewer.
/// </para>
/// <para>
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
/// while the subscription lives.
/// </para>
/// </remarks>
/// <param name="sessionManager">Registry the session id is resolved against.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
public sealed class DashboardSessionAcl(
ISessionManager sessionManager,
IOptions<GatewayOptions> options) : IDashboardSessionAcl
{
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
{
if (principal is null || string.IsNullOrWhiteSpace(sessionId))
{
return false;
}
if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
{
return true;
}
if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
{
return false;
}
if (session.Tags.Count == 0)
{
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
}
// Session.Tags is an ordinal-ignore-case set, so the containment test carries the
// comparison; scanning the claims (rather than materializing the grant) keeps this
// allocation-free beyond the claim enumerator.
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
{
if (session.Tags.Contains(tagClaim.Value))
{
return true;
}
}
return false;
}
}
@@ -17,6 +17,17 @@ public sealed record DashboardTagValue(
DateTimeOffset? SourceTimestamp,
string? Error)
{
/// <summary>
/// Placeholder rendered in place of <see cref="ValueText"/> when
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default). The
/// substitution happens once, in <c>DashboardLiveDataService</c>, so the
/// Browse page renders whatever it is handed and no view has to repeat
/// the decision. Deliberately a visible marker rather than an empty
/// string: an operator must be able to tell a suppressed value from a
/// tag that read back blank.
/// </summary>
public const string RedactedValueText = "[redacted]";
/// <summary>
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
/// advised value; anything lower is uncertain or bad.
@@ -2,14 +2,16 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Mints and validates short-lived bearer tokens for SignalR hub connections.
/// The token is a data-protected JSON payload containing the user's name and
/// role claims. Validity is enforced by the data-protection time-limited
/// protector; no separate signing keys are configured.
/// The token is a data-protected JSON payload containing the user's name, role
/// claims, and granted dashboard visibility tags. Validity is enforced by the
/// data-protection time-limited protector; no separate signing keys are configured.
/// </summary>
/// <remarks>
/// This service is registered as a singleton in
@@ -27,29 +29,42 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </remarks>
public sealed class HubTokenService
{
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
// Internal rather than private so a test can protect a hand-built payload through the same
// purpose and assert how Validate reads a payload shape this class no longer mints (a token
// predating the Tags field). Copying the literal into the test instead would let the two
// drift and silently turn that test into an assertion about an unrelated protector.
internal const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
// 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. Five minutes is transparent to
// clients that re-fetch from /hubs/token on every (re)connect, which is what a remote hub
// consumer is expected to do; see docs/GatewayDashboardDesign.md. Heavier jti-denylist
// revocation is deliberately deferred until per-session hub ACLs land, when tokens gain
// session binding.
// 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
// 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
// on every (re)connect, which is what a remote hub consumer is expected to do; see
// docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation stays deferred.
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
private readonly ITimeLimitedDataProtector _protector;
private readonly IOptions<GatewayOptions> _options;
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
/// <param name="dataProtection">The data protection provider for token encryption.</param>
public HubTokenService(IDataProtectionProvider dataProtection)
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map used to resolve the
/// caller's granted visibility tags at mint time.
/// </param>
public HubTokenService(IDataProtectionProvider dataProtection, IOptions<GatewayOptions> options)
{
ArgumentNullException.ThrowIfNull(dataProtection);
ArgumentNullException.ThrowIfNull(options);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
_options = options;
}
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <summary>Issues a bearer token carrying the user's identity, roles, and granted tags.</summary>
/// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
@@ -65,10 +80,20 @@ public sealed class HubTokenService
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
{
ArgumentNullException.ThrowIfNull(user);
// Resolved from the caller's LDAP-group claims rather than copied from any tag claims the
// principal already carries: re-resolving is what makes the 5-minute lifetime an actual
// staleness bound on the grant. Tags are stamped for every caller — an Administrator
// bypasses the ACL, so theirs are simply moot rather than a special case here.
IReadOnlySet<string> grantedTags = DashboardGroupTagMapping.MapGroupsToTags(
user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType).Select(c => c.Value),
_options.Value.Dashboard.GroupToTag);
HubTokenPayload payload = new(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)],
[.. grantedTags]);
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
}
@@ -107,6 +132,12 @@ public sealed class HubTokenService
}
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
// Rehydrated alongside the roles so the reconstructed principal is what
// IDashboardSessionAcl reads on the hub path — a token minted before the tag field
// existed (or by a caller with no grant) simply yields an empty grant, which denies.
claims.AddRange((payload.Tags ?? []).Select(t => new Claim(
DashboardAuthenticationDefaults.DashboardTagClaimType,
t)));
ClaimsIdentity identity = new(
claims,
@@ -121,5 +152,5 @@ public sealed class HubTokenService
}
}
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles, string[]? Tags);
}
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
@@ -11,11 +13,28 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// client. The hub itself is session-less; clients filter / route messages
/// in the browser.
/// </summary>
/// <remarks>
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), the
/// alarm value fields are stripped from a redacted copy before the message
/// reaches any browser client — the same rule
/// <see cref="DashboardEventBroadcaster"/> applies to the events-hub mirror, so
/// the two SignalR seams cannot disagree about whether values leave the gateway.
/// The source message is never mutated: it fans out from one feed to the gRPC
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
/// those audiences is subject to this dashboard-display flag.
/// </remarks>
/// <param name="alarmService">The gateway's central alarm feed.</param>
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
/// <param name="logger">Logger for best-effort broadcast failures.</param>
public sealed class AlarmsHubPublisher(
IGatewayAlarmService alarmService,
IHubContext<AlarmsHub> hubContext,
IOptions<GatewayOptions> options,
ILogger<AlarmsHubPublisher> logger) : BackgroundService
{
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -37,9 +56,10 @@ public sealed class AlarmsHubPublisher(
try
{
AlarmFeedMessage outbound = _showTagValues ? message : RedactValues(message);
await hubContext.Clients
.Group(AlarmsHub.AllAlarmsGroup)
.SendAsync(AlarmsHub.AlarmMessage, message, stoppingToken)
.SendAsync(AlarmsHub.AlarmMessage, outbound, stoppingToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -66,4 +86,42 @@ public sealed class AlarmsHubPublisher(
}
}
}
/// <summary>
/// Produces a copy of <paramref name="source"/> with the alarm value fields
/// cleared, leaving every other field — reference, severity, state, category,
/// operator, timestamps — intact so the alarms page still renders the row.
/// </summary>
/// <param name="source">The message as the alarm feed produced it.</param>
/// <returns>
/// A redacted deep clone for the two value-bearing payload arms; the source
/// instance itself for every other arm, which carries no value to strip.
/// New payload arms therefore pass through unchanged by default — the switch
/// names only the arms that have something to redact, so adding a valueless
/// arm to the contract needs no change here.
/// </returns>
private static AlarmFeedMessage RedactValues(AlarmFeedMessage source)
{
switch (source.PayloadCase)
{
case AlarmFeedMessage.PayloadOneofCase.Transition:
{
AlarmFeedMessage redacted = source.Clone();
redacted.Transition.CurrentValue = null;
redacted.Transition.LimitValue = null;
return redacted;
}
case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm:
{
AlarmFeedMessage redacted = source.Clone();
redacted.ActiveAlarm.CurrentValue = null;
redacted.ActiveAlarm.LimitValue = null;
return redacted;
}
default:
return source;
}
}
}
@@ -20,9 +20,12 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// values are stripped from a redacted copy of the event before it reaches any
/// dashboard client. The source <see cref="MxEvent"/> is shared with the gRPC
/// event path and the reconnect replay ring, so it is never mutated in place —
/// the redaction is applied to a deep clone. This closes the value-leak seam at
/// the mirror independently of the still-outstanding per-session hub ACL
/// (see <see cref="EventsHub"/>).
/// the redaction is applied to a deep clone. This is the second of two
/// independent layers: <see cref="IDashboardSessionAcl"/> decides at the
/// subscribe seam <em>which</em> sessions a caller may observe at all (see
/// <see cref="EventsHub"/>), while the redaction decides what a permitted
/// subscriber sees — so the value-leak seam stays closed whatever the ACL
/// admits.
/// </remarks>
/// <param name="hubContext">Hub context used to send to the session's group.</param>
/// <param name="viewerRegistry">
@@ -15,8 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// registry to skip all mirror work for sessions nobody is watching.
/// </remarks>
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
/// <param name="sessionAcl">Per-session visibility gate consulted before any group join.</param>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
public sealed class EventsHub(
EventsHubViewerRegistry viewerRegistry,
IDashboardSessionAcl sessionAcl) : Hub
{
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent";
@@ -33,27 +36,26 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
/// client.
/// </summary>
/// <remarks>
/// In v1 the hub-level <see cref="AuthorizeAttribute"/>
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
/// the dashboard roles (Admin or Viewer); both roles may subscribe to
/// any session id they choose. This is acceptable today because (a) the
/// dashboard's per-session views show non-secret session metadata that
/// any authenticated dashboard user can already see, and (b) tag values
/// are stripped from the mirrored events by
/// <see cref="DashboardEventBroadcaster"/> when
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), so the
/// most sensitive payload cannot leak through this seam regardless of the
/// still-missing ACL. The per-session ACL that gates the gRPC
/// <c>StreamEvents</c> RPC is intentionally not yet mirrored here.
/// TODO(per-session-acl): tracked as remediation roadmap item 12
/// (SEC-25). Once a role/scope is introduced that scopes a Viewer to a
/// specific session or tenant, add a session-access check at this seam —
/// either inline (consult the per-user allowed-session set on
/// <c>Context.User</c> claims / <c>Context.Items</c>) or via a dedicated
/// authorization policy applied to the hub method itself.
/// 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
/// 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
/// follows <c>Dashboard:UntaggedSessionVisibility</c>, and a session id the
/// registry does not have is denied outright — the phantom-id denial is
/// therefore a non-Admin rule, not a universal one.
/// A denied caller is not joined to the group and is
/// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
/// stays off for a session nobody is legitimately watching. The same ACL
/// gates the in-process seam used by the session-details page, so neither
/// path is the weaker one.
/// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns>
/// <exception cref="HubException">The caller may not observe this session.</exception>
public Task SubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -61,6 +63,13 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
return Task.CompletedTask;
}
if (!sessionAcl.CanViewSession(Context.User, sessionId))
{
// Surfaced rather than swallowed so a client can tell "denied" from "no events yet".
// The message names neither the session's tags nor the caller's grant.
throw new HubException("Not authorized for this session.");
}
// Register before joining the group: the reverse order would leave a window
// in which this connection is a group member but the broadcaster's gate still
// reports the session unwatched, silently dropping events it should receive.
@@ -0,0 +1,36 @@
using System.Security.Claims;
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
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
/// session-details page.
/// </summary>
/// <remarks>
/// The dashboard authenticates LDAP users while sessions are owned by API keys —
/// two disjoint identity domains — so the bridge is the session <em>tag</em>: a
/// session inherits its owning key's tags, and a dashboard group grants tags via
/// <c>MxGateway:Dashboard:GroupToTag</c>. See
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c>.
/// </remarks>
public interface IDashboardSessionAcl
{
/// <summary>
/// Returns whether <paramref name="principal"/> may observe the events of the
/// session identified by <paramref name="sessionId"/>.
/// </summary>
/// <param name="principal">
/// The dashboard caller. <see langword="null"/> is denied outright — there is no
/// caller to grant tags to, so it never reaches the untagged-session branch and is
/// refused even under <c>UntaggedSessionVisibility=AllViewers</c>. An
/// unauthenticated or claim-less principal (the anonymous-localhost path included)
/// is a Viewer holding an empty tag grant, which denies every tagged session but
/// still follows that branch.
/// </param>
/// <param name="sessionId">Session id the caller wants to observe.</param>
/// <returns><see langword="true"/> when the caller may observe the session; otherwise <see langword="false"/>.</returns>
bool CanViewSession(ClaimsPrincipal? principal, string sessionId);
}
@@ -32,11 +32,17 @@ public sealed class MxAccessGatewayService(
try
{
requestValidator.ValidateOpenSession(request);
// 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).
ApiKeyIdentity? owner = identityAccessor.Current;
GatewaySession session = await sessionManager
.OpenSessionAsync(
SessionOpenRequest.FromContract(request),
ResolveClientIdentity(),
identityAccessor.Current?.KeyId,
owner?.KeyId,
owner?.EffectiveConstraints.DashboardTags,
context.CancellationToken)
.ConfigureAwait(false);
@@ -143,8 +143,13 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
string expiry = key.ExpiresUtc is { } expires
? expires.ToUniversalTime().ToString("u", System.Globalization.CultureInfo.InvariantCulture)
: "-";
// Dashboard tags are operator-facing labels, not key material, so they are safe to
// print alongside the scopes; "-" keeps the column aligned for an untagged key.
string dashboardTags = key.Constraints.DashboardTags.Count > 0
? string.Join(',', key.Constraints.DashboardTags)
: "-";
await output.WriteLineAsync(
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}")
$"{key.KeyId}\t{key.DisplayName}\t{status}\t{expiry}\t{string.Join(',', key.Scopes)}\t{dashboardTags}")
.ConfigureAwait(false);
}
}
@@ -233,7 +233,45 @@ public static class ApiKeyAdminCommandLineParser
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
BrowseSubtrees: GetOptions(options, "browse-subtree"),
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
ReadHistorizedOnly: HasFlag(options, "read-historized-only"))
{
DashboardTags = ParseDashboardTags(options),
};
}
// --dashboard-tags takes a comma-separated list ("team-a,team-b"); repeating the flag unions
// its values. Segments are trimmed and de-duplicated ordinal-ignore-case, matching how the
// enforcement site compares them. An empty segment is rejected rather than dropped: a stray
// comma otherwise silently persists a grant the operator did not mean to write.
private static IReadOnlyList<string> ParseDashboardTags(Dictionary<string, List<string?>> options)
{
if (!options.TryGetValue("dashboard-tags", out List<string?>? values))
{
return Array.Empty<string>();
}
List<string> tags = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string? raw in values)
{
foreach (string segment in (raw ?? string.Empty).Split(','))
{
string tag = segment.Trim();
if (tag.Length == 0)
{
throw new FormatException(
"--dashboard-tags must be a comma-separated list of non-empty tags.");
}
if (seen.Add(tag))
{
tags.Add(tag);
}
}
}
return tags.Count == 0 ? Array.Empty<string>() : tags;
}
// Parses the optional --expires value into an absolute UTC expiry. Accepts a relative
@@ -22,6 +22,11 @@ public static class ApiKeyConstraintSerializer
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
/// <param name="json">The JSON string to deserialize.</param>
/// <returns>The deserialized constraints, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null/whitespace.</returns>
/// <remarks>
/// Members absent from the JSON take their default: rows persisted before
/// <see cref="ApiKeyConstraints.DashboardTags"/> existed carry no <c>dashboard_tags</c>
/// member and deserialize to an untagged key, unchanged in every other respect.
/// </remarks>
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -10,6 +10,38 @@ public sealed record ApiKeyConstraints(
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
private readonly IReadOnlyList<string> _dashboardTags = Array.Empty<string>();
/// <summary>
/// Gets the dashboard event-visibility tags granted to this key (SEC-25).
/// </summary>
/// <remarks>
/// <para>
/// This is <em>dashboard event-visibility only</em>. It is <strong>never</strong> a
/// data-access constraint: no read, write, browse, or subscribe path consults it, and
/// adding a tag neither widens nor narrows what the key may read or write. Sessions
/// opened by the key inherit these tags (<c>GatewaySession.Tags</c>), and a dashboard
/// Viewer may observe a session's mirrored event metadata only when their granted tags
/// intersect the session's. It rides in the same serialized constraints blob purely to
/// avoid an auth-store schema migration — see
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c> §3.1.
/// </para>
/// <para>
/// Tag values are stored exactly as supplied; comparisons are ordinal-ignore-case at the
/// enforcement site, so <c>Team-A</c> and <c>team-a</c> name the same tag. An empty list
/// means untagged.
/// </para>
/// </remarks>
public IReadOnlyList<string> DashboardTags
{
get => _dashboardTags;
// Defensive copy: the tag set is a security-relevant grant, so the record must not alias a
// caller-owned list that could be mutated after construction. A null or empty value (an old
// persisted row has no dashboard_tags member at all) normalizes to untagged.
init => _dashboardTags = value is { Count: > 0 } ? [.. value] : Array.Empty<string>();
}
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
@@ -22,6 +54,11 @@ public sealed record ApiKeyConstraints(
ReadHistorizedOnly: false);
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
/// <remarks>
/// <see cref="DashboardTags"/> counts here even though it restricts nothing: an empty
/// instance is not persisted at all (<c>ApiKeyConstraintSerializer.Serialize</c> returns
/// null), so a key whose only per-key policy is a dashboard tag must still round-trip.
/// </remarks>
public bool IsEmpty =>
ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0
@@ -30,7 +67,8 @@ public sealed record ApiKeyConstraints(
&& MaxWriteClassification is null
&& BrowseSubtrees.Count == 0
&& !ReadAlarmOnly
&& !ReadHistorizedOnly;
&& !ReadHistorizedOnly
&& DashboardTags.Count == 0;
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
public bool HasReadConstraints =>
@@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
@@ -12,6 +13,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Sessions;
public sealed class GatewaySession
{
// Shared untagged sentinel: most sessions carry no dashboard tags. Frozen so the exposed set
// cannot be mutated by a cast — the tag set is a visibility grant, not a scratch collection.
private static readonly IReadOnlySet<string> EmptyTags = FrozenSet<string>.Empty;
private readonly object _syncRoot = new();
private readonly SemaphoreSlim _closeLock = new(1, 1);
private readonly SessionEventStreaming _eventStreaming;
@@ -149,6 +154,12 @@ public sealed class GatewaySession
/// <see cref="MarkFaulted"/> using <paramref name="eventStreaming"/>'s clock so the timer
/// is unit-testable.
/// </param>
/// <param name="ownerDashboardTags">
/// Dashboard event-visibility tags inherited from the owning API key (SEC-25). 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.
/// </param>
public GatewaySession(
string sessionId,
string backendName,
@@ -167,7 +178,8 @@ public sealed class GatewaySession
TimeSpan detachGrace = default,
TimeSpan workerReadyWaitTimeout = default,
ArrayAddressNormalizer? addressNormalizer = null,
TimeSpan faultedGrace = default)
TimeSpan faultedGrace = default,
IReadOnlyList<string>? ownerDashboardTags = null)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -195,6 +207,9 @@ public sealed class GatewaySession
Nonce = nonce;
ClientIdentity = clientIdentity;
OwnerKeyId = ownerKeyId;
Tags = ownerDashboardTags is { Count: > 0 }
? ownerDashboardTags.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
: EmptyTags;
ClientSessionName = clientSessionName;
ClientCorrelationId = clientCorrelationId;
CommandTimeout = commandTimeout;
@@ -241,6 +256,19 @@ public sealed class GatewaySession
/// </summary>
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.
/// </summary>
/// <remarks>
/// Immutable for the session's life — assigned once at construction from the owner key's
/// <c>ApiKeyConstraints.DashboardTags</c> — so a dashboard subscription decided at join time
/// never has to be re-evaluated. The set compares ordinal-ignore-case. These tags gate
/// nothing on the gRPC data path; they exist only so the dashboard can scope which sessions'
/// mirrored event metadata a Viewer may observe.
/// </remarks>
public IReadOnlySet<string> Tags { get; }
/// <summary>
/// Gets the client-supplied session name.
/// </summary>
@@ -765,13 +793,11 @@ public sealed class GatewaySession
// distributor guarantees a single consumer) and maps each frame to the public MxEvent,
// preserving worker order. Mirrors the former ProduceEventsAsync mapping exactly.
//
// This deliberately duplicates the three lines of ReadEventsAsync rather than enumerating
// it: every worker event crosses this source, and routing it through a second pure
// pass-through iterator cost two extra MoveNextAsync state-machine hops per event for no
// semantic value. ReadEventsAsync stays for ISessionManager.ReadEventsAsync; keep the two
// bodies in step. Only one of them may run per attach — WorkerClient.ReadEventsAsync
// single-reader-claims the event channel and throws on a second consumer — and on the
// distributor path that one consumer is this method.
// This is the session's only reader of the worker event channel: every gateway consumer —
// gRPC subscribers, the dashboard mirror, the alarm monitor — attaches to the distributor
// this source feeds. WorkerClient.ReadEventsAsync single-reader-claims that channel and
// throws on a second consumer, so any future path that drains it directly fails loudly
// rather than splitting events.
private async IAsyncEnumerable<MxEvent> MapWorkerEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -1522,33 +1548,6 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Reads events from the worker as an asynchronous enumerable stream.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <remarks>
/// Backs <c>ISessionManager.ReadEventsAsync</c>. The distributor does <em>not</em> come
/// through here — <c>MapWorkerEventsAsync</c> inlines this body to save a per-event
/// iterator hop, so changes made here belong there too. The two are mutually exclusive
/// per attach: <see cref="IWorkerClient.ReadEventsAsync"/> claims the worker event
/// channel for a single reader and throws on the second consumer.
/// </remarks>
/// <returns>An asynchronous stream of worker events.</returns>
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
IWorkerClient workerClient = await GetReadyWorkerClientAsync(cancellationToken).ConfigureAwait(false);
TouchClientActivity(_eventStreaming.TimeProvider.GetUtcNow());
await foreach (WorkerEvent workerEvent in workerClient
.ReadEventsAsync(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return workerEvent;
}
}
/// <summary>
/// Closes the session and shuts down the worker process.
/// </summary>
@@ -17,6 +17,33 @@ public interface ISessionManager
string? ownerKeyId,
CancellationToken cancellationToken);
/// <summary>
/// Opens a new gateway session, stamping the owning API key's dashboard event-visibility
/// tags onto it (SEC-25).
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param>
/// <param name="ownerKeyId">API key identifier of the caller creating the session.</param>
/// <param name="ownerDashboardTags">
/// The owner key's <c>ApiKeyConstraints.DashboardTags</c>. Null or empty opens an untagged
/// session. Never sourced from the client's wire request — see
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c> §3.1.
/// </param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The newly opened session.</returns>
/// <remarks>
/// The default implementation forwards to the tagless overload, so an implementation that
/// does not model tags (unit-test fakes) opens an <em>untagged</em> session. That is the
/// fail-closed direction: untagged sessions are the least dashboard-visible ones.
/// </remarks>
Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
=> OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken);
/// <summary>Attempts to retrieve a session by ID.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session, if found.</param>
@@ -35,14 +62,6 @@ public interface ISessionManager
WorkerCommand command,
CancellationToken cancellationToken);
/// <summary>Reads events streamed from the worker for the specified session.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Events emitted by the worker.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken);
/// <summary>Closes a session and terminates its worker process.</summary>
/// <param name="sessionId">Identifier of the session to close.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
@@ -87,11 +87,20 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken)
=> OpenSessionAsync(request, clientIdentity, ownerKeyId, ownerDashboardTags: null, cancellationToken);
/// <inheritdoc />
public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
@@ -101,7 +110,7 @@ public sealed class SessionManager : ISessionManager
bool sessionOpenedRecorded = false;
try
{
session = CreateSession(request, clientIdentity, ownerKeyId);
session = CreateSession(request, clientIdentity, ownerKeyId, ownerDashboardTags);
if (!_registry.TryAdd(session))
{
throw new SessionManagerException(
@@ -187,16 +196,6 @@ public sealed class SessionManager : ISessionManager
}
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
{
GatewaySession session = GetRequiredSession(sessionId);
return session.ReadEventsAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -504,7 +503,8 @@ public sealed class SessionManager : ISessionManager
private GatewaySession CreateSession(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId)
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags)
{
string sessionUid = Guid.NewGuid().ToString("N");
string sessionId = $"session-{sessionUid}";
@@ -551,7 +551,8 @@ public sealed class SessionManager : ISessionManager
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.DetachGraceSeconds)),
TimeSpan.FromMilliseconds(Math.Max(0, _options.Sessions.WorkerReadyWaitTimeoutMs)),
_addressNormalizer,
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)));
TimeSpan.FromSeconds(Math.Max(0, _options.Sessions.FaultedGraceSeconds)),
ownerDashboardTags);
}
private static string CreateClientCorrelationId(
@@ -464,11 +464,6 @@ public sealed class AlarmFailoverEndToEndTests
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
@@ -0,0 +1,656 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
/// <summary>
/// Carries the worker's truncated-fetch verdict across the gateway: worker
/// reply payload → <see cref="GatewayAlarmMonitor"/> → the public
/// <c>QueryActiveAlarms</c> stream (per record) and the live
/// <c>StreamAlarms</c> feed (the <c>snapshot_status</c> frame).
/// </summary>
/// <remarks>
/// <para>
/// The truncation guard itself (the worker merging rather than replacing
/// a capped snapshot) is already covered in the worker suite. What was
/// missing is that the guard is <em>silent</em>: a capped fetch suppresses
/// absence-implies-Clear inference and says so only in a rate-limited
/// stderr warning, so a consumer of the alarm surface could not tell a
/// complete active set from a capped one. These tests pin the structural
/// signal that replaces the guesswork.
/// </para>
/// <para>
/// The load-bearing assertion is the <em>false</em> one
/// (<see cref="QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset"/>).
/// "Truncated reply sets the flag" would also pass against a field
/// hard-wired to true; only the complete-reply case proves the flag is
/// actually derived from the worker's verdict.
/// </para>
/// </remarks>
public sealed class AlarmTruncationSignalTests
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// A capped worker reply sets the monitor's completeness caveat and
/// stamps every cached snapshot, so both the dashboard (which reads the
/// service flag) and the RPC (which reads the records) can surface it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = true,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.True(monitor.SnapshotTruncated);
ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms);
Assert.True(cached.FromTruncatedSnapshot);
// Truncation is about the completeness of the SET, not the fidelity of
// the record — the subtag-fallback flag must stay independent of it.
Assert.False(cached.Degraded);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The public <c>QueryActiveAlarms</c> stream carries the per-record flag
/// through untouched. That RPC returns a bare
/// <c>stream ActiveAlarmSnapshot</c> with no envelope message, so the
/// per-record boolean is the only place set-level degraded status can
/// ride — if the service ever starts re-projecting records instead of
/// forwarding them, this is what catches the dropped field.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient()
{
FakeGatewayAlarmService alarms = new()
{
SnapshotTruncated = true,
CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
MxAccessGatewayService service = CreateService(alarms);
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages);
Assert.True(streamed.FromTruncatedSnapshot);
Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference);
}
/// <summary>
/// The control. A complete worker reply must leave both the monitor flag
/// and the streamed records unset — otherwise every snapshot would read
/// as possibly-incomplete and the signal would carry no information.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = false,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.False(monitor.SnapshotTruncated);
MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService
{
SnapshotTruncated = monitor.SnapshotTruncated,
CurrentAlarms = monitor.CurrentAlarms,
});
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The feed-level edge. A reconcile that flips the verdict to truncated
/// pushes exactly one <c>snapshot_status</c> frame to every attached
/// subscriber; a following reconcile carrying the same verdict pushes
/// none. Without the edge test the frame could be emitted per reconcile —
/// a status frame every few seconds forever, which consumers would learn
/// to ignore.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileFlippingToTruncated_EmitsOneSnapshotStatusFrame()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new();
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
List<AlarmFeedMessage> received = [];
TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously);
using CancellationTokenSource streamCts = new();
Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token);
await attached.Task.WaitAsync(WaitTimeout);
// The worker's next fetch comes back capped. A provider-mode event forces the reconcile
// immediately, so the test never waits on the periodic timer; each probe also broadcasts a
// provider_status frame, which is the barrier the assertions below count on.
sessions.SnapshotTruncated = true;
sessions.EmitEvent(ProviderModeProbe(1));
// A second, identical reconcile: same verdict, so no second frame. The third probe is only
// a barrier — the monitor handles provider-mode events one at a time and awaits the
// reconcile inside each, so its provider_status proves probe 2's reconcile has completed.
sessions.EmitEvent(ProviderModeProbe(2));
sessions.EmitEvent(ProviderModeProbe(3));
await WaitUntilAsync(() => CountOf(received, AlarmFeedMessage.PayloadOneofCase.ProviderStatus) >= 4, WaitTimeout);
lock (received)
{
bool[] verdicts = received
.Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus)
.Select(m => m.SnapshotStatus.Truncated)
.ToArray();
// The first is this subscriber's open-time baseline; the second is the edge.
Assert.Equal([false, true], verdicts);
}
await streamCts.CancelAsync();
await reader;
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The clearing edge. A fetch that comes back under the cap restores
/// absence authority, and the feed says so — otherwise a consumer that
/// saw the truncated frame would caveat its alarm set forever.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileClearingTruncation_EmitsTheClearingFrame()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new() { SnapshotTruncated = true };
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.SnapshotTruncated, WaitTimeout);
List<AlarmFeedMessage> received = [];
TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously);
using CancellationTokenSource streamCts = new();
Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token);
await attached.Task.WaitAsync(WaitTimeout);
sessions.SnapshotTruncated = false;
sessions.EmitEvent(ProviderModeProbe(1));
await WaitUntilAsync(
() => CountOf(received, AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) >= 2,
WaitTimeout);
lock (received)
{
bool[] verdicts = received
.Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus)
.Select(m => m.SnapshotStatus.Truncated)
.ToArray();
Assert.Equal([true, false], verdicts);
}
await streamCts.CancelAsync();
await reader;
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// Late-joiner priming while the verdict is truncated. The frame must
/// arrive after <c>provider_status</c> and before the cached
/// <c>active_alarm</c> frames: a consumer that applies the snapshot as it
/// streams needs the completeness caveat in hand before the records it
/// qualifies, not after.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscriberAttachingWhileTruncated_IsPrimedBeforeTheCachedSnapshot()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = true,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.SnapshotTruncated && monitor.CurrentAlarms.Count == 1, WaitTimeout);
AlarmFeedMessage[] preamble = await ReadPreambleAsync(monitor);
Assert.Equal(
[
AlarmFeedMessage.PayloadOneofCase.ProviderStatus,
AlarmFeedMessage.PayloadOneofCase.SnapshotStatus,
AlarmFeedMessage.PayloadOneofCase.ActiveAlarm,
AlarmFeedMessage.PayloadOneofCase.SnapshotComplete,
],
preamble.Select(m => m.PayloadCase).ToArray());
Assert.True(preamble[1].SnapshotStatus.Truncated);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The control for the priming frame: it is unconditional, so a
/// subscriber attaching to a complete feed is told so explicitly rather
/// than having to infer "no frame means not truncated" — an inference
/// that is indistinguishable from a gateway that never sends the frame.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscriberAttachingWhileComplete_IsPrimedWithTheFalseVerdict()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new();
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
AlarmFeedMessage[] preamble = await ReadPreambleAsync(monitor);
Assert.Equal(
[
AlarmFeedMessage.PayloadOneofCase.ProviderStatus,
AlarmFeedMessage.PayloadOneofCase.SnapshotStatus,
AlarmFeedMessage.PayloadOneofCase.SnapshotComplete,
],
preamble.Select(m => m.PayloadCase).ToArray());
Assert.False(preamble[1].SnapshotStatus.Truncated);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// A monitor restart drops the truncation verdict with the cache
/// generation it describes. That drop is a completeness change like any
/// other, so subscribers — which outlive the monitor's worker session —
/// must see the clearing frame; otherwise a feed that silently re-seeds
/// leaves them caveating a set that is no longer truncated.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MonitorRestartClearingTruncation_EmitsTheClearingFrame()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new() { SnapshotTruncated = true };
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.SnapshotTruncated, WaitTimeout);
List<AlarmFeedMessage> received = [];
TaskCompletionSource attached = new(TaskCreationOptions.RunContinuationsAsynchronously);
using CancellationTokenSource streamCts = new();
Task reader = ReadFeedAsync(monitor, received, attached, streamCts.Token);
await attached.Task.WaitAsync(WaitTimeout);
// Ending the worker event stream faults the monitor lifecycle, which clears the cache.
sessions.EndWorkerEventStream();
await WaitUntilAsync(
() => CountOf(received, AlarmFeedMessage.PayloadOneofCase.SnapshotStatus) >= 2,
WaitTimeout);
lock (received)
{
// Only the first two: the supervisor re-opens the session after its backoff, and that
// second lifecycle legitimately re-reports the truncated verdict.
bool[] verdicts = received
.Where(m => m.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotStatus)
.Select(m => m.SnapshotStatus.Truncated)
.Take(2)
.ToArray();
Assert.Equal([true, false], verdicts);
}
await streamCts.CancelAsync();
await reader;
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
private static int CountOf(List<AlarmFeedMessage> received, AlarmFeedMessage.PayloadOneofCase payloadCase)
{
lock (received)
{
return received.Count(m => m.PayloadCase == payloadCase);
}
}
// Drains the monitor's feed into received (every frame, preamble included), signalling gate
// once the subscriber is registered — that is, on its first frame.
private static Task ReadFeedAsync(
GatewayAlarmMonitor monitor,
List<AlarmFeedMessage> received,
TaskCompletionSource gate,
CancellationToken cancellationToken)
{
return Task.Run(
async () =>
{
try
{
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, cancellationToken))
{
lock (received)
{
received.Add(message);
}
gate.TrySetResult();
}
}
catch (OperationCanceledException)
{
// Expected when the test cancels the stream.
}
},
CancellationToken.None);
}
// Reads one subscriber's open-time preamble: everything up to and including snapshot_complete.
private static async Task<AlarmFeedMessage[]> ReadPreambleAsync(GatewayAlarmMonitor monitor)
{
List<AlarmFeedMessage> preamble = [];
using CancellationTokenSource streamCts = new(WaitTimeout);
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, streamCts.Token))
{
preamble.Add(message);
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete)
{
break;
}
}
return [.. preamble];
}
// A no-op provider-mode event. The monitor forces an immediate reconcile after every one,
// which is how these tests drive a reconcile pass without waiting on the periodic timer.
private static MxEvent ProviderModeProbe(ulong sequence) => new()
{
Family = MxEventFamily.OnAlarmProviderModeChanged,
WorkerSequence = sequence,
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
{
Mode = AlarmProviderMode.Alarmmgr,
Reason = "probe",
At = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
},
};
private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot)
{
return new ActiveAlarmSnapshot
{
AlarmFullReference = reference,
SourceObjectReference = "Tank01.Level",
AlarmTypeName = "HiHi",
Category = "Area",
Severity = 500,
CurrentState = AlarmConditionState.Active,
SourceProvider = AlarmProviderMode.Alarmmgr,
FromTruncatedSnapshot = fromTruncatedSnapshot,
};
}
private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics)
{
AlarmsOptions options = new()
{
Enabled = true,
SubscriptionExpression = @"\\NODE\Galaxy!Area",
};
return new GatewayAlarmMonitor(
sessions,
new StubWatchListResolver(),
metrics,
Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
NullLogger<GatewayAlarmMonitor>.Instance);
}
private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms)
{
StubSessionManager sessions = new();
return new MxAccessGatewayService(
sessions,
new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new StubEventStreamService(),
new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance,
alarms);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met in time.");
}
/// <summary><see cref="IAlarmWatchListResolver"/> that resolves an empty watch-list.</summary>
private sealed class StubWatchListResolver : IAlarmWatchListResolver
{
/// <inheritdoc />
public Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<AlarmSubtagTarget>>([]);
}
/// <summary>
/// Minimal <see cref="ISessionManager"/> that answers the monitor's
/// QueryActiveAlarms with a scripted reply payload — the seam this suite
/// drives the truncation verdict through.
/// </summary>
private sealed class StubSessionManager : ISessionManager
{
private readonly Channel<WorkerEvent> _events = Channel.CreateUnbounded<WorkerEvent>();
private readonly TaskCompletionSource _reconciled =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly object _sync = new();
private bool _snapshotTruncated;
private IReadOnlyList<ActiveAlarmSnapshot> _snapshots = [];
/// <summary>
/// Gets or sets the truncation verdict the scripted reply carries. Settable mid-test so
/// a suite can drive the verdict across reconciles and observe the feed-level edge.
/// </summary>
public bool SnapshotTruncated
{
get { lock (_sync) { return _snapshotTruncated; } }
set { lock (_sync) { _snapshotTruncated = value; } }
}
/// <summary>Gets or sets the snapshots the scripted reply carries.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> Snapshots
{
get { lock (_sync) { return _snapshots; } }
set { lock (_sync) { _snapshots = value; } }
}
/// <summary>Completes once the monitor has issued its first QueryActiveAlarms.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
/// <summary>Pushes a worker event into the session's distributor pump.</summary>
/// <param name="mxEvent">The event to push.</param>
public void EmitEvent(MxEvent mxEvent) =>
_events.Writer.TryWrite(new WorkerEvent { Event = mxEvent });
/// <summary>
/// Ends the worker event stream, which faults the monitor's lifecycle exactly as a
/// worker exit would and takes it through its cache-clearing teardown.
/// </summary>
public void EndWorkerEventStream() => _events.Writer.TryComplete();
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken)
{
GatewaySession session = new(
Guid.NewGuid().ToString("N"),
"Galaxy",
"pipe-test",
"nonce-test",
clientIdentity,
null,
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
session.MarkReady();
return Task.FromResult(session);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
MxCommandReply reply = new()
{
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms)
{
QueryActiveAlarmsReplyPayload payload = new();
lock (_sync)
{
payload.SnapshotTruncated = _snapshotTruncated;
payload.Snapshots.AddRange(_snapshots.Select(snapshot => snapshot.Clone()));
}
reply.QueryActiveAlarms = payload;
_reconciled.TrySetResult();
}
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = null;
return false;
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
{
_events.Writer.TryComplete();
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
/// <see cref="IEventStreamService"/> stub — QueryActiveAlarms never
/// touches the event path, but the service constructor requires one.
/// </summary>
private sealed class StubEventStreamService : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
string? callerKeyId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
}
}
@@ -618,11 +618,6 @@ public sealed class GatewayAlarmMonitorAttachOrderTests
return new WorkerCommandReply { Reply = reply };
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
@@ -777,11 +777,6 @@ public sealed class GatewayAlarmMonitorProviderModeTests
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
@@ -0,0 +1,164 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
/// <summary>
/// Covers the projection <see cref="GatewayConfigurationProvider"/> makes from bound
/// <see cref="GatewayOptions"/> onto the effective-configuration record the dashboard renders.
/// </summary>
/// <remarks>
/// The projection is hand-written member by member, so an option that exists and validates can
/// still be invisible on the settings page — which is exactly what happened to the two SEC-25
/// dashboard-ACL options. These tests pin the dashboard-tag members to the options they come
/// from; the redaction assertions pin the opposite invariant, that the two secret-bearing members
/// are the only ones masked.
/// </remarks>
public sealed class GatewayConfigurationProviderTests
{
/// <summary>The group → tag map reaches the projection with its groups, tags and ordering intact.</summary>
[Fact]
public void GetEffectiveConfiguration_CopiesGroupToTagMapping()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
// A multi-tag group is the interesting case: a projection that flattened the
// array to its first element, or to a joined string, would still satisfy a
// single-tag fixture.
["GwOps"] = ["team-a", "team-b"],
["GwViewers"] = ["team-a"],
},
},
};
EffectiveDashboardConfiguration dashboard = Project(options).Dashboard;
Assert.Equal(2, dashboard.GroupToTag.Count);
Assert.Equal(["team-a", "team-b"], dashboard.GroupToTag["GwOps"]);
Assert.Equal(["team-a"], dashboard.GroupToTag["GwViewers"]);
}
/// <summary>
/// The map's case-insensitive lookup survives the projection. LDAP group names arrive in
/// whatever case the directory returns them, so a projection that rebuilt the dictionary with
/// the default ordinal comparer would silently stop matching.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_KeepsGroupToTagLookupCaseInsensitive()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwOps"] = ["team-a"],
},
},
};
EffectiveDashboardConfiguration dashboard = Project(options).Dashboard;
Assert.True(dashboard.GroupToTag.ContainsKey("gwops"));
}
/// <summary>
/// An unconfigured map projects as empty rather than null — the settings page renders the row
/// either way, and "none configured" is the operationally interesting answer.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_WhenNoTagsConfigured_ProjectsEmptyMapping()
{
EffectiveDashboardConfiguration dashboard = Project(new GatewayOptions()).Dashboard;
Assert.Empty(dashboard.GroupToTag);
Assert.Empty(dashboard.GroupToRole);
}
/// <summary>The untagged-session visibility policy is projected, defaulting to the fail-closed value.</summary>
[Fact]
public void GetEffectiveConfiguration_CopiesUntaggedSessionVisibility()
{
Assert.Equal(
UntaggedSessionVisibility.AdminOnly,
Project(new GatewayOptions()).Dashboard.UntaggedSessionVisibility);
GatewayOptions widened = new()
{
Dashboard = new DashboardOptions
{
UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers,
},
};
Assert.Equal(
UntaggedSessionVisibility.AllViewers,
Project(widened).Dashboard.UntaggedSessionVisibility);
}
/// <summary>The pre-existing dashboard members keep coming from the options they always did.</summary>
[Fact]
public void GetEffectiveConfiguration_CopiesTheOtherDashboardMembers()
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
Enabled = false,
AllowAnonymousLocalhost = false,
SnapshotIntervalMilliseconds = 2_500,
RecentFaultLimit = 7,
RecentSessionLimit = 11,
ShowTagValues = true,
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmins"] = "Administrator",
},
},
};
EffectiveDashboardConfiguration dashboard = Project(options).Dashboard;
Assert.False(dashboard.Enabled);
Assert.False(dashboard.AllowAnonymousLocalhost);
Assert.Equal(2_500, dashboard.SnapshotIntervalMilliseconds);
Assert.Equal(7, dashboard.RecentFaultLimit);
Assert.Equal(11, dashboard.RecentSessionLimit);
Assert.True(dashboard.ShowTagValues);
Assert.Equal("Administrator", dashboard.GroupToRole["GwAdmins"]);
}
/// <summary>
/// The masking boundary. Only the pepper name and the LDAP bind password are redacted; the
/// tag mapping is configuration, not a secret, so masking it would hide the very thing the
/// settings page was extended to show.
/// </summary>
[Fact]
public void GetEffectiveConfiguration_RedactsOnlyTheSecretBearingMembers()
{
GatewayOptions options = new()
{
Ldap = new LdapOptions { ServiceAccountPassword = "bind-password" },
Dashboard = new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwOps"] = ["team-a"],
},
},
};
EffectiveGatewayConfiguration configuration = Project(options);
Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Authentication.PepperSecretName);
Assert.Equal(GatewayConfigurationProvider.RedactedValue, configuration.Ldap.ServiceAccountPassword);
Assert.Equal(["team-a"], configuration.Dashboard.GroupToTag["GwOps"]);
}
private static EffectiveGatewayConfiguration Project(GatewayOptions options) =>
new GatewayConfigurationProvider(Options.Create(options)).GetEffectiveConfiguration();
}
@@ -161,6 +161,33 @@ public sealed class GatewayOptionsTests
Assert.Null(new DashboardOptions().AutoLoginUser);
}
/// <summary>
/// Verifies that <c>Dashboard:GroupToTag</c> keeps its ordinal-ignore-case group lookup after
/// configuration binding, and that <c>UntaggedSessionVisibility</c> binds from its string form.
/// </summary>
/// <remarks>
/// The property initializer seeds the dictionary with <see cref="StringComparer.OrdinalIgnoreCase"/>,
/// but only the binder decides whether that instance is populated in place or replaced by a
/// default-comparer one. Asserting the comparer on a hand-constructed <see cref="DashboardOptions"/>
/// would prove nothing about the configured path; a mis-cased LDAP group name from the directory
/// would then silently grant no tags, and the SEC-25 ACL would deny with no diagnostic.
/// </remarks>
[Fact]
public void DashboardOptions_GroupToTag_BindsCaseInsensitively()
{
GatewayOptions options = BindOptions(new Dictionary<string, string?>
{
["MxGateway:Dashboard:GroupToTag:GwReader:0"] = "team-a",
["MxGateway:Dashboard:GroupToTag:GwReader:1"] = "team-b",
["MxGateway:Dashboard:UntaggedSessionVisibility"] = "AllViewers",
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
});
Assert.True(options.Dashboard.GroupToTag.TryGetValue("gwREADER", out string[]? tags));
Assert.Equal(["team-a", "team-b"], tags);
Assert.Equal(UntaggedSessionVisibility.AllViewers, options.Dashboard.UntaggedSessionVisibility);
}
private static GatewayOptions BindOptions(IReadOnlyDictionary<string, string?> configurationValues)
{
using ServiceProvider services = BuildServices(configurationValues);
@@ -884,6 +884,144 @@ public sealed class GatewayOptionsValidatorTests
Assert.True(result.Succeeded);
}
/// <summary>Verifies a populated GroupToTag map with well-formed tags passes validation.</summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagWellFormed()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b", "team-c"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>
/// Verifies GroupToTag is not coupled to GroupToRole: a group that grants a tag
/// but no role (and vice versa) is a legal configuration.
/// </summary>
[Fact]
public void Validate_Succeeds_WhenGroupToTagAndGroupToRoleShareNoGroups()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = "Administrator",
},
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["TeamBViewers"] = ["team-b"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies a blank GroupToTag key (LDAP group name) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagKeyIsBlank()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[" "] = ["team-a"],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag") && f.Contains("non-blank"));
}
/// <summary>Verifies a blank tag entry fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagContainsBlankTag()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a", " "],
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("non-blank"));
}
/// <summary>Verifies a null tag list (e.g. <c>"GwViewer": null</c> in JSON) fails validation.</summary>
[Fact]
public void Validate_Fails_WhenGroupToTagValueIsNull()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = null!,
},
});
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:GroupToTag['GwViewer']") && f.Contains("null"));
}
/// <summary>Verifies both defined <see cref="UntaggedSessionVisibility"/> values pass validation.</summary>
/// <param name="visibility">The visibility value under test.</param>
[Theory]
[InlineData(UntaggedSessionVisibility.AdminOnly)]
[InlineData(UntaggedSessionVisibility.AllViewers)]
public void Validate_Succeeds_ForDefinedUntaggedSessionVisibility(UntaggedSessionVisibility visibility)
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions { UntaggedSessionVisibility = visibility });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
/// <summary>Verifies an out-of-range <see cref="UntaggedSessionVisibility"/> fails validation.</summary>
[Fact]
public void Validate_Fails_WhenUntaggedSessionVisibilityUndefined()
{
GatewayOptions options = CloneWithDashboard(
ValidOptions(),
new DashboardOptions { UntaggedSessionVisibility = (UntaggedSessionVisibility)42 });
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Failed);
Assert.Contains(
result.Failures!,
f => f.Contains("MxGateway:Dashboard:UntaggedSessionVisibility"));
}
/// <summary>Verifies the shipped default for untagged sessions is the strict AdminOnly.</summary>
[Fact]
public void DashboardOptions_UntaggedSessionVisibility_DefaultsToAdminOnly()
{
Assert.Equal(UntaggedSessionVisibility.AdminOnly, new DashboardOptions().UntaggedSessionVisibility);
Assert.Empty(new DashboardOptions().GroupToTag);
}
/// <summary>Verifies plaintext LDAP transport (None) aborts startup in Production.</summary>
[Fact]
public void Validate_Fails_WhenLdapTransportNoneInProduction()
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Renders <see cref="AlarmsPage"/> and asserts the truncated-snapshot
/// caveat banner appears exactly when the alarm query reports a capped
/// provider fetch.
/// </summary>
/// <remarks>
/// <para>
/// The absence assertion is the load-bearing one: a banner that renders
/// unconditionally would satisfy the positive case while telling every
/// operator, on every normal day, that the alarm list might be missing
/// alarms. A caveat that is always on is a caveat nobody reads.
/// </para>
/// <para>
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, as in
/// <c>SecretsNavRenderTests</c> — the assertion is about markup the server
/// emits, so no component-testing dependency is warranted. The page's
/// poll loop runs its first pass inline during <c>OnInitialized</c>
/// (the stub query completes synchronously), so the rendered markup
/// already reflects the query result.
/// </para>
/// </remarks>
public sealed class AlarmsPageTruncationBannerTests
{
private const string BannerMarker = "Alarm snapshot may be incomplete";
/// <summary>A capped provider fetch puts the completeness caveat on the page.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner()
{
string html = await RenderAsync(snapshotTruncated: true);
Assert.Contains(BannerMarker, html, StringComparison.Ordinal);
}
/// <summary>
/// The proof the banner is gated. A complete fetch must render no caveat
/// at all, while the page itself still renders — the alarm-table heading
/// is the control that keeps this from passing over a blank page.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenSnapshotComplete_OmitsTheCaveatBanner()
{
string html = await RenderAsync(snapshotTruncated: false);
Assert.DoesNotContain(BannerMarker, html, StringComparison.Ordinal);
Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
}
private static async Task<string> RenderAsync(bool snapshotTruncated)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardLiveDataService>(
new StubLiveDataService(snapshotTruncated));
services.AddSingleton<IGatewayAlarmService>(
new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated });
services.AddSingleton<IOptions<GatewayOptions>>(
Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<AlarmsPage>();
return output.ToHtmlString();
});
}
// Answers the page's 3-second poll synchronously, so the first pass completes
// inline inside OnInitialized and the rendered markup reflects it.
private sealed class StubLiveDataService(bool snapshotTruncated) : IDashboardLiveDataService
{
/// <inheritdoc />
public Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardLiveReadResult.Empty);
/// <inheritdoc />
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken) =>
Task.FromResult(new DashboardAlarmQueryResult(
Alarms: [],
Error: null,
WorkerProcessId: null,
SnapshotTruncated: snapshotTruncated));
}
}
@@ -0,0 +1,358 @@
using System.Runtime.CompilerServices;
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Covers <c>dashboard_tags</c> on the dashboard API Keys page: the constraints
/// column must name it, and the create form must be able to set it.
/// </summary>
/// <remarks>
/// <para>
/// The column mattered first. <c>ApiKeyConstraints.IsEmpty</c> counts
/// <c>DashboardTags</c>, so a key whose only per-key policy is a dashboard tag is
/// <em>not</em> unconstrained — but the page's summary enumerated only the eight
/// positional members, produced an empty string, and rendered it as <c>-</c>. An
/// operator auditing keys saw the same cell for "no policy recorded" and "scoped to
/// team-a", which is the reading a tag grant can least afford.
/// </para>
/// <para>
/// Rendered through the framework's static rendering infrastructure, the idiom
/// <c>AlarmsPageTruncationBannerTests</c> and <c>SessionDetailsPageEventAclTests</c>
/// use — the assertions are about emitted markup, so no component-testing package is
/// warranted.
/// </para>
/// </remarks>
public sealed class ApiKeysPageDashboardTagsTests
{
// Deliberately free of the words the assertions search for: a key id containing
// "unconstrained" would be counted as a summary and mask a regression in the cell.
private const string TagsOnlyKeyId = "key-tagged";
private const string UnconstrainedKeyId = "key-plain";
/// <summary>
/// A key whose only constraint is a dashboard tag names the tags in its constraints
/// cell. Before the fix this cell read <c>-</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_WhenKeyOnlyHasDashboardTags_NamesThemInTheConstraintsColumn()
{
string html = await RenderAsync(openCreateDialog: false);
Assert.Contains("dashboard_tags=[team-a, team-b]", html, StringComparison.Ordinal);
}
/// <summary>
/// The control for the assertion above: a key with genuinely no constraints must still
/// read <c>unconstrained</c>, and only that key may. Without this, a summary that
/// labelled every key would satisfy the positive case while erasing the distinction the
/// column exists to draw.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_WhenKeyHasNoConstraints_StillReadsUnconstrained()
{
string html = await RenderAsync(openCreateDialog: false);
Assert.Contains(UnconstrainedKeyId, html, StringComparison.Ordinal);
Assert.Contains("unconstrained", html, StringComparison.Ordinal);
// Exactly one row may claim it — the tags-only key is constrained, by IsEmpty's own reckoning.
Assert.Equal(1, CountOccurrences(html, "unconstrained"));
}
/// <summary>
/// The create form offers a dashboard-tags field, so a tagged key can be minted from the
/// dashboard rather than only from the <c>apikey</c> CLI.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKeysPage_CreateForm_OffersADashboardTagsField()
{
string html = await RenderAsync(openCreateDialog: true);
// The sibling constraint field is the control: it proves the form rendered at all, so a
// dialog that failed to open could not pass this by rendering nothing.
Assert.Contains("id=\"readSubtrees\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"dashboardTags\"", html, StringComparison.Ordinal);
Assert.Contains("Dashboard tags", html, StringComparison.Ordinal);
}
/// <summary>
/// The field is wired through to the request, not merely displayed: the CLI's comma
/// separation is honoured and each tag is trimmed.
/// </summary>
[Fact]
public void TryBuildCreateRequest_CarriesTheDashboardTagsField()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a, team-b";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Equal(["team-a", "team-b"], request.Constraints.DashboardTags);
// Nothing else may be inferred from a tags-only form: dashboard tags are a visibility
// grant, and turning one into a data-access constraint would be a silent policy change.
Assert.False(request.Constraints.IsEmpty);
Assert.False(request.Constraints.HasReadConstraints);
Assert.False(request.Constraints.HasWriteConstraints);
}
/// <summary>
/// Two spellings of one tag persist as one grant, matching how
/// <c>ApiKeyAdminCommandLineParser.ParseDashboardTags</c> collapses them and how the
/// enforcement site compares them.
/// </summary>
/// <remarks>
/// Enforcement would survive the duplicate — a session holds its tags in a
/// case-insensitive set — but the API Keys page's constraints column would render
/// <c>dashboard_tags=[team-a, TEAM-A]</c> and report one grant as two, which is the
/// reading an audit surface can least afford.
/// </remarks>
[Fact]
public void TryBuildCreateRequest_DeduplicatesDashboardTagsIgnoringCase()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a, TEAM-A, team-a";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
// First spelling typed wins, so what the operator wrote is what the audit surface shows.
Assert.Equal(["team-a"], request.Constraints.DashboardTags);
}
/// <summary>
/// A dedupe that spanned fields would be a bug of its own: the five glob lists are matched
/// literally, so near-duplicates there are not necessarily the same rule and must survive
/// verbatim.
/// </summary>
[Fact]
public void TryBuildCreateRequest_DoesNotDeduplicateTheOtherConstraintLists()
{
ApiKeysPage page = new();
page.CreateModel.ReadSubtrees = "Area1, AREA1";
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Equal(["Area1", "AREA1"], request.Constraints.ReadSubtrees);
}
/// <summary>
/// <c>Reset</c> clears the new field, so the next key minted in the same dialog does not
/// inherit the previous key's tag grant.
/// </summary>
[Fact]
public void Reset_ClearsDashboardTagsAlongsideTheOtherConstraintFields()
{
ApiKeysPage page = new();
page.CreateModel.DashboardTags = "team-a";
// A sibling field pins the assertion to Reset itself: were Reset a no-op, both would
// survive and the failure would name the method rather than the one line.
page.CreateModel.ReadSubtrees = "Area1";
page.CreateModel.KeyId = "leftover";
page.CreateModel.Reset();
Assert.Equal(string.Empty, page.CreateModel.DashboardTags);
Assert.Equal(string.Empty, page.CreateModel.ReadSubtrees);
Assert.Equal(string.Empty, page.CreateModel.KeyId);
}
/// <summary>An empty field leaves the key untagged rather than inventing a tag.</summary>
[Fact]
public void TryBuildCreateRequest_WhenDashboardTagsIsBlank_LeavesTheKeyUntagged()
{
ApiKeysPage page = new();
bool built = page.TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? error);
Assert.True(built, error);
Assert.NotNull(request);
Assert.Empty(request.Constraints.DashboardTags);
Assert.True(request.Constraints.IsEmpty);
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0;
int index = haystack.IndexOf(needle, StringComparison.Ordinal);
while (index >= 0)
{
count++;
index = haystack.IndexOf(needle, index + needle.Length, StringComparison.Ordinal);
}
return count;
}
private static async Task<string> RenderAsync(bool openCreateDialog)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
services.AddSingleton<IDashboardApiKeyManagementService>(new ManagingApiKeyService());
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());
await using ServiceProvider provider = services.BuildServiceProvider();
await using InstanceMountingHtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
ApiKeysPage page = renderer.CreateComponent<ApiKeysPage>();
if (openCreateDialog)
{
page.OpenCreateDialog();
}
HtmlRootComponent output = renderer.BeginRenderingComponent(page, ParameterView.Empty);
await output.QuiescenceTask;
return output.ToHtmlString();
});
}
// The create form lives behind a click, and static rendering has no way to dispatch one, so
// the dialog is opened on the instance before it is handed to the renderer. Reaching a
// pre-built instance needs Renderer.InstantiateComponent (which is what performs [Inject]
// property injection); the sealed HtmlRenderer used by the sibling tests exposes no such seam,
// so this subclasses the same static-rendering infrastructure HtmlRenderer itself wraps.
//
// BL0006 warns that RenderTree types are not for use outside the Blazor framework. Suppressed
// here and only here, exactly as SessionDetailsPageEventAclTests does: this is test-only
// scaffolding that never ships, and the cost of the warning coming true is a compile break in
// one test file on an SDK bump. Production code must keep honouring BL0006.
#pragma warning disable BL0006
private sealed class InstanceMountingHtmlRenderer(IServiceProvider services, ILoggerFactory loggerFactory)
: StaticHtmlRenderer(services, loggerFactory)
{
/// <summary>Instantiates a component with its <c>[Inject]</c> properties resolved.</summary>
/// <typeparam name="TComponent">Component type to create.</typeparam>
/// <returns>The component instance, not yet attached to the renderer.</returns>
public TComponent CreateComponent<TComponent>()
where TComponent : IComponent =>
(TComponent)InstantiateComponent(typeof(TComponent));
}
#pragma warning restore BL0006
private sealed class StubSnapshotService : IDashboardSnapshotService
{
/// <inheritdoc />
public DashboardSnapshot GetSnapshot() => new(
GeneratedAt: DateTimeOffset.UnixEpoch,
GatewayStartedAt: DateTimeOffset.UnixEpoch,
GatewayUptime: TimeSpan.Zero,
GatewayStatus: "Healthy",
GatewayVersion: "test",
Sessions: [],
Workers: [],
Metrics: [],
Faults: [],
ApiKeys:
[
new DashboardApiKeySummary(
KeyId: TagsOnlyKeyId,
DisplayName: "Tags only",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with { DashboardTags = ["team-a", "team-b"] },
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: null),
new DashboardApiKeySummary(
KeyId: UnconstrainedKeyId,
DisplayName: "Unconstrained",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.UnixEpoch,
LastUsedUtc: null,
RevokedUtc: null),
],
Configuration: null!,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
new IdleSnapshotFeed().WatchAsync(cancellationToken);
}
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
// second snapshot mid-render.
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
// Grants management so the create dialog is reachable; no test here exercises a mutation.
private sealed class ManagingApiKeyService : IDashboardApiKeyManagementService
{
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user) => true;
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
/// <inheritdoc />
public Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardApiKeyManagementResult.Fail("not exercised"));
}
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "admin-user"), new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role))));
}
}
@@ -0,0 +1,136 @@
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Renders <see cref="SettingsPage"/> and asserts the two SEC-25 dashboard-ACL options —
/// <c>Dashboard:GroupToTag</c> and <c>Dashboard:UntaggedSessionVisibility</c> — reach the page.
/// </summary>
/// <remarks>
/// <para>
/// The provider tests one folder over prove the projection carries the values; they would stay
/// green with no row on the page at all. An operator debugging why a Viewer sees no sessions
/// reads the settings page, not the record, so the markup is where the evidence has to be.
/// </para>
/// <para>
/// Tag NAMES are configuration, like the group → role mapping rendered beside them. No tag VALUE
/// is involved, so nothing here is subject to the value-redaction rule.
/// </para>
/// <para>
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, the idiom used by
/// <c>SecretsNavRenderTests</c> and <c>AlarmsPageTruncationBannerTests</c> — the assertion is
/// about markup the server emits, so no component-testing dependency is warranted.
/// </para>
/// </remarks>
public sealed class SettingsPageTagVisibilityRenderTests
{
private const string EmptyMarker = "(none configured)";
/// <summary>A configured mapping renders its group and every tag, plus the visibility policy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SettingsPage_WhenTagsConfigured_RendersGroupsTagsAndVisibility()
{
string html = await RenderAsync(new DashboardOptions
{
GroupToTag = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwOps"] = ["team-a", "team-b"],
},
UntaggedSessionVisibility = UntaggedSessionVisibility.AllViewers,
});
Assert.Contains("GwOps", html, StringComparison.Ordinal);
// Both tags, not just the first: a row that rendered only the head of the array would be
// actively misleading about which sessions a group can observe.
Assert.Contains("team-a", html, StringComparison.Ordinal);
Assert.Contains("team-b", html, StringComparison.Ordinal);
Assert.Contains(nameof(UntaggedSessionVisibility.AllViewers), html, StringComparison.Ordinal);
}
/// <summary>
/// The unconfigured deployment — the common one, and the one whose Viewers see nothing. The
/// row must still render, saying so, and the fail-closed default must be on the page. The
/// role-mapping heading is the control that keeps this from passing over a blank page.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SettingsPage_WhenNoTagsConfigured_RendersTheEmptyStateAndDefaultVisibility()
{
string html = await RenderAsync(new DashboardOptions());
Assert.Contains("Dashboard tag mapping", html, StringComparison.Ordinal);
Assert.Contains(EmptyMarker, html, StringComparison.Ordinal);
Assert.Contains(nameof(UntaggedSessionVisibility.AdminOnly), html, StringComparison.Ordinal);
Assert.Contains("Dashboard role mapping", html, StringComparison.Ordinal);
}
private static async Task<string> RenderAsync(DashboardOptions dashboard)
{
EffectiveGatewayConfiguration configuration =
new GatewayConfigurationProvider(Options.Create(new GatewayOptions { Dashboard = dashboard }))
.GetEffectiveConfiguration();
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService(configuration));
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<SettingsPage>();
return output.ToHtmlString();
});
}
// Seeds the page's first (and only) render. Everything except Configuration is inert here.
private sealed class StubSnapshotService(EffectiveGatewayConfiguration configuration)
: IDashboardSnapshotService
{
/// <inheritdoc />
public DashboardSnapshot GetSnapshot() => new(
GeneratedAt: DateTimeOffset.UnixEpoch,
GatewayStartedAt: DateTimeOffset.UnixEpoch,
GatewayUptime: TimeSpan.Zero,
GatewayStatus: "Healthy",
GatewayVersion: "test",
Sessions: [],
Workers: [],
Metrics: [],
Faults: [],
ApiKeys: [],
Configuration: configuration,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
new IdleSnapshotFeed().WatchAsync(cancellationToken);
}
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
// second snapshot mid-assertion.
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
}
@@ -0,0 +1,351 @@
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Verifies that <see cref="AlarmsHubPublisher"/> honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): the alarm value fields of
/// both value-bearing payload arms are stripped from the copy broadcast to
/// browser clients when the flag is off, present when it is on, and the source
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
/// subscribers and the alarms page — is never mutated.
/// </summary>
public sealed class AlarmsHubPublisherTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
/// <summary>Both value-bearing arms lose their values when the flag is off; metadata survives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
transition,
activeAlarm);
Assert.Equal(2, hubContext.Sent.Count);
AlarmFeedMessage sentTransition = hubContext.Sent[0];
Assert.Null(sentTransition.Transition.CurrentValue);
Assert.Null(sentTransition.Transition.LimitValue);
Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference);
Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference);
Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind);
Assert.Equal(800, sentTransition.Transition.Severity);
Assert.Equal("Process", sentTransition.Transition.Category);
AlarmFeedMessage sentActive = hubContext.Sent[1];
Assert.Null(sentActive.ActiveAlarm.CurrentValue);
Assert.Null(sentActive.ActiveAlarm.LimitValue);
Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState);
Assert.Equal(500, sentActive.ActiveAlarm.Severity);
}
/// <summary>
/// Redaction applies to a clone: the source message fans out to gRPC
/// <c>StreamAlarms</c> subscribers and the alarms page, so it must keep its
/// values.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
transition,
activeAlarm);
Assert.NotNull(transition.Transition.CurrentValue);
Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue);
Assert.NotNull(transition.Transition.LimitValue);
Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue);
Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue);
Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue);
Assert.NotSame(transition, hubContext.Sent[0]);
Assert.NotSame(activeAlarm, hubContext.Sent[1]);
}
/// <summary>Values pass through unredacted — and uncloned — when the flag is on.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues()
{
AlarmFeedMessage transition = BuildTransition();
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: true,
transition,
activeAlarm);
Assert.Same(transition, hubContext.Sent[0]);
Assert.Same(activeAlarm, hubContext.Sent[1]);
Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue);
Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue);
Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue);
Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue);
}
/// <summary>
/// A payload arm that carries no value is forwarded as-is — the same
/// instance, no clone. This is also the contract for arms added later: the
/// switch names only the value-bearing arms, so a new arm passes through.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched()
{
AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true };
AlarmFeedMessage providerStatus = new()
{
ProviderStatus = new AlarmProviderStatus
{
Mode = AlarmProviderMode.Subtag,
Degraded = true,
Reason = "alarmmgr unavailable",
},
};
AlarmFeedMessage snapshotStatus = new()
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = true },
};
CapturingHubContext hubContext = await RunPublisherAsync(
showTagValues: false,
snapshotComplete,
providerStatus,
snapshotStatus);
Assert.Same(snapshotComplete, hubContext.Sent[0]);
Assert.Same(providerStatus, hubContext.Sent[1]);
Assert.Same(snapshotStatus, hubContext.Sent[2]);
Assert.True(hubContext.Sent[2].SnapshotStatus.Truncated);
}
/// <summary>
/// Starts the publisher over a scripted feed, waits until every scripted
/// message has been broadcast, and stops it.
/// </summary>
/// <param name="showTagValues">The <c>Dashboard:ShowTagValues</c> value under test.</param>
/// <param name="messages">The messages the fake alarm feed yields, in order.</param>
/// <returns>The hub context holding everything the publisher sent.</returns>
private static async Task<CapturingHubContext> RunPublisherAsync(
bool showTagValues,
params AlarmFeedMessage[] messages)
{
ScriptedAlarmService alarmService = new(messages);
CapturingHubContext hubContext = new();
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
};
AlarmsHubPublisher publisher = new(
alarmService,
hubContext,
Options.Create(gatewayOptions),
NullLogger<AlarmsHubPublisher>.Instance);
using CancellationTokenSource cts = new();
await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length);
await cts.CancelAsync();
await publisher.StopAsync(CancellationToken.None);
return hubContext;
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
while (!predicate())
{
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
}
}
/// <summary>Builds a value-bearing <c>transition</c> feed message.</summary>
/// <returns>The message.</returns>
private static AlarmFeedMessage BuildTransition()
{
return new AlarmFeedMessage
{
Transition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 800,
Category = "Process",
Description = "Level high-high",
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 },
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 },
},
};
}
/// <summary>Builds a value-bearing <c>active_alarm</c> feed message.</summary>
/// <returns>The message.</returns>
private static AlarmFeedMessage BuildActiveAlarm()
{
return new AlarmFeedMessage
{
ActiveAlarm = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank02.Level.Lo",
SourceObjectReference = "Tank02",
AlarmTypeName = "AnalogLimitAlarm.Lo",
CurrentState = AlarmConditionState.Active,
Severity = 500,
Category = "Process",
Description = "Level low",
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 },
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 },
},
};
}
/// <summary>
/// Yields a scripted message list once and then stays open until cancelled,
/// so the publisher's reconnect loop never re-subscribes mid-test.
/// </summary>
/// <param name="messages">The messages to yield, in order.</param>
private sealed class ScriptedAlarmService(IReadOnlyList<AlarmFeedMessage> messages) : IGatewayAlarmService
{
/// <inheritdoc />
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
/// <inheritdoc />
public string? LastError => null;
/// <inheritdoc />
public int? WorkerProcessId => null;
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];
/// <inheritdoc />
public bool SnapshotTruncated => false;
/// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (AlarmFeedMessage message in messages)
{
yield return message;
}
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
/// <inheritdoc />
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
}
private sealed class CapturingHubContext : IHubContext<AlarmsHub>
{
private readonly CapturingHubClients _clients = new();
/// <summary>Gets the hub clients.</summary>
public IHubClients Clients => _clients;
/// <summary>Gets the group manager.</summary>
public IGroupManager Groups { get; } = new NoopGroupManager();
/// <summary>Gets every message the publisher broadcast, in order.</summary>
public IReadOnlyList<AlarmFeedMessage> Sent => _clients.GroupProxy.Sent;
}
private sealed class CapturingHubClients : IHubClients
{
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
public CapturingClientProxy GroupProxy { get; } = new();
public IClientProxy All => GroupProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
public IClientProxy Client(string connectionId) => GroupProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
public IClientProxy Group(string groupName) => GroupProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
public IClientProxy User(string userId) => GroupProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
}
private sealed class CapturingClientProxy : IClientProxy
{
private readonly List<AlarmFeedMessage> _sent = [];
/// <summary>Gets every alarm message sent through this proxy, in order.</summary>
public IReadOnlyList<AlarmFeedMessage> Sent
{
get
{
lock (_sent)
{
return [.. _sent];
}
}
}
/// <summary>Records the broadcast message and completes synchronously.</summary>
/// <param name="method">The SignalR method name.</param>
/// <param name="args">The method arguments.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A completed task.</returns>
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
if (args.Length > 0 && args[0] is AlarmFeedMessage message)
{
lock (_sent)
{
_sent.Add(message);
}
}
return Task.CompletedTask;
}
}
private sealed class NoopGroupManager : IGroupManager
{
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}
@@ -291,6 +291,7 @@ public sealed class DashboardAuthenticatorTests
return new DashboardAuthenticator(
ldapAuthService,
roleMapper,
Options.Create(options),
NullLogger<DashboardAuthenticator>.Instance);
}
@@ -0,0 +1,113 @@
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for <see cref="DashboardGroupTagMapping"/>, the LDAP-group → dashboard
/// visibility-tag grant. Group matching must follow the same rules as
/// <see cref="DashboardGroupRoleMapping"/> (full DN first, leading-RDN fallback,
/// case-insensitive), and the grant is the union across the user's groups.
/// </summary>
public sealed class DashboardGroupTagMappingTests
{
private static Dictionary<string, string[]> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b", "team-c"],
};
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedTag">The expected single granted tag, or null if no match.</param>
[Theory]
[InlineData("GwViewer", "team-a")]
[InlineData("gwviewer", "team-a")]
[InlineData("ou=GwViewer,ou=groups,dc=zb,dc=local", "team-a")]
[InlineData("OtherGroup", null)]
public void MapGroupsToTags_ResolvesByShortNameAndDistinguishedName(string ldapGroup, string? expectedTag)
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags([ldapGroup], StandardMapping());
if (expectedTag is null)
{
Assert.Empty(tags);
}
else
{
Assert.Equal(expectedTag, Assert.Single(tags));
}
}
/// <summary>Verifies the grant is the union of every matching group's tags.</summary>
[Fact]
public void MapGroupsToTags_MultipleGroups_UnionsTags()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer", "TeamBViewers"],
StandardMapping());
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
Assert.Equal<string>(["team-a", "team-b", "team-c"], ordered);
}
/// <summary>Verifies an unknown group contributes nothing to a grant its siblings still produce.</summary>
[Fact]
public void MapGroupsToTags_UnknownGroup_ContributesNothing()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer", "NotInTheMap"],
StandardMapping());
Assert.Equal("team-a", Assert.Single(tags));
}
/// <summary>Verifies the same tag granted by two groups, differing only in case, collapses to one entry.</summary>
[Fact]
public void MapGroupsToTags_DuplicateTagsAcrossGroups_DedupedCaseInsensitively()
{
Dictionary<string, string[]> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GroupOne"] = ["team-a"],
["GroupTwo"] = ["TEAM-A"],
};
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["GroupOne", "GroupTwo"], mapping);
Assert.Single(tags);
Assert.Contains("team-a", tags);
Assert.Contains("TEAM-A", tags);
}
/// <summary>Verifies an empty map yields an empty grant — no Viewer sees a tagged session.</summary>
[Fact]
public void MapGroupsToTags_EmptyMapping_ReturnsNoTags()
{
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(
["GwViewer"],
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase));
Assert.Empty(tags);
}
/// <summary>
/// The tag grant is independent of the role map: a group present only in
/// GroupToTag still grants its tags. Asserted here because the two maps are
/// deliberately uncoupled in validation as well.
/// </summary>
[Fact]
public void MapGroupsToTags_GroupAbsentFromRoleMap_StillGrantsTags()
{
Dictionary<string, string> groupToRole = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
};
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(["TeamBViewers"], groupToRole);
IReadOnlySet<string> tags = DashboardGroupTagMapping.MapGroupsToTags(["TeamBViewers"], StandardMapping());
string[] ordered = [.. tags.OrderBy(t => t, StringComparer.Ordinal)];
Assert.Empty(roles);
Assert.Equal<string>(["team-b", "team-c"], ordered);
}
}
@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
@@ -186,11 +188,100 @@ public sealed class DashboardLiveDataServiceTests
Assert.Equal(filler[0], worker.SubscribedTags[^1]);
}
private static DashboardLiveDataService CreateService(ISessionManager sessionManager)
/// <summary>
/// Verifies the <c>/browse</c> live-value seam honours
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): with the flag off — the
/// default — the value text the page renders is the redaction placeholder,
/// never the formatted tag value.
/// </summary>
[Fact]
public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata()
{
RecordingWorkerClient worker = new()
{
ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
};
await using FakeSessionManager sessionManager = new(worker);
await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false);
DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);
DashboardTagValue value = Assert.Single(result.Values);
Assert.Equal(DashboardTagValue.RedactedValueText, value.ValueText);
Assert.DoesNotContain("42.5", value.ValueText, StringComparison.Ordinal);
// Everything that is not the value still renders: the panel stays useful.
Assert.Equal("Tank_001.PV", value.TagAddress);
Assert.True(value.Ok);
Assert.Equal("Double", value.DataType);
Assert.Equal(192, value.Quality);
Assert.True(value.QualityGood);
Assert.Null(value.Error);
}
/// <summary>Verifies the formatted value is served when the flag is on.</summary>
[Fact]
public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue()
{
RecordingWorkerClient worker = new()
{
ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
};
await using FakeSessionManager sessionManager = new(worker);
await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: true);
DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);
DashboardTagValue value = Assert.Single(result.Values);
Assert.Equal("42.5", value.ValueText);
Assert.Equal("Double", value.DataType);
}
/// <summary>
/// Verifies redaction is scoped to reads that actually produced a value: a
/// failed read keeps the "-" placeholder rather than claiming a value was
/// withheld, and its diagnostic still reaches the panel. Redacting it would
/// contradict the error the same row displays.
/// </summary>
[Fact]
public async Task ReadAsync_WhenReadFailedAndShowTagValuesFalse_LeavesPlaceholderUnredacted()
{
RecordingWorkerClient worker = new()
{
ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
};
worker.FailReadFor.Add("Bad.PV");
await using FakeSessionManager sessionManager = new(worker);
await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false);
DashboardLiveReadResult result = await service.ReadAsync(
["Bad.PV", "Tank_001.PV"],
CancellationToken.None);
DashboardTagValue failed = result.Values.Single(value => value.TagAddress == "Bad.PV");
Assert.False(failed.Ok);
Assert.Equal("-", failed.ValueText);
Assert.Equal("Simulated read failure.", failed.Error);
Assert.False(failed.QualityGood);
// The successful row in the same read is still redacted.
DashboardTagValue succeeded = result.Values.Single(value => value.TagAddress == "Tank_001.PV");
Assert.Equal(DashboardTagValue.RedactedValueText, succeeded.ValueText);
}
private static DashboardLiveDataService CreateService(
ISessionManager sessionManager,
bool showTagValues = false)
{
GatewayOptions gatewayOptions = new()
{
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
};
return new DashboardLiveDataService(
sessionManager,
new FakeGatewayAlarmService(),
Options.Create(gatewayOptions),
NullLogger<DashboardLiveDataService>.Instance);
}
@@ -261,10 +352,6 @@ public sealed class DashboardLiveDataServiceTests
CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(string sessionId, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
@@ -326,6 +413,15 @@ public sealed class DashboardLiveDataServiceTests
/// <summary>Gets or sets a value indicating whether unsubscribe commands throw.</summary>
public bool FailUnsubscribe { get; set; }
/// <summary>
/// Gets or sets the value every bulk read returns. Null (the default) leaves
/// the read results value-less, which is all the advise-set tests need.
/// </summary>
public MxValue? ReadValue { get; set; }
/// <summary>Gets the tag addresses whose bulk read comes back unsuccessful.</summary>
public HashSet<string> FailReadFor { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the item handle bound for a previously subscribed tag.</summary>
/// <param name="tagAddress">Tag address to look up.</param>
/// <returns>The bound item handle.</returns>
@@ -438,14 +534,35 @@ public sealed class DashboardLiveDataServiceTests
BulkReadReply readReply = new();
foreach (string tagAddress in tagAddresses)
{
readReply.Results.Add(new BulkReadResult
if (FailReadFor.Contains(tagAddress))
{
readReply.Results.Add(new BulkReadResult
{
ServerHandle = RegisteredServerHandle,
TagAddress = tagAddress,
ItemHandle = _itemHandles.TryGetValue(tagAddress, out int failedHandle) ? failedHandle : 0,
WasSuccessful = false,
Quality = 0,
ErrorMessage = "Simulated read failure.",
});
continue;
}
BulkReadResult readResult = new()
{
ServerHandle = RegisteredServerHandle,
TagAddress = tagAddress,
ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0,
WasSuccessful = true,
Quality = 192,
});
};
if (ReadValue is not null)
{
readResult.Value = ReadValue.Clone();
}
readReply.Results.Add(readResult);
}
return readReply;
@@ -0,0 +1,280 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
/// consult (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// Every branch is asserted in its denying direction as well as its allowing one, because the
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
/// cannot distinguish a working gate from no gate at all.
/// </remarks>
public sealed class DashboardSessionAclTests
{
private const string TaggedSessionId = "session-tagged";
private const string UntaggedSessionId = "session-untagged";
/// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
[Fact]
public void CanViewSession_Administrator_BypassesTagCheck()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
}
/// <summary>
/// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
/// roles and no tags) working exactly as before this change.
/// </summary>
[Fact]
public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
TaggedSessionId));
}
/// <summary>
/// The decision table's order is load-bearing at exactly one corner: an Administrator naming
/// a session id the registry does not have is ALLOWED, because the admin bypass is checked
/// before the lookup. Pinned deliberately — reordering the two checks (a plausible "look the
/// session up first, it reads better" refactor) would flip this to a denial and quietly change
/// what an Administrator's hub join does for a session that closed a moment ago.
/// </summary>
[Fact]
public void CanViewSession_AdministratorAndUnknownSession_Allowed()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), "session-does-not-exist"));
}
/// <summary>
/// An unknown session id is denied for a non-Admin even when they hold every configured tag:
/// no subscription is created for a session the registry does not have. The Administrator
/// counterpart above is the deliberate exception.
/// </summary>
[Fact]
public void CanViewSession_UnknownSession_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
"session-does-not-exist"));
}
/// <summary>A blank session id is denied without consulting anything.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void CanViewSession_BlankSessionId_Denied(string sessionId)
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
}
/// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
[Fact]
public void CanViewSession_NullPrincipal_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(null, UntaggedSessionId));
}
/// <summary>
/// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
/// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
/// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
/// <param name="visibility">The configured untagged-session visibility.</param>
/// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
[Theory]
[InlineData(UntaggedSessionVisibility.AdminOnly, false)]
[InlineData(UntaggedSessionVisibility.AllViewers, true)]
public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
UntaggedSessionVisibility visibility,
bool expected)
{
DashboardSessionAcl acl = CreateAcl(visibility);
Assert.Equal(
expected,
acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
}
/// <summary>
/// A Viewer whose grant intersects the session's tags is allowed; the comparison is
/// ordinal-ignore-case, matching the session's tag set and the config map.
/// </summary>
/// <param name="grantedTag">The single tag the Viewer holds.</param>
[Theory]
[InlineData("team-a")]
[InlineData("TEAM-A")]
public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
TaggedSessionId));
}
/// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
[Fact]
public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
TaggedSessionId));
}
/// <summary>
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
[Fact]
public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
{
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
}
/// <summary>
/// An unauthenticated principal that nonetheless carries an Administrator role claim does not
/// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
/// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
/// </summary>
[Fact]
public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
{
// No authentication type => IsAuthenticated is false.
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: null,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
}
private static DashboardSessionAcl CreateAcl(
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
};
return new DashboardSessionAcl(
new TwoSessionManager(
CreateSession(TaggedSessionId, ["team-a"]),
CreateSession(UntaggedSessionId, tags: null)),
Options.Create(options));
}
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
{
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange((tags ?? []).Select(tag => new Claim(
DashboardAuthenticationDefaults.DashboardTagClaimType,
tag)));
return new ClaimsPrincipal(new ClaimsIdentity(
claims,
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
}
private static GatewaySession CreateSession(string sessionId, string[]? tags)
{
return new GatewaySession(
sessionId: sessionId,
backendName: "backend",
pipeName: $"pipe-{sessionId}",
nonce: "nonce",
clientIdentity: "client",
ownerKeyId: "key-1",
clientSessionName: "client-session",
clientCorrelationId: "correlation",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UnixEpoch,
ownerDashboardTags: tags);
}
/// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => Task.FromResult(tagged);
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessionId switch
{
TaggedSessionId => tagged,
UntaggedSessionId => untagged,
_ => null,
};
return session is not null;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -370,14 +370,6 @@ public sealed class DashboardSessionAdminServiceTests
throw new NotSupportedException();
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -0,0 +1,175 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// The denial assertions are the load-bearing ones — before the gate existed every caller was
/// joined, so "an allowed caller is joined" is indistinguishable from no gate. They assert the
/// absence of BOTH effects of a join: the SignalR group membership and the viewer registration
/// that turns the broadcaster's mirror on for the session. Leaving either behind would keep the
/// event clone running for a caller who may not observe it.
/// </remarks>
public sealed class EventsHubTests
{
private const string SessionId = "session-1";
private const string ConnectionId = "connection-1";
private static readonly ClaimsPrincipal TestPrincipal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "viewer-user")],
authenticationType: "test"));
/// <summary>An allowed caller joins the group and registers as a viewer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_WhenAclAllows_JoinsGroupAndRegistersViewer()
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: true);
await hub.SubscribeSession(SessionId);
Assert.Equal([(ConnectionId, EventsHub.GroupName(SessionId))], groups.Added);
Assert.True(registry.HasViewers(SessionId));
}
/// <summary>
/// A denied caller gets a <see cref="HubException"/>, is not joined, and is not registered.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_WhenAclDenies_ThrowsAndDoesNotJoin()
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: false);
HubException error = await Assert.ThrowsAsync<HubException>(() => hub.SubscribeSession(SessionId));
Assert.Equal("Not authorized for this session.", error.Message);
Assert.Empty(groups.Added);
Assert.False(registry.HasViewers(SessionId));
}
/// <summary>
/// A blank session id is still a no-op rather than a denial, so a client that sends one is not
/// told it lacks authorization for a session it never named.
/// </summary>
/// <param name="sessionId">The blank session id supplied by the caller.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task SubscribeSession_BlankSessionId_IsNoOp(string sessionId)
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: false);
await hub.SubscribeSession(sessionId);
Assert.Empty(groups.Added);
}
/// <summary>The ACL is asked about the session the caller named, with the caller's own principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_AsksAclAboutTheRequestedSession()
{
StubSessionAcl acl = new(allow: true);
EventsHub hub = new(new EventsHubViewerRegistry(), acl)
{
Groups = new RecordingGroupManager(),
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
};
await hub.SubscribeSession(SessionId);
Assert.Equal(SessionId, acl.LastSessionId);
Assert.Same(TestPrincipal, acl.LastPrincipal);
}
private static EventsHub CreateHub(
EventsHubViewerRegistry registry,
RecordingGroupManager groups,
bool allow)
{
return new EventsHub(registry, new StubSessionAcl(allow))
{
Groups = groups,
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
};
}
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
{
/// <summary>Gets the principal passed to the most recent call.</summary>
public ClaimsPrincipal? LastPrincipal { get; private set; }
/// <summary>Gets the session id passed to the most recent call.</summary>
public string? LastSessionId { get; private set; }
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
{
LastPrincipal = principal;
LastSessionId = sessionId;
return allow;
}
}
private sealed class RecordingGroupManager : IGroupManager
{
/// <summary>Gets the (connection id, group name) pairs added, in order.</summary>
public List<(string ConnectionId, string GroupName)> Added { get; } = [];
/// <inheritdoc />
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveFromGroupAsync(
string connectionId,
string groupName,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext
{
/// <inheritdoc />
public override string ConnectionId { get; } = connectionId;
/// <inheritdoc />
public override string? UserIdentifier => User?.Identity?.Name;
/// <inheritdoc />
public override ClaimsPrincipal? User { get; } = user;
/// <inheritdoc />
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
/// <inheritdoc />
public override IFeatureCollection Features { get; } = new FeatureCollection();
/// <inheritdoc />
public override CancellationToken ConnectionAborted => CancellationToken.None;
/// <inheritdoc />
public override void Abort()
{
// Nothing to abort in a unit-constructed context.
}
}
}
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
@@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
// Issue from a principal with NO Name claim and NO NameIdentifier
// claim. The Issue method's payload will then carry
@@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
ClaimsIdentity identity = new(
[
@@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
ClaimsIdentity identity = new(
[
@@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_NullToken_ReturnsNull()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
Assert.Null(service.Validate(null));
}
@@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_EmptyToken_ReturnsNull()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
Assert.Null(service.Validate(string.Empty));
}
@@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_GarbageToken_ReturnsNull()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
}
@@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Name, "bob"),
@@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests
[Fact]
public void Validate_ExpiredToken_ReturnsNull()
{
HubTokenService service = new(new EphemeralDataProtectionProvider());
HubTokenService service = CreateService();
ClaimsIdentity identity = new(
[new Claim(ClaimTypes.Name, "carol")],
authenticationType: "test");
@@ -174,4 +176,119 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(expiredToken));
}
/// <summary>
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
/// <c>IDashboardSessionAcl</c> reads on the hub path.
/// </summary>
[Fact]
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b"],
});
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Name, "dana"),
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
Assert.NotNull(result);
Assert.Equal(
["team-a", "team-b"],
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
.Select(c => c.Value)
.Order(StringComparer.Ordinal));
}
/// <summary>
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
/// is also the shape of every token minted before the tag field existed (the payload field
/// deserializes to null), so the fail-closed direction is covered for both.
/// </summary>
[Fact]
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["SomeOtherGroup"] = ["team-a"],
});
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Name, "erin"),
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
Assert.NotNull(result);
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
}
/// <summary>
/// A token minted before the payload carried tags at all still validates, and yields an empty
/// grant rather than throwing or rejecting. Distinct from the empty-grant test above, which
/// exercises a <c>Tags</c> key that is present and empty: this one protects a hand-built
/// payload with the key genuinely ABSENT, which is the shape every in-flight token has across
/// the deploy that introduces the field. Deserialization leaves the field null, and the
/// null-coalesce in <c>Validate</c> is the only thing standing between that and a crash on
/// the hub's authentication path.
/// </summary>
[Fact]
public void Validate_TokenMintedBeforeTagsFieldExisted_YieldsEmptyGrant()
{
EphemeralDataProtectionProvider dataProtection = new();
HubTokenService service = CreateService(dataProtection: dataProtection);
// The pre-field payload shape, verbatim: no "Tags" key anywhere.
const string LegacyPayload = """{"Name":"frank","NameIdentifier":"frank-id","Roles":["Viewer"]}""";
string legacyToken = dataProtection
.CreateProtector(HubTokenService.ProtectorPurpose)
.ToTimeLimitedDataProtector()
.Protect(LegacyPayload, HubTokenService.TokenLifetime);
ClaimsPrincipal? result = service.Validate(legacyToken);
Assert.NotNull(result);
Assert.Equal("frank", result.Identity?.Name);
Assert.True(result.IsInRole(DashboardRoles.Viewer));
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
}
private static HubTokenService CreateService(
Dictionary<string, string[]>? groupToTag = null,
IDataProtectionProvider? dataProtection = null)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
},
};
return new HubTokenService(
dataProtection ?? new EphemeralDataProtectionProvider(),
Options.Create(options));
}
}
@@ -0,0 +1,396 @@
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Threading.Channels;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// <para>
/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a
/// session's events — <see cref="IDashboardSessionEventSubscriber"/>, used by this page — so
/// gating the hub join alone would leave the page as an ungated path to the same feed. The
/// denial assertion here is the one that proves the second seam is closed: it asserts that
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/> is never called, not merely that the
/// panel renders differently.
/// </para>
/// <para>
/// Rendered through the framework's static <see cref="HtmlRenderer"/>, the same idiom
/// <c>SecretsNavRenderTests</c> uses — no component-testing package, because the assertions are
/// about the emitted markup and the calls the lifecycle makes, not about interactivity.
/// </para>
/// </remarks>
public sealed class SessionDetailsPageEventAclTests
{
private const string SessionId = "session-1";
// Matched without the trailing possessive so the assertion does not depend on how the
// renderer escapes the apostrophe.
private const string DeniedMessage = "Not authorized for this session";
private const string WaitingMarker = "Waiting for events.";
/// <summary>A denied caller gets the message and no subscription is opened.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe()
{
RecordingEventSubscriber subscriber = new();
string html = await RenderAsync(subscriber, allow: false);
Assert.Contains(DeniedMessage, html, StringComparison.Ordinal);
Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal);
Assert.Empty(subscriber.SubscribedSessionIds);
}
/// <summary>
/// The control for the denial above: an allowed caller subscribes and sees the ordinary
/// waiting state. Without this, a page that failed to render its events panel at all would
/// satisfy the "no subscription" assertion and the suite would report a working gate over a
/// broken panel.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState()
{
RecordingEventSubscriber subscriber = new();
string html = await RenderAsync(subscriber, allow: true);
Assert.Equal([SessionId], subscriber.SubscribedSessionIds);
Assert.Contains(WaitingMarker, html, StringComparison.Ordinal);
Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal);
}
/// <summary>
/// The re-entrancy guard on <c>AttachEventsAsync</c>: a rapid A -> B navigation must leave
/// exactly one live subscription, not two.
/// </summary>
/// <remarks>
/// <para>
/// Gating the ACL made attach asynchronous — it awaits the authentication state — and that
/// await is a suspension point the synchronous version did not have. The interleaving this
/// test forces is the one that window admits: A's attach parks on the auth state, B's whole
/// parameter set runs to completion behind it, and only then does A resume. A now reads
/// <c>SessionId</c> as B's and, ungurarded, subscribes to B a SECOND time — overwriting the
/// fields holding B's first subscription, which is then unreachable: never disposed, its
/// <c>EventsHubViewerRegistry</c> entry never released (so the mirror keeps cloning events
/// for it), its pump never cancelled.
/// </para>
/// <para>
/// The assertion is deliberately about subscription COUNT and disposal rather than about the
/// ACL: the guard is a resource-lifecycle fix, and the second attach was never an
/// authorization bypass — B had already been cleared by the newer attach.
/// </para>
/// <para>
/// This case needs a renderer that can re-set parameters on the SAME component instance, which
/// the static <see cref="HtmlRenderer"/> used by the tests above cannot do — it renders a root
/// component once and exposes no parameter-update seam. Hence the minimal
/// <see cref="ParameterDrivingRenderer"/> below, which is the smallest thing that can express
/// a second <c>SetParametersAsync</c> while the first is still suspended.
/// </para>
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_WhenNavigationOvertakesASuspendedAttach_LeavesOneSubscription()
{
RecordingEventSubscriber subscriber = new();
// Call 1 is OnInitializedAsync's CanManage lookup; call 2 is the first session's attach,
// which is the one that must be caught mid-flight.
GatedAuthenticationStateProvider auth = new(gateOnCall: 2);
ServiceCollection services = BuildServices(subscriber, allow: true, authenticationStateProvider: auth);
await using ServiceProvider provider = services.BuildServiceProvider();
await using ParameterDrivingRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());
SessionDetailsPage page = await renderer.MountAsync<SessionDetailsPage>();
// Not awaited: it parks inside the first attach, which is the whole point.
Task first = renderer.SetParametersAsync(page, "session-a");
await auth.Entered.WaitAsync(TestTimeout);
// The overtaking navigation completes end to end while the first attach is suspended.
await renderer.SetParametersAsync(page, "session-b").WaitAsync(TestTimeout);
auth.Release();
await first.WaitAsync(TestTimeout);
Assert.Empty(renderer.Exceptions);
// Without the generation guard this is ["session-b", "session-b"] and the first of the two
// is stranded — the exact leak the guard exists to prevent.
Assert.Equal(["session-b"], subscriber.SubscribedSessionIds);
Assert.Empty(subscriber.UndisposedAfterReplacement);
}
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30);
private static ServiceCollection BuildServices(
RecordingEventSubscriber subscriber,
bool allow,
AuthenticationStateProvider? authenticationStateProvider = null)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
services.AddSingleton<IDashboardSessionAdminService>(new NonManagingSessionAdminService());
services.AddSingleton<IDashboardSessionEventSubscriber>(subscriber);
services.AddSingleton<IDashboardSessionAcl>(new StubSessionAcl(allow));
services.AddSingleton<AuthenticationStateProvider>(
authenticationStateProvider ?? new StubAuthenticationStateProvider());
return services;
}
private static async Task<string> RenderAsync(RecordingEventSubscriber subscriber, bool allow)
{
await using ServiceProvider provider = BuildServices(subscriber, allow).BuildServiceProvider();
await using HtmlRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<SessionDetailsPage>(
ParameterView.FromDictionary(new Dictionary<string, object?>
{
[nameof(SessionDetailsPage.SessionId)] = SessionId,
}));
return output.ToHtmlString();
});
}
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
{
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow;
}
private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber
{
private readonly List<IdleSubscription> _handedOut = [];
/// <summary>Gets the session ids <see cref="Subscribe"/> was called with, in order.</summary>
public List<string> SubscribedSessionIds { get; } = [];
/// <summary>
/// Gets the subscriptions that were superseded by a later one and never disposed — the
/// signature of a stranded subscription, whose viewer registration is never released. The
/// most recent subscription is excluded because the page legitimately still holds it.
/// </summary>
public IReadOnlyList<IdleSubscription> UndisposedAfterReplacement =>
[.. _handedOut.SkipLast(1).Where(subscription => !subscription.IsDisposed)];
/// <inheritdoc />
public IDashboardEventSubscription Subscribe(string sessionId)
{
SubscribedSessionIds.Add(sessionId);
IdleSubscription subscription = new();
_handedOut.Add(subscription);
return subscription;
}
// A subscription whose channel never yields and never completes, so the page's pump parks
// exactly as it would against a quiet session.
internal sealed class IdleSubscription : IDashboardEventSubscription
{
private readonly Channel<MxEvent> _channel = Channel.CreateUnbounded<MxEvent>();
/// <summary>Gets a value indicating whether the page released this subscription.</summary>
public bool IsDisposed { get; private set; }
/// <inheritdoc />
public ChannelReader<MxEvent> Reader => _channel.Reader;
/// <inheritdoc />
public void Dispose()
{
IsDisposed = true;
_channel.Writer.TryComplete();
}
}
}
// Gates one nominated call so a test can suspend an attach exactly where the ACL check made it
// asynchronous, and let a second parameter set overtake it.
private sealed class GatedAuthenticationStateProvider(int gateOnCall) : AuthenticationStateProvider
{
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _calls;
/// <summary>Completes once the gated call has been entered and is parked.</summary>
public Task Entered => _entered.Task;
/// <summary>Lets the parked call finish.</summary>
public void Release() => _release.TrySetResult();
/// <inheritdoc />
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (Interlocked.Increment(ref _calls) == gateOnCall)
{
_entered.TrySetResult();
await _release.Task.ConfigureAwait(false);
}
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role)));
}
}
// The smallest renderer that can drive a SECOND parameter set into an already-mounted
// component instance. HtmlRenderer renders a root component once and offers no such seam, so
// the interleaving under test is inexpressible with it; everything here is plumbing around
// Renderer's protected mount/parameter surface, with no behaviour of its own.
//
// BL0006 warns that Microsoft.AspNetCore.Components.RenderTree is not for use outside the
// Blazor framework because those types may change between releases. Suppressed here and only
// here: this is test-only scaffolding (the same thing component-testing packages do), it never
// ships, and the cost of the warning coming true is a compile break in one test file on an SDK
// bump — not a production defect. Production code must keep honouring BL0006.
#pragma warning disable BL0006
private sealed class ParameterDrivingRenderer(IServiceProvider services, ILoggerFactory loggerFactory)
: Renderer(services, loggerFactory)
{
/// <summary>Gets exceptions the renderer surfaced, so a test never passes over a swallowed fault.</summary>
public List<Exception> Exceptions { get; } = [];
/// <inheritdoc />
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
/// <summary>Instantiates the component with DI-injected properties and attaches it as a root.</summary>
/// <typeparam name="TComponent">Component type to mount.</typeparam>
/// <returns>The mounted component instance.</returns>
public Task<TComponent> MountAsync<TComponent>()
where TComponent : IComponent
{
return Dispatcher.InvokeAsync(() =>
{
TComponent component = (TComponent)InstantiateComponent(typeof(TComponent));
AssignRootComponentId(component);
return component;
});
}
/// <summary>Sets the session-id parameter on an already-mounted page.</summary>
/// <param name="component">The mounted page.</param>
/// <param name="sessionId">Session id to render.</param>
/// <returns>The task the component's parameter-set lifecycle returns.</returns>
public Task SetParametersAsync(IComponent component, string sessionId)
{
return Dispatcher.InvokeAsync(() => component.SetParametersAsync(
ParameterView.FromDictionary(new Dictionary<string, object?>
{
[nameof(SessionDetailsPage.SessionId)] = sessionId,
})));
}
/// <inheritdoc />
protected override void HandleException(Exception exception) => Exceptions.Add(exception);
/// <inheritdoc />
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
#pragma warning restore BL0006
private sealed class StubSnapshotService : IDashboardSnapshotService
{
/// <inheritdoc />
public DashboardSnapshot GetSnapshot() => new(
GeneratedAt: DateTimeOffset.UnixEpoch,
GatewayStartedAt: DateTimeOffset.UnixEpoch,
GatewayUptime: TimeSpan.Zero,
GatewayStatus: "Healthy",
GatewayVersion: "test",
Sessions:
[
new DashboardSessionSummary(
SessionId: SessionId,
BackendName: "backend",
State: SessionState.Ready,
ClientIdentity: "client",
ClientSessionName: "client-session",
ClientCorrelationId: "correlation",
OpenedAt: DateTimeOffset.UnixEpoch,
LastClientActivityAt: DateTimeOffset.UnixEpoch,
LeaseExpiresAt: null,
WorkerProcessId: null,
WorkerState: null,
LastWorkerHeartbeatAt: null,
EventsReceived: 0,
LastFault: null),
],
Workers: [],
Metrics: [],
Faults: [],
ApiKeys: [],
Configuration: null!,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
new IdleSnapshotFeed().WatchAsync(cancellationToken);
}
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
// second snapshot mid-assertion.
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService
{
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user) => false;
/// <inheritdoc />
public Task<DashboardSessionAdminResult> CloseSessionAsync(
ClaimsPrincipal user,
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
/// <inheritdoc />
public Task<DashboardSessionAdminResult> KillWorkerAsync(
ClaimsPrincipal user,
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
}
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role))));
}
}
@@ -761,14 +761,6 @@ public sealed class EventStreamServiceTests
return Task.FromResult(new WorkerCommandReply());
}
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
{
return _sessions[sessionId].ReadEventsAsync(cancellationToken);
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -935,19 +935,6 @@ public sealed class MxAccessGatewayServiceConstraintTests
return Task.FromResult(InvokeReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev;
}
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -50,6 +50,45 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
}
/// <summary>
/// Verifies OpenSession forwards the calling key's dashboard-visibility tags, so the
/// session's tags are derived from the owning API key rather than the wire request (SEC-25).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSession_WithTaggedKey_ForwardsOwnerDashboardTags()
{
GatewayRequestIdentityAccessor identityAccessor = new();
FakeSessionManager sessionManager = new();
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
ApiKeyIdentity identity = CreateIdentity() with
{
Constraints = ApiKeyConstraints.Empty with { DashboardTags = ["team-a"] },
};
using IDisposable identityScope = identityAccessor.Push(identity);
await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext());
Assert.Equal(["team-a"], sessionManager.LastOwnerDashboardTags);
}
/// <summary>
/// Verifies an unauthenticated OpenSession (no resolved key identity) opens an untagged
/// session — the fail-closed state for dashboard event visibility.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSession_WithoutIdentity_ForwardsNoDashboardTags()
{
FakeSessionManager sessionManager = new();
MxAccessGatewayService service = CreateService(sessionManager, new GatewayRequestIdentityAccessor());
await service.OpenSession(new OpenSessionRequest(), new TestServerCallContext());
Assert.Null(sessionManager.LastOwnerDashboardTags);
Assert.Null(sessionManager.LastOwnerKeyId);
}
/// <summary>
/// Verifies that Invoke maps a genuinely missing session to NotFound via the
/// service's own <c>ResolveSession</c> lookup. No <c>InvokeException</c> is
@@ -517,7 +556,10 @@ public sealed class MxAccessGatewayServiceTests
/// <summary>The last owner key id passed to OpenSessionAsync.</summary>
public string? LastOwnerKeyId { get; private set; }
/// <summary>The last session ID passed to ReadEventsAsync.</summary>
/// <summary>The last owner dashboard tags passed to OpenSessionAsync.</summary>
public IReadOnlyList<string>? LastOwnerDashboardTags { get; private set; }
/// <summary>The last session ID the event stream service was asked to stream.</summary>
public string? LastReadEventsSessionId { get; private set; }
/// <summary>The last worker command passed to InvokeAsync.</summary>
@@ -540,10 +582,10 @@ public sealed class MxAccessGatewayServiceTests
/// <summary>The number of times InvokeAsync was called.</summary>
public int InvokeCount { get; private set; }
/// <summary>The events to return from ReadEventsAsync.</summary>
/// <summary>The events the fake event stream service replays for this manager.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <summary>Records the session ID passed to ReadEventsAsync.</summary>
/// <summary>Records the session ID the event stream service was asked to stream.</summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RecordReadEventsSessionId(string sessionId)
{
@@ -564,6 +606,19 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
}
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
IReadOnlyList<string>? ownerDashboardTags,
CancellationToken cancellationToken)
{
LastOwnerDashboardTags = ownerDashboardTags;
return OpenSessionAsync(request, clientIdentity, ownerKeyId, cancellationToken);
}
/// <inheritdoc />
public bool TryGetSession(
string sessionId,
@@ -602,20 +657,6 @@ public sealed class MxAccessGatewayServiceTests
return Task.FromResult(InvokeReply);
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
LastReadEventsSessionId = sessionId;
foreach (WorkerEvent workerEvent in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return workerEvent;
}
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -347,11 +347,6 @@ public sealed class GatewaySessionDashboardMirrorTests
WorkerCommand command,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -109,6 +109,55 @@ public sealed class SessionManagerTests
Assert.Null(session.OwnerKeyId);
}
/// <summary>
/// Verifies a session inherits the owning API key's dashboard-visibility tags (SEC-25),
/// compared ordinal-ignore-case so a differently cased grant still matches.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithOwnerDashboardTags_CopiesTagsOntoSession()
{
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(),
clientIdentity: "MyKey Display",
ownerKeyId: "key-abc123",
ownerDashboardTags: ["team-a", "team-b"],
CancellationToken.None);
Assert.Equal(["team-a", "team-b"], session.Tags.OrderBy(tag => tag, StringComparer.Ordinal));
Assert.Contains("TEAM-A", session.Tags);
}
/// <summary>
/// Verifies a session opened by a key with no dashboard tags is untagged, which is the
/// fail-closed state for dashboard event visibility.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_WithoutOwnerDashboardTags_LeavesSessionUntagged()
{
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(),
clientIdentity: "MyKey Display",
ownerKeyId: "key-abc123",
ownerDashboardTags: null,
CancellationToken.None);
Assert.Empty(session.Tags);
GatewaySession tagless = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-1",
ownerKeyId: null,
CancellationToken.None);
Assert.Empty(tagless.Tags);
}
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]

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