diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index e315ce0..6b60257 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -199,12 +199,50 @@ 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 public snapshot stream + +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**: `IsTruncatedFetch` remains +`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe run for this work could +not verify whether `ALARM_RECORDS/@COUNT` reports the total active count or only +the records in the reply (`docs/AlarmProbeFindings.md`), and an exact-looking +signal derived from an unverified attribute is worse than an honest heuristic — +it would read as precise while being wrong in the one direction that matters. +Switching to `@COUNT` stays blocked on probe 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. + +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 diff --git a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md index c1e23d7..fc7d09b 100644 --- a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md +++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md @@ -295,3 +295,57 @@ 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. diff --git a/gateway.md b/gateway.md index 1105639..8fc8278 100644 --- a/gateway.md +++ b/gateway.md @@ -240,6 +240,24 @@ 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 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 make the test exact only if +it reported the total active count rather than the records in the reply, which +a live probe could not discriminate (see `docs/AlarmProbeFindings.md`). + 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 diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index bbaa9ff..feadce8 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -285,248 +285,249 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { "ASgJEhcKD214YWNjZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNp", "ZBgEIAEoCSJAChBEcmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIc", "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFs", - "YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUiXAodUXVl", + "YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUieAodUXVl", "cnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygL", - "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90Io8I", - "CgdNeEV2ZW50EjIKBmZhbWlseRgBIAEoDjIiLm14YWNjZXNzX2dhdGV3YXku", - "djEuTXhFdmVudEZhbWlseRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZl", - "cl9oYW5kbGUYAyABKAUSEwoLaXRlbV9oYW5kbGUYBCABKAUSKwoFdmFsdWUY", - "BSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0", - "eRgGIAEoBRI0ChBzb3VyY2VfdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnBy", - "b3RvYnVmLlRpbWVzdGFtcBI0CghzdGF0dXNlcxgIIAMoCzIiLm14YWNjZXNz", - "X2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eRIXCg93b3JrZXJfc2VxdWVuY2UY", - "CSABKAQSNAoQd29ya2VyX3RpbWVzdGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90", - "b2J1Zi5UaW1lc3RhbXASPQoZZ2F0ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgL", - "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgM", - "IAEoBUgBiAEBEhIKCnJhd19zdGF0dXMYDSABKAkSNwoKcmVwbGF5X2dhcBgO", - "IAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuUmVwbGF5R2FwSAKIAQESQAoO", - "b25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLk9u", - "RGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29tcGxldGUYFSABKAsy", - "KS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21wbGV0ZUV2ZW50SAAS", - "SQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoXb25fYnVmZmVyZWRf", - "ZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRld2F5LnYxLk9uQnVm", - "ZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJKChNvbl9hbGFybV90cmFuc2l0aW9u", - "GBggASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtVHJhbnNpdGlv", - "bkV2ZW50SAASXgoeb25fYWxhcm1fcHJvdmlkZXJfbW9kZV9jaGFuZ2VkGBkg", - "ASgLMjQubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtUHJvdmlkZXJNb2Rl", - "Q2hhbmdlZEV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdEINCgtfcmVwbGF5", - "X2dhcCJQCglSZXBsYXlHYXASIAoYcmVxdWVzdGVkX2FmdGVyX3NlcXVlbmNl", - "GAEgASgEEiEKGW9sZGVzdF9hdmFpbGFibGVfc2VxdWVuY2UYAiABKAQiEwoR", - "T25EYXRhQ2hhbmdlRXZlbnQiFgoUT25Xcml0ZUNvbXBsZXRlRXZlbnQiGAoW", - "T3BlcmF0aW9uQ29tcGxldGVFdmVudCLUAQoZT25CdWZmZXJlZERhdGFDaGFu", - "Z2VFdmVudBIyCglkYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5", - "LnYxLk14RGF0YVR5cGUSNAoOcXVhbGl0eV92YWx1ZXMYAiABKAsyHC5teGFj", - "Y2Vzc19nYXRld2F5LnYxLk14QXJyYXkSNgoQdGltZXN0YW1wX3ZhbHVlcxgD", - "IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRIVCg1yYXdfZGF0", - "YV90eXBlGAQgASgFItAEChZPbkFsYXJtVHJhbnNpdGlvbkV2ZW50EhwKFGFs", - "YXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8KF3NvdXJjZV9vYmplY3RfcmVm", - "ZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVfbmFtZRgDIAEoCRJBCg90cmFu", - "c2l0aW9uX2tpbmQYBCABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt", - "VHJhbnNpdGlvbktpbmQSEAoIc2V2ZXJpdHkYBSABKAUSPAoYb3JpZ2luYWxf", - "cmFpc2VfdGltZXN0YW1wGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", - "dGFtcBI4ChR0cmFuc2l0aW9uX3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5w", - "cm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3JfdXNlchgIIAEoCRIYChBv", - "cGVyYXRvcl9jb21tZW50GAkgASgJEhAKCGNhdGVnb3J5GAogASgJEhMKC2Rl", - "c2NyaXB0aW9uGAsgASgJEjMKDWN1cnJlbnRfdmFsdWUYDCABKAsyHC5teGFj", - "Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUSMQoLbGltaXRfdmFsdWUYDSABKAsy", - "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSEAoIZGVncmFkZWQYDiAB", - "KAgSPwoPc291cmNlX3Byb3ZpZGVyGA8gASgOMiYubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5BbGFybVByb3ZpZGVyTW9kZSKgAQofT25BbGFybVByb3ZpZGVyTW9k", - "ZUNoYW5nZWRFdmVudBI0CgRtb2RlGAEgASgOMiYubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5BbGFybVByb3ZpZGVyTW9kZRIOCgZyZWFzb24YAiABKAkSDwoHaHJl", - "c3VsdBgDIAEoBRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l", - "c3RhbXAi0AQKE0FjdGl2ZUFsYXJtU25hcHNob3QSHAoUYWxhcm1fZnVsbF9y", - "ZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVjdF9yZWZlcmVuY2UYAiAB", - "KAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEhAKCHNldmVyaXR5GAQgASgF", - "EjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5w", - "cm90b2J1Zi5UaW1lc3RhbXASPwoNY3VycmVudF9zdGF0ZRgGIAEoDjIoLm14", - "YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Db25kaXRpb25TdGF0ZRIQCghjYXRl", - "Z29yeRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI9ChlsYXN0X3RyYW5z", - "aXRpb25fdGltZXN0YW1wGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", - "dGFtcBIVCg1vcGVyYXRvcl91c2VyGAogASgJEhgKEG9wZXJhdG9yX2NvbW1l", - "bnQYCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIcLm14YWNjZXNzX2dh", - "dGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgNIAEoCzIcLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQCghkZWdyYWRlZBgOIAEoCBI/Cg9z", - "b3VyY2VfcHJvdmlkZXIYDyABKA4yJi5teGFjY2Vzc19nYXRld2F5LnYxLkFs", - "YXJtUHJvdmlkZXJNb2RlIpABChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBId", - "ChVjbGllbnRfY29ycmVsYXRpb25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9y", - "ZWZlcmVuY2UYAyABKAkSDwoHY29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91", - "c2VyGAUgASgJSgQIARACUgpzZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFs", - "YXJtUmVwbHkSFgoOY29ycmVsYXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xf", - "c3RhdHVzGAMgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0", - "YXR1cxIUCgdocmVzdWx0GAQgASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIu", - "bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0", - "aWNfbWVzc2FnZRgGIAEoCUIKCghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9p", - "ZCJRChNTdHJlYW1BbGFybXNSZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlv", - "bl9pZBgBIAEoCRIbChNhbGFybV9maWx0ZXJfcHJlZml4GAIgASgJIoQCChBB", - "bGFybUZlZWRNZXNzYWdlEkAKDGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNj", - "ZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBz", - "aG90X2NvbXBsZXRlGAIgASgISAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14", - "YWNjZXNzX2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEkMK", - "D3Byb3ZpZGVyX3N0YXR1cxgEIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEu", - "QWxhcm1Qcm92aWRlclN0YXR1c0gAQgkKB3BheWxvYWQimAEKE0FsYXJtUHJv", - "dmlkZXJTdGF0dXMSNAoEbW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXku", - "djEuQWxhcm1Qcm92aWRlck1vZGUSEAoIZGVncmFkZWQYAiABKAgSDgoGcmVh", - "c29uGAMgASgJEikKBXNpbmNlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp", - "bWVzdGFtcCLrAQoNTXhTdGF0dXNQcm94eRIPCgdzdWNjZXNzGAEgASgFEjcK", - "CGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1", - "c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMgASgOMiMubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZkZXRhaWwYBCABKAUSFAoMcmF3", - "X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRlY3RlZF9ieRgGIAEoBRIXCg9k", - "aWFnbm9zdGljX3RleHQYByABKAki6QMKB014VmFsdWUSMgoJZGF0YV90eXBl", - "GAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZh", - "cmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxsGAMgASgIEhYKDnJhd19kaWFn", - "bm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5cGUYBSABKAUSFAoKYm9vbF92", - "YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVlGAsgASgFSAASFQoLaW50NjRf", - "dmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1ZRgNIAEoAkgAEhYKDGRvdWJs", - "ZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192YWx1ZRgPIAEoCUgAEjUKD3Rp", - "bWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh", - "bXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIcLm14YWNjZXNzX2dhdGV3YXku", - "djEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgSIAEoDEgAEkAKEnNwYXJzZV9h", - "cnJheV92YWx1ZRgTIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTcGFy", - "c2VBcnJheUgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRh", - "X3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUS", - "FAoMdmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoO", - "cmF3X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBl", - "GAUgASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFj", - "Y2Vzc19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMY", - "DCABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3Cgxm", - "bG9hdF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0", - "QXJyYXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsy", - "IC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0", - "YW1wX3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0", - "YW1wQXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0", - "ZXdheS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyKZAQoNTXhTcGFyc2VBcnJh", - "eRI6ChFlbGVtZW50X2RhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3", - "YXkudjEuTXhEYXRhVHlwZRIUCgx0b3RhbF9sZW5ndGgYAiABKA0SNgoIZWxl", - "bWVudHMYAyADKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlRWxl", - "bWVudCJNCg9NeFNwYXJzZUVsZW1lbnQSDQoFaW5kZXgYASABKA0SKwoFdmFs", - "dWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiGwoJQm9v", - "bEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVl", - "cxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9h", - "dEFycmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1", - "ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRp", - "bWVzdGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1", - "Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlBy", - "b3RvY29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5", - "LnYxLlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1N", - "eENvbW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAA", - "EhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRf", - "S0lORF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVN", - "EAMSHQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1B", - "TkRfS0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJ", - "U0UQBhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09N", - "TUFORF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRf", - "S0lORF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9T", - "RVRfQlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9L", - "SU5EX1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZ", - "ChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRf", - "V1JJVEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIi", - "Ch5NWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01N", - "QU5EX0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJ", - "TkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRf", - "QUREX0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRF", - "TV9CVUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQ", - "FRInCiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIK", - "Hk1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1B", - "TkRfS0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5E", - "X1NVQlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJT", - "Q1JJQkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdF", - "X0FMQVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFS", - "TVMQHBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllf", - "TkFNRRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhf", - "Q09NTUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5E", - "X1dSSVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklU", - "RV9TRUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVM", - "SxAiEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9L", - "SU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dF", - "VF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVO", - "VFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgqegoR", - "QWxhcm1Qcm92aWRlck1vZGUSIwofQUxBUk1fUFJPVklERVJfTU9ERV9VTlNQ", - "RUNJRklFRBAAEiAKHEFMQVJNX1BST1ZJREVSX01PREVfQUxBUk1NR1IQARIe", - "ChpBTEFSTV9QUk9WSURFUl9NT0RFX1NVQlRBRxACKq0CCg1NeEV2ZW50RmFt", - "aWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAAEiIKHk1YX0VW", - "RU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VWRU5UX0ZBTUlM", - "WV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZBTUlMWV9PUEVS", - "QVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlfT05fQlVGRkVS", - "RURfREFUQV9DSEFOR0UQBBInCiNNWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1f", - "VFJBTlNJVElPThAFEjIKLk1YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9QUk9W", - "SURFUl9NT0RFX0NIQU5HRUQQBirKAQoTQWxhcm1UcmFuc2l0aW9uS2luZBIl", - "CiFBTEFSTV9UUkFOU0lUSU9OX0tJTkRfVU5TUEVDSUZJRUQQABIfChtBTEFS", - "TV9UUkFOU0lUSU9OX0tJTkRfUkFJU0UQARIlCiFBTEFSTV9UUkFOU0lUSU9O", - "X0tJTkRfQUNLTk9XTEVER0UQAhIfChtBTEFSTV9UUkFOU0lUSU9OX0tJTkRf", - "Q0xFQVIQAxIjCh9BTEFSTV9UUkFOU0lUSU9OX0tJTkRfUkVUUklHR0VSEAQq", - "qgEKE0FsYXJtQ29uZGl0aW9uU3RhdGUSJQohQUxBUk1fQ09ORElUSU9OX1NU", - "QVRFX1VOU1BFQ0lGSUVEEAASIAocQUxBUk1fQ09ORElUSU9OX1NUQVRFX0FD", - "VElWRRABEiYKIkFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkVfQUNLRUQQ", - "AhIiCh5BTEFSTV9DT05ESVRJT05fU1RBVEVfSU5BQ1RJVkUQAyqlAwoQTXhT", - "dGF0dXNDYXRlZ29yeRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJ", - "RUQQABIeChpNWF9TVEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NU", - "QVRVU19DQVRFR09SWV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5E", - "SU5HEAMSHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9T", - "VEFUVVNfQ0FURUdPUllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NU", - "QVRVU19DQVRFR09SWV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RB", - "VFVTX0NBVEVHT1JZX09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVT", - "X0NBVEVHT1JZX1NFQ1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVH", - "T1JZX1NPRlRXQVJFX0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09U", - "SEVSX0VSUk9SEAoqygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19T", - "T1VSQ0VfVU5TUEVDSUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05P", - "V04QARIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwof", - "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRV", - "U19TT1VSQ0VfUkVRVUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNF", - "X1JFU1BPTkRJTkdfTk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNU", - "SU5HX0FVVE9NQVRJT05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9S", - "RVNQT05ESU5HX0FVVE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUS", - "HAoYTVhfREFUQV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBF", - "X1VOS05PV04QARIYChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RB", - "VEFfVFlQRV9CT09MRUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIW", - "ChJNWF9EQVRBX1RZUEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxF", - "EAYSFwoTTVhfREFUQV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9U", - "SU1FEAgSHQoZTVhfREFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RB", - "VEFfVFlQRV9SRUZFUkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFU", - "VVNfVFlQRRALEhUKEU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9U", - "WVBFX1NFQ1VSSVRZX0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRB", - "X1RZUEVfREFUQV9RVUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVB", - "TElGSUVEX0VOVU0QDxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVD", - "VBAQEikKJU1YX0RBVEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQ", - "ERIbChdNWF9EQVRBX1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQ", - "RV9FTkQQEyqjAwoSUHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NU", - "QVRVU19DT0RFX1VOU1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NP", - "REVfT0sQARIoCiRQUk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVF", - "U1QQAhIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VO", - "RBADEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZ", - "EAQSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxF", - "EAUSIAocUFJPVE9DT0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RP", - "Q09MX1NUQVRVU19DT0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVT", - "X0NPREVfUFJPVE9DT0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVT", - "X0NPREVfTVhBQ0NFU1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZ", - "U0VTU0lPTl9TVEFURV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVf", - "Q1JFQVRJTkcQARIhCh1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhAC", - "EiIKHlNFU1NJT05fU1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJ", - "T05fU1RBVEVfSEFORFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJ", - "QUxJWklOR19XT1JLRVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoV", - "U0VTU0lPTl9TVEFURV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9T", - "RUQQCBIZChVTRVNTSU9OX1NUQVRFX0ZBVUxURUQQCTLDBQoPTXhBY2Nlc3NH", - "YXRld2F5El0KC09wZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5P", - "cGVuU2Vzc2lvblJlcXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5T", - "ZXNzaW9uUmVwbHkSYAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdh", - "eS52MS5DbG9zZVNlc3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52", - "MS5DbG9zZVNlc3Npb25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRl", - "d2F5LnYxLk14Q29tbWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYx", - "Lk14Q29tbWFuZFJlcGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dh", - "dGV3YXkudjEuU3RyZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3", - "YXkudjEuTXhFdmVudDABEmwKEEFja25vd2xlZGdlQWxhcm0SLC5teGFjY2Vz", - "c19nYXRld2F5LnYxLkFja25vd2xlZGdlQWxhcm1SZXF1ZXN0GioubXhhY2Nl", - "c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHkSYQoMU3RyZWFt", - "QWxhcm1zEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJlYW1BbGFybXNSZXF1", - "ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUZlZWRNZXNzYWdlMAES", - "bgoRUXVlcnlBY3RpdmVBbGFybXMSLS5teGFjY2Vzc19nYXRld2F5LnYxLlF1", - "ZXJ5QWN0aXZlQWxhcm1zUmVxdWVzdBooLm14YWNjZXNzX2dhdGV3YXkudjEu", - "QWN0aXZlQWxhcm1TbmFwc2hvdDABQiaqAiNaQi5NT00uV1cuTXhHYXRld2F5", - "LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z")); + "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90EhoK", + "EnNuYXBzaG90X3RydW5jYXRlZBgCIAEoCCKPCAoHTXhFdmVudBIyCgZmYW1p", + "bHkYASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkS", + "EgoKc2Vzc2lvbl9pZBgCIAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMK", + "C2l0ZW1faGFuZGxlGAQgASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5NeFZhbHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNl", + "X3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAS", + "NAoIc3RhdHVzZXMYCCADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3Rh", + "dHVzUHJveHkSFwoPd29ya2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90", + "aW1lc3RhbXAYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0K", + "GWdhdGV3YXlfcmVjZWl2ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJv", + "dG9idWYuVGltZXN0YW1wEhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdf", + "c3RhdHVzGA0gASgJEjcKCnJlcGxheV9nYXAYDiABKAsyHi5teGFjY2Vzc19n", + "YXRld2F5LnYxLlJlcGxheUdhcEgCiAEBEkAKDm9uX2RhdGFfY2hhbmdlGBQg", + "ASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkRhdGFDaGFuZ2VFdmVudEgA", + "EkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgLMikubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgAEkkKEm9wZXJhdGlvbl9jb21w", + "bGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlcmF0aW9uQ29t", + "cGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVkX2RhdGFfY2hhbmdlGBcgASgL", + "Mi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1ZmZlcmVkRGF0YUNoYW5nZUV2", + "ZW50SAASSgoTb25fYWxhcm1fdHJhbnNpdGlvbhgYIAEoCzIrLm14YWNjZXNz", + "X2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEl4KHm9uX2Fs", + "YXJtX3Byb3ZpZGVyX21vZGVfY2hhbmdlZBgZIAEoCzI0Lm14YWNjZXNzX2dh", + "dGV3YXkudjEuT25BbGFybVByb3ZpZGVyTW9kZUNoYW5nZWRFdmVudEgAQgYK", + "BGJvZHlCCgoIX2hyZXN1bHRCDQoLX3JlcGxheV9nYXAiUAoJUmVwbGF5R2Fw", + "EiAKGHJlcXVlc3RlZF9hZnRlcl9zZXF1ZW5jZRgBIAEoBBIhChlvbGRlc3Rf", + "YXZhaWxhYmxlX3NlcXVlbmNlGAIgASgEIhMKEU9uRGF0YUNoYW5nZUV2ZW50", + "IhYKFE9uV3JpdGVDb21wbGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRl", + "RXZlbnQi1AEKGU9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90", + "eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQK", + "DnF1YWxpdHlfdmFsdWVzGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N", + "eEFycmF5EjYKEHRpbWVzdGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19n", + "YXRld2F5LnYxLk14QXJyYXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLQBAoW", + "T25BbGFybVRyYW5zaXRpb25FdmVudBIcChRhbGFybV9mdWxsX3JlZmVyZW5j", + "ZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9h", + "bGFybV90eXBlX25hbWUYAyABKAkSQQoPdHJhbnNpdGlvbl9raW5kGAQgASgO", + "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVRyYW5zaXRpb25LaW5kEhAK", + "CHNldmVyaXR5GAUgASgFEjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgG", + "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoUdHJhbnNpdGlv", + "bl90aW1lc3RhbXAYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w", + "EhUKDW9wZXJhdG9yX3VzZXIYCCABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgJ", + "IAEoCRIQCghjYXRlZ29yeRgKIAEoCRITCgtkZXNjcmlwdGlvbhgLIAEoCRIz", + "Cg1jdXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N", + "eFZhbHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5NeFZhbHVlEhAKCGRlZ3JhZGVkGA4gASgIEj8KD3NvdXJjZV9wcm92", + "aWRlchgPIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl", + "ck1vZGUioAEKH09uQWxhcm1Qcm92aWRlck1vZGVDaGFuZ2VkRXZlbnQSNAoE", + "bW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl", + "ck1vZGUSDgoGcmVhc29uGAIgASgJEg8KB2hyZXN1bHQYAyABKAUSJgoCYXQY", + "BCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvEEChNBY3RpdmVB", + "bGFybVNuYXBzaG90EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8K", + "F3NvdXJjZV9vYmplY3RfcmVmZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVf", + "bmFtZRgDIAEoCRIQCghzZXZlcml0eRgEIAEoBRI8ChhvcmlnaW5hbF9yYWlz", + "ZV90aW1lc3RhbXAYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w", + "Ej8KDWN1cnJlbnRfc3RhdGUYBiABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYx", + "LkFsYXJtQ29uZGl0aW9uU3RhdGUSEAoIY2F0ZWdvcnkYByABKAkSEwoLZGVz", + "Y3JpcHRpb24YCCABKAkSPQoZbGFzdF90cmFuc2l0aW9uX3RpbWVzdGFtcBgJ", + "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3Jf", + "dXNlchgKIAEoCRIYChBvcGVyYXRvcl9jb21tZW50GAsgASgJEjMKDWN1cnJl", + "bnRfdmFsdWUYDCABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUS", + "MQoLbGltaXRfdmFsdWUYDSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14", + "VmFsdWUSEAoIZGVncmFkZWQYDiABKAgSPwoPc291cmNlX3Byb3ZpZGVyGA8g", + "ASgOMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVByb3ZpZGVyTW9kZRIf", + "Chdmcm9tX3RydW5jYXRlZF9zbmFwc2hvdBgQIAEoCCKQAQoXQWNrbm93bGVk", + "Z2VBbGFybVJlcXVlc3QSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAIgASgJ", + "EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAMgASgJEg8KB2NvbW1lbnQYBCAB", + "KAkSFQoNb3BlcmF0b3JfdXNlchgFIAEoCUoECAEQAlIKc2Vzc2lvbl9pZCLx", + "AQoVQWNrbm93bGVkZ2VBbGFybVJlcGx5EhYKDmNvcnJlbGF0aW9uX2lkGAIg", + "ASgJEjwKD3Byb3RvY29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3", + "YXkudjEuUHJvdG9jb2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIK", + "BnN0YXR1cxgFIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ", + "cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHRK", + "BAgBEAJSCnNlc3Npb25faWQiUQoTU3RyZWFtQWxhcm1zUmVxdWVzdBIdChVj", + "bGllbnRfY29ycmVsYXRpb25faWQYASABKAkSGwoTYWxhcm1fZmlsdGVyX3By", + "ZWZpeBgCIAEoCSKEAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh", + "cm0YASABKAsyKC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25h", + "cHNob3RIABIbChFzbmFwc2hvdF9jb21wbGV0ZRgCIAEoCEgAEkEKCnRyYW5z", + "aXRpb24YAyABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1UcmFu", + "c2l0aW9uRXZlbnRIABJDCg9wcm92aWRlcl9zdGF0dXMYBCABKAsyKC5teGFj", + "Y2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJTdGF0dXNIAEIJCgdwYXls", + "b2FkIpgBChNBbGFybVByb3ZpZGVyU3RhdHVzEjQKBG1vZGUYASABKA4yJi5t", + "eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEhAKCGRlZ3Jh", + "ZGVkGAIgASgIEg4KBnJlYXNvbhgDIAEoCRIpCgVzaW5jZRgEIAEoCzIaLmdv", + "b2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi6wEKDU14U3RhdHVzUHJveHkSDwoH", + "c3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dh", + "dGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEo", + "DjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0", + "YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0", + "ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIukDCgdNeFZh", + "bHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEu", + "TXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgD", + "IAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBl", + "GAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgL", + "IAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUY", + "DSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFs", + "dWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUu", + "cHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5t", + "eGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiAB", + "KAxIABJAChJzcGFyc2VfYXJyYXlfdmFsdWUYEyABKAsyIi5teGFjY2Vzc19n", + "YXRld2F5LnYxLk14U3BhcnNlQXJyYXlIAEIGCgRraW5kIv4ECgdNeEFycmF5", + "EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1l", + "bnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19l", + "bGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIe", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFs", + "dWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAAS", + "NwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5J", + "bnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEo", + "CzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJp", + "bmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdB", + "cnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19n", + "YXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEo", + "CzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMi", + "mQEKDU14U3BhcnNlQXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5cGUYASABKA4y", + "Hy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoMdG90YWxfbGVu", + "Z3RoGAIgASgNEjYKCGVsZW1lbnRzGAMgAygLMiQubXhhY2Nlc3NfZ2F0ZXdh", + "eS52MS5NeFNwYXJzZUVsZW1lbnQiTQoPTXhTcGFyc2VFbGVtZW50Eg0KBWlu", + "ZGV4GAEgASgNEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52", + "MS5NeFZhbHVlIhsKCUJvb2xBcnJheRIOCgZ2YWx1ZXMYASADKAgiHAoKSW50", + "MzJBcnJheRIOCgZ2YWx1ZXMYASADKAUiHAoKSW50NjRBcnJheRIOCgZ2YWx1", + "ZXMYASADKAMiHAoKRmxvYXRBcnJheRIOCgZ2YWx1ZXMYASADKAIiHQoLRG91", + "YmxlQXJyYXkSDgoGdmFsdWVzGAEgAygBIh0KC1N0cmluZ0FycmF5Eg4KBnZh", + "bHVlcxgBIAMoCSI8Cg5UaW1lc3RhbXBBcnJheRIqCgZ2YWx1ZXMYASADKAsy", + "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIhoKCFJhd0FycmF5Eg4KBnZh", + "bHVlcxgBIAMoDCJYCg5Qcm90b2NvbFN0YXR1cxI1CgRjb2RlGAEgASgOMicu", + "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1c0NvZGUSDwoHbWVz", + "c2FnZRgCIAEoCSqfCwoNTXhDb21tYW5kS2luZBIfChtNWF9DT01NQU5EX0tJ", + "TkRfVU5TUEVDSUZJRUQQABIcChhNWF9DT01NQU5EX0tJTkRfUkVHSVNURVIQ", + "ARIeChpNWF9DT01NQU5EX0tJTkRfVU5SRUdJU1RFUhACEhwKGE1YX0NPTU1B", + "TkRfS0lORF9BRERfSVRFTRADEh0KGU1YX0NPTU1BTkRfS0lORF9BRERfSVRF", + "TTIQBBIfChtNWF9DT01NQU5EX0tJTkRfUkVNT1ZFX0lURU0QBRIaChZNWF9D", + "T01NQU5EX0tJTkRfQURWSVNFEAYSHQoZTVhfQ09NTUFORF9LSU5EX1VOX0FE", + "VklTRRAHEiYKIk1YX0NPTU1BTkRfS0lORF9BRFZJU0VfU1VQRVJWSVNPUlkQ", + "CBIlCiFNWF9DT01NQU5EX0tJTkRfQUREX0JVRkZFUkVEX0lURU0QCRIwCixN", + "WF9DT01NQU5EX0tJTkRfU0VUX0JVRkZFUkVEX1VQREFURV9JTlRFUlZBTBAK", + "EhsKF01YX0NPTU1BTkRfS0lORF9TVVNQRU5EEAsSHAoYTVhfQ09NTUFORF9L", + "SU5EX0FDVElWQVRFEAwSGQoVTVhfQ09NTUFORF9LSU5EX1dSSVRFEA0SGgoW", + "TVhfQ09NTUFORF9LSU5EX1dSSVRFMhAOEiEKHU1YX0NPTU1BTkRfS0lORF9X", + "UklURV9TRUNVUkVEEA8SIgoeTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VS", + "RUQyEBASJQohTVhfQ09NTUFORF9LSU5EX0FVVEhFTlRJQ0FURV9VU0VSEBES", + "KAokTVhfQ09NTUFORF9LSU5EX0FSQ0hFU1RSQV9VU0VSX1RPX0lEEBISIQod", + "TVhfQ09NTUFORF9LSU5EX0FERF9JVEVNX0JVTEsQExIkCiBNWF9DT01NQU5E", + "X0tJTkRfQURWSVNFX0lURU1fQlVMSxAUEiQKIE1YX0NPTU1BTkRfS0lORF9S", + "RU1PVkVfSVRFTV9CVUxLEBUSJwojTVhfQ09NTUFORF9LSU5EX1VOX0FEVklT", + "RV9JVEVNX0JVTEsQFhIiCh5NWF9DT01NQU5EX0tJTkRfU1VCU0NSSUJFX0JV", + "TEsQFxIkCiBNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQlVMSxAYEiQK", + "IE1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQUxBUk1TEBkSJgoiTVhfQ09N", + "TUFORF9LSU5EX1VOU1VCU0NSSUJFX0FMQVJNUxAaEiUKIU1YX0NPTU1BTkRf", + "S0lORF9BQ0tOT1dMRURHRV9BTEFSTRAbEicKI01YX0NPTU1BTkRfS0lORF9R", + "VUVSWV9BQ1RJVkVfQUxBUk1TEBwSLQopTVhfQ09NTUFORF9LSU5EX0FDS05P", + "V0xFREdFX0FMQVJNX0JZX05BTUUQHRIeChpNWF9DT01NQU5EX0tJTkRfV1JJ", + "VEVfQlVMSxAeEh8KG01YX0NPTU1BTkRfS0lORF9XUklURTJfQlVMSxAfEiYK", + "Ik1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEX0JVTEsQIBInCiNNWF9D", + "T01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDJfQlVMSxAhEh0KGU1YX0NPTU1B", + "TkRfS0lORF9SRUFEX0JVTEsQIhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBk", + "EiUKIU1YX0NPTU1BTkRfS0lORF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01Y", + "X0NPTU1BTkRfS0lORF9HRVRfV09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5E", + "X0tJTkRfRFJBSU5fRVZFTlRTEGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRE", + "T1dOX1dPUktFUhBoKnoKEUFsYXJtUHJvdmlkZXJNb2RlEiMKH0FMQVJNX1BS", + "T1ZJREVSX01PREVfVU5TUEVDSUZJRUQQABIgChxBTEFSTV9QUk9WSURFUl9N", + "T0RFX0FMQVJNTUdSEAESHgoaQUxBUk1fUFJPVklERVJfTU9ERV9TVUJUQUcQ", + "AiqtAgoNTXhFdmVudEZhbWlseRIfChtNWF9FVkVOVF9GQU1JTFlfVU5TUEVD", + "SUZJRUQQABIiCh5NWF9FVkVOVF9GQU1JTFlfT05fREFUQV9DSEFOR0UQARIl", + "CiFNWF9FVkVOVF9GQU1JTFlfT05fV1JJVEVfQ09NUExFVEUQAhImCiJNWF9F", + "VkVOVF9GQU1JTFlfT1BFUkFUSU9OX0NPTVBMRVRFEAMSKwonTVhfRVZFTlRf", + "RkFNSUxZX09OX0JVRkZFUkVEX0RBVEFfQ0hBTkdFEAQSJwojTVhfRVZFTlRf", + "RkFNSUxZX09OX0FMQVJNX1RSQU5TSVRJT04QBRIyCi5NWF9FVkVOVF9GQU1J", + "TFlfT05fQUxBUk1fUFJPVklERVJfTU9ERV9DSEFOR0VEEAYqygEKE0FsYXJt", + "VHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BF", + "Q0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQoh", + "QUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1f", + "VFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9L", + "SU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFM", + "QVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NP", + "TkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RB", + "VEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lO", + "QUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NB", + "VEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VO", + "S05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFU", + "VVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9X", + "QVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05f", + "RVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9F", + "UlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJP", + "UhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUK", + "IU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NU", + "QVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJj", + "ZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RB", + "VFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFV", + "RVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19M", + "TVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwof", + "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRV", + "U19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1Y", + "X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAH", + "Kt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAA", + "EhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05P", + "X0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFf", + "VFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhf", + "REFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIV", + "ChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VE", + "X1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhN", + "WF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5V", + "TRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9F", + "TlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwob", + "TVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBF", + "X1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJ", + "T05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQ", + "EhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29k", + "ZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BS", + "T1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NP", + "REVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVf", + "U0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9T", + "RVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dP", + "UktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJ", + "TUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisK", + "J1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikK", + "JVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoM", + "U2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIa", + "ChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9T", + "VEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9S", + "X1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VT", + "U0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9T", + "VEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNF", + "U1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVE", + "EAkywwUKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNj", + "ZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3Nf", + "Z2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIo", + "Lm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14", + "YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tl", + "EiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhh", + "Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVu", + "dHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3Qa", + "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRn", + "ZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJt", + "UmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFy", + "bVJlcGx5EmEKDFN0cmVhbUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEu", + "U3RyZWFtQWxhcm1zUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxh", + "cm1GZWVkTWVzc2FnZTABEm4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0ubXhhY2Nl", + "c3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3QaKC5teGFj", + "Y2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUImqgIjWkIu", + "TU9NLldXLk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -602,7 +603,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots", "SnapshotTruncated" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "ReplayGap", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition", "OnAlarmProviderModeChanged" }, new[]{ "Body", "Hresult", "ReplayGap" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap.Parser, new[]{ "RequestedAfterSequence", "OldestAvailableSequence" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null), @@ -611,7 +612,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent.Parser, new[]{ "Mode", "Reason", "Hresult", "At" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", "FromTruncatedSnapshot" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), @@ -23224,6 +23225,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public QueryActiveAlarmsReplyPayload(QueryActiveAlarmsReplyPayload other) : this() { snapshots_ = other.snapshots_.Clone(); + snapshotTruncated_ = other.snapshotTruncated_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -23244,6 +23246,26 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { get { return snapshots_; } } + /// Field number for the "snapshot_truncated" field. + public const int SnapshotTruncatedFieldNumber = 2; + private bool snapshotTruncated_; + /// + /// 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. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool SnapshotTruncated { + get { return snapshotTruncated_; } + set { + snapshotTruncated_ = value; + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -23260,6 +23282,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { return true; } if(!snapshots_.Equals(other.snapshots_)) return false; + if (SnapshotTruncated != other.SnapshotTruncated) return false; return Equals(_unknownFields, other._unknownFields); } @@ -23268,6 +23291,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { public override int GetHashCode() { int hash = 1; hash ^= snapshots_.GetHashCode(); + if (SnapshotTruncated != false) hash ^= SnapshotTruncated.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -23287,6 +23311,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawMessage(this); #else snapshots_.WriteTo(output, _repeated_snapshots_codec); + if (SnapshotTruncated != false) { + output.WriteRawTag(16); + output.WriteBool(SnapshotTruncated); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -23298,6 +23326,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { snapshots_.WriteTo(ref output, _repeated_snapshots_codec); + if (SnapshotTruncated != false) { + output.WriteRawTag(16); + output.WriteBool(SnapshotTruncated); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -23309,6 +23341,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { public int CalculateSize() { int size = 0; size += snapshots_.CalculateSize(_repeated_snapshots_codec); + if (SnapshotTruncated != false) { + size += 1 + 1; + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -23322,6 +23357,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { return; } snapshots_.Add(other.snapshots_); + if (other.SnapshotTruncated != false) { + SnapshotTruncated = other.SnapshotTruncated; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -23345,6 +23383,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { snapshots_.AddEntriesFrom(input, _repeated_snapshots_codec); break; } + case 16: { + SnapshotTruncated = input.ReadBool(); + break; + } } } #endif @@ -23368,6 +23410,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { snapshots_.AddEntriesFrom(ref input, _repeated_snapshots_codec); break; } + case 16: { + SnapshotTruncated = input.ReadBool(); + break; + } } } } @@ -26732,6 +26778,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { limitValue_ = other.limitValue_ != null ? other.limitValue_.Clone() : null; degraded_ = other.degraded_; sourceProvider_ = other.sourceProvider_; + fromTruncatedSnapshot_ = other.fromTruncatedSnapshot_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -26944,6 +26991,29 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } } + /// Field number for the "from_truncated_snapshot" field. + public const int FromTruncatedSnapshotFieldNumber = 16; + private bool fromTruncatedSnapshot_; + /// + /// 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. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool FromTruncatedSnapshot { + get { return fromTruncatedSnapshot_; } + set { + fromTruncatedSnapshot_ = value; + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -26974,6 +27044,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (!object.Equals(LimitValue, other.LimitValue)) return false; if (Degraded != other.Degraded) return false; if (SourceProvider != other.SourceProvider) return false; + if (FromTruncatedSnapshot != other.FromTruncatedSnapshot) return false; return Equals(_unknownFields, other._unknownFields); } @@ -26996,6 +27067,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (limitValue_ != null) hash ^= LimitValue.GetHashCode(); if (Degraded != false) hash ^= Degraded.GetHashCode(); if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) hash ^= SourceProvider.GetHashCode(); + if (FromTruncatedSnapshot != false) hash ^= FromTruncatedSnapshot.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -27074,6 +27146,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(120); output.WriteEnum((int) SourceProvider); } + if (FromTruncatedSnapshot != false) { + output.WriteRawTag(128, 1); + output.WriteBool(FromTruncatedSnapshot); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -27144,6 +27220,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { output.WriteRawTag(120); output.WriteEnum((int) SourceProvider); } + if (FromTruncatedSnapshot != false) { + output.WriteRawTag(128, 1); + output.WriteBool(FromTruncatedSnapshot); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -27199,6 +27279,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) { size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) SourceProvider); } + if (FromTruncatedSnapshot != false) { + size += 2 + 1; + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -27268,6 +27351,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { if (other.SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) { SourceProvider = other.SourceProvider; } + if (other.FromTruncatedSnapshot != false) { + FromTruncatedSnapshot = other.FromTruncatedSnapshot; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -27359,6 +27445,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum(); break; } + case 128: { + FromTruncatedSnapshot = input.ReadBool(); + break; + } } } #endif @@ -27450,6 +27540,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum(); break; } + case 128: { + FromTruncatedSnapshot = input.ReadBool(); + break; + } } } } diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto index db5fb4f..0ee3fb2 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -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 { diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs index 5ee6788..f470396 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs @@ -170,6 +170,7 @@ public sealed class DashboardLdapLiveTests return new DashboardAuthenticator( new LdapAuthService(ldapOptions), new DashboardGroupRoleMapper(Options.Create(gatewayOptions)), + Options.Create(gatewayOptions), NullLogger.Instance); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs index b29d00a..8ce372e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs @@ -57,6 +57,11 @@ 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, so it always describes the same pass that + // produced the current _alarms generation. + private bool _snapshotTruncated; + private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; private volatile string? _lastError; private GatewaySession? _session; @@ -110,6 +115,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic } } + /// + public bool SnapshotTruncated + { + get { lock (_sync) { return _snapshotTruncated; } } + } + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -416,7 +427,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 +621,13 @@ 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 snapshots) + // + // Truncation (`snapshotTruncated`) needs no special 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 therefore only recorded, for the operator-facing completeness caveat. + private void ApplyReconcile(IEnumerable snapshots, bool snapshotTruncated) { Dictionary next = new(StringComparer.Ordinal); foreach (ActiveAlarmSnapshot snapshot in snapshots) @@ -669,6 +686,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic _alarms[incoming.Key] = incoming.Value; } + _snapshotTruncated = snapshotTruncated; _currentAlarmsProjection = null; } } @@ -716,6 +734,10 @@ 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. + _snapshotTruncated = false; _currentAlarmsProjection = null; } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs index 7231ba4..fd596bd 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IGatewayAlarmService.cs @@ -38,6 +38,16 @@ public interface IGatewayAlarmService /// A point-in-time copy of the current active-alarm set. IReadOnlyList CurrentAlarms { get; } + /// + /// True when the worker's most recent reconcile fetch hit the provider's + /// per-fetch cap, so may be missing active + /// alarms. The monitor is otherwise healthy — this is not a fault, it is + /// a completeness caveat, which is why it is separate from + /// and . Cleared by the first + /// reconcile whose fetch comes back under the cap. + /// + bool SnapshotTruncated { get; } + /// /// Attaches to the central alarm feed. The returned stream yields one /// per currently-active alarm, then a diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor index 634b4ba..0cabe58 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -34,6 +34,17 @@
Alarm query failed: @_queryError
} +@* 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) +{ +
+ 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 + MxGateway:Alarms:MaxAlarmsPerFetch or narrow the subscription if this persists. +
+} +
@@ -156,6 +167,7 @@ @code { private readonly List _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(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index af26880..0546c68 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -10,6 +10,7 @@ @inject AuthenticationStateProvider AuthenticationStateProvider @inject IDashboardSessionAdminService SessionAdminService @inject IDashboardSessionEventSubscriber EventSubscriber +@inject IDashboardSessionAcl SessionAcl Dashboard Session @@ -114,7 +115,11 @@ else @(_eventsConnected ? "live" : "offline") - @if (_recentEvents.Count == 0) + @if (!_eventsAuthorized) + { +
Not authorized for this session's events.
+ } + else if (_recentEvents.Count == 0) {
Waiting for events. The dashboard subscribes to this session's events directly, so @@ -175,6 +180,10 @@ 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; private readonly LinkedList _recentEvents = new(); @@ -203,7 +212,7 @@ else // 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(); } } @@ -288,19 +297,34 @@ 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() { 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(); + + _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. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs index 90708e2..fe3a76f 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardActiveAlarm.cs @@ -57,7 +57,14 @@ public sealed record DashboardActiveAlarm( /// The active alarms, or an empty list on error. /// A diagnostic message when the query failed; otherwise null. /// The worker process id backing the dashboard session, when available. +/// +/// True when the provider fetch behind hit its per-fetch cap, so the +/// list may be missing active alarms. Distinct from : 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. +/// public sealed record DashboardAlarmQueryResult( IReadOnlyList Alarms, string? Error, - int? WorkerProcessId); + int? WorkerProcessId, + bool SnapshotTruncated = false); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs index f00caf3..7f00a59 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -36,6 +36,16 @@ public static class DashboardAuthenticationDefaults public const string LdapGroupClaimType = "mxgateway:ldap_group"; public const string KeyPrefixClaimType = "mxgateway:key_prefix"; + /// + /// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped + /// at cookie login by and at hub-token mint by + /// , both resolving the caller's LDAP groups through + /// MxGateway:Dashboard:GroupToTag; read by . 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. + /// + public const string DashboardTagClaimType = "zb:dashboardtag"; + /// /// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure /// (RequireHttpsCookie=false) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs index 4ad86fe..8250990 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -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; /// /// Shared LDAP bind-then-search provider. /// Maps LDAP groups to dashboard roles. +/// +/// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map that turns the user's +/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25). +/// /// Logger for diagnostic, credential-free login outcomes. public sealed class DashboardAuthenticator( ILdapAuthService ldapAuthService, IGroupRoleMapper roleMapper, + IOptions options, ILogger 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)); } /// @@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator( /// is role-based), so the shape change is non-breaking for dashboard consumers. /// /// The dashboard roles resolved from . + /// + /// The configured Dashboard:GroupToTag map. The tags it grants are stamped as + /// 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. + /// private static ClaimsPrincipal CreatePrincipal( string username, string displayName, IEnumerable groups, - IEnumerable roles) + IEnumerable roles, + IReadOnlyDictionary 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 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, diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs index ec2aa2e..6e7e2c2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardLiveDataService.cs @@ -127,7 +127,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 diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 83ef2d1..f9367d9 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // 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(); // 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. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs new file mode 100644 index 0000000..6a28aec --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs @@ -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; + +/// +/// Tag-intersection implementation of . Fails closed on +/// every branch: an unknown session, an empty tag grant, and an untagged session under the +/// default all deny. +/// +/// +/// +/// Decision order (first match wins): +/// +/// +/// Authenticated caller in → allow. Admin +/// already reaches every destructive surface, so event-metadata visibility is strictly weaker. +/// Session not present in → deny. No subscription +/// is created for a phantom id. +/// Session carries no tags → allow only when +/// MxGateway:Dashboard:UntaggedSessionVisibility is +/// . +/// Otherwise allow iff the session's tags intersect the caller's granted tags +/// (ordinal-ignore-case). +/// +/// +/// Granted tags are read from the caller's +/// claims, stamped at login () or at hub-token mint +/// (). A principal with no such claims — anonymous localhost included — +/// is an empty-grant Viewer. +/// +/// +/// This sits on the subscribe 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 +/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale +/// while the subscription lives. +/// +/// +/// Registry the session id is resolved against. +/// Gateway options supplying Dashboard:UntaggedSessionVisibility. +public sealed class DashboardSessionAcl( + ISessionManager sessionManager, + IOptions options) : IDashboardSessionAcl +{ + /// + 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; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs index 1ea957b..5985374 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs @@ -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; /// /// 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. /// /// /// This service is registered as a singleton in @@ -32,24 +34,33 @@ public sealed class HubTokenService // 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 _options; /// Initializes a new instance of the HubTokenService with a data protection provider. /// The data protection provider for token encryption. - public HubTokenService(IDataProtectionProvider dataProtection) + /// + /// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map used to resolve the + /// caller's granted visibility tags at mint time. + /// + public HubTokenService(IDataProtectionProvider dataProtection, IOptions options) { ArgumentNullException.ThrowIfNull(dataProtection); + ArgumentNullException.ThrowIfNull(options); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); + _options = options; } - /// Issues a bearer token carrying the user's identity and roles. + /// Issues a bearer token carrying the user's identity, roles, and granted tags. /// The claims principal representing the user. /// The data-protected bearer token string. public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime); @@ -65,10 +76,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 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 +128,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 +148,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); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs index 53c5c1c..601fabc 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/EventsHub.cs @@ -15,8 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// registry to skip all mirror work for sessions nobody is watching. /// /// Registry tracking which sessions have live subscribers. +/// Per-session visibility gate consulted before any group join. [Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] -public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub +public sealed class EventsHub( + EventsHubViewerRegistry viewerRegistry, + IDashboardSessionAcl sessionAcl) : Hub { /// Method name used to push individual MxEvent values to clients. public const string EventMessage = "MxEvent"; @@ -33,27 +36,21 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub /// client. /// /// - /// In v1 the hub-level - /// (HubClientsPolicy) 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 - /// when - /// MxGateway:Dashboard:ShowTagValues 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 - /// StreamEvents 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 - /// Context.User claims / Context.Items) or via a dedicated - /// authorization policy applied to the hub method itself. + /// The hub-level (HubClientsPolicy) + /// 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 's + /// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a + /// session only when its tags intersect their granted tags, and an unknown + /// session id is denied. A denied caller is not joined to the group and is + /// not registered with , 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. /// /// Session id to subscribe the caller to. /// A task representing the subscription operation. + /// The caller may not observe this session. public Task SubscribeSession(string sessionId) { if (string.IsNullOrWhiteSpace(sessionId)) @@ -61,6 +58,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. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs new file mode 100644 index 0000000..5ae4f87 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/IDashboardSessionAcl.cs @@ -0,0 +1,33 @@ +using System.Security.Claims; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Decides whether a dashboard principal may observe one session's mirrored +/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the +/// SignalR EventsHub.SubscribeSession join and the in-process +/// IDashboardSessionEventSubscriber.Subscribe used by the +/// session-details page. +/// +/// +/// The dashboard authenticates LDAP users while sessions are owned by API keys — +/// two disjoint identity domains — so the bridge is the session tag: a +/// session inherits its owning key's tags, and a dashboard group grants tags via +/// MxGateway:Dashboard:GroupToTag. See +/// docs/plans/2026-07-10-dashboard-session-acl-tst15.md. +/// +public interface IDashboardSessionAcl +{ + /// + /// Returns whether may observe the events of the + /// session identified by . + /// + /// + /// The dashboard caller. , unauthenticated, or claim-less + /// principals (including the anonymous-localhost path) are treated as Viewers + /// holding an empty tag grant. + /// + /// Session id the caller wants to observe. + /// when the caller may observe the session; otherwise . + bool CanViewSession(ClaimsPrincipal? principal, string sessionId); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs new file mode 100644 index 0000000..e8f8772 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmTruncationSignalTests.cs @@ -0,0 +1,329 @@ +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; + +/// +/// Carries the worker's truncated-fetch verdict across the gateway: worker +/// reply payload → → the public +/// QueryActiveAlarms stream. +/// +/// +/// +/// 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 silent: 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. +/// +/// +/// The load-bearing assertion is the false one +/// (). +/// "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. +/// +/// +public sealed class AlarmTruncationSignalTests +{ + private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15); + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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); + } + + /// + /// The public QueryActiveAlarms stream carries the per-record flag + /// through untouched. That RPC returns a bare + /// stream ActiveAlarmSnapshot 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. + /// + /// A task that represents the asynchronous operation. + [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 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); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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 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); + } + + 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.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.Instance, + alarms); + } + + private static async Task WaitUntilAsync(Func 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."); + } + + /// that resolves an empty watch-list. + private sealed class StubWatchListResolver : IAlarmWatchListResolver + { + /// + public Task> ResolveAsync( + AlarmsOptions options, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + } + + /// + /// Minimal that answers the monitor's + /// QueryActiveAlarms with a scripted reply payload — the seam this suite + /// drives the truncation verdict through. + /// + private sealed class StubSessionManager : ISessionManager + { + private readonly Channel _events = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _reconciled = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets or sets the truncation verdict the scripted reply carries. + public bool SnapshotTruncated { get; init; } + + /// Gets or sets the snapshots the scripted reply carries. + public IReadOnlyList Snapshots { get; init; } = []; + + /// Completes once the monitor has issued its first QueryActiveAlarms. + /// The maximum time to wait. + /// A task that represents the asynchronous operation. + public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout); + + /// + public Task 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); + } + + /// + public Task 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() { SnapshotTruncated = SnapshotTruncated }; + payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone())); + reply.QueryActiveAlarms = payload; + _reconciled.TrySetResult(); + } + + return Task.FromResult(new WorkerCommandReply { Reply = reply }); + } + + /// + public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) + { + session = null; + return false; + } + + /// + public Task CloseSessionAsync(string sessionId, CancellationToken cancellationToken) + { + _events.Writer.TryComplete(); + return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + } + + /// + public Task KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) => + Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + + /// + public Task CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) => + Task.FromResult(0); + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + /// + /// stub — QueryActiveAlarms never + /// touches the event path, but the service constructor requires one. + /// + private sealed class StubEventStreamService : IEventStreamService + { + /// + public async IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + string? callerKeyId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs new file mode 100644 index 0000000..ebb4e54 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/AlarmsPageTruncationBannerTests.cs @@ -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; + +/// +/// Renders and asserts the truncated-snapshot +/// caveat banner appears exactly when the alarm query reports a capped +/// provider fetch. +/// +/// +/// +/// 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. +/// +/// +/// Static rendering via the framework's , as in +/// SecretsNavRenderTests — 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 OnInitialized +/// (the stub query completes synchronously), so the rendered markup +/// already reflects the query result. +/// +/// +public sealed class AlarmsPageTruncationBannerTests +{ + private const string BannerMarker = "Alarm snapshot may be incomplete"; + + /// A capped provider fetch puts the completeness caveat on the page. + /// A task that represents the asynchronous operation. + [Fact] + public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner() + { + string html = await RenderAsync(snapshotTruncated: true); + + Assert.Contains(BannerMarker, html, StringComparison.Ordinal); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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 RenderAsync(bool snapshotTruncated) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton( + new StubLiveDataService(snapshotTruncated)); + services.AddSingleton( + new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated }); + services.AddSingleton>( + Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } })); + + await using ServiceProvider provider = services.BuildServiceProvider(); + await using HtmlRenderer renderer = new( + provider, + provider.GetRequiredService()); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + HtmlRootComponent output = await renderer.RenderComponentAsync(); + 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 + { + /// + public Task ReadAsync( + IReadOnlyCollection tagAddresses, + CancellationToken cancellationToken) => + Task.FromResult(DashboardLiveReadResult.Empty); + + /// + public Task QueryAlarmsAsync(CancellationToken cancellationToken) => + Task.FromResult(new DashboardAlarmQueryResult( + Alarms: [], + Error: null, + WorkerProcessId: null, + SnapshotTruncated: snapshotTruncated)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index 08e87b1..8773038 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -291,6 +291,7 @@ public sealed class DashboardAuthenticatorTests return new DashboardAuthenticator( ldapAuthService, roleMapper, + Options.Create(options), NullLogger.Instance); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs new file mode 100644 index 0000000..ff8ddba --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs @@ -0,0 +1,264 @@ +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; + +/// +/// Covers , the single decision both dashboard subscribe seams +/// consult (SEC-25 / TST-15). +/// +/// +/// 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. +/// +public sealed class DashboardSessionAclTests +{ + private const string TaggedSessionId = "session-tagged"; + private const string UntaggedSessionId = "session-untagged"; + + /// An Administrator bypasses the tag check entirely, including for a tag they hold none of. + [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)); + } + + /// + /// The admin bypass is what keeps Dashboard:DisableLogin auto-login (which stamps both + /// roles and no tags) working exactly as before this change. + /// + [Fact] + public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed() + { + DashboardSessionAcl acl = CreateAcl(); + + Assert.True(acl.CanViewSession( + Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]), + TaggedSessionId)); + } + + /// + /// An unknown session id is denied even for a caller holding every configured tag: no + /// subscription is created for a session the registry does not have. + /// + [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")); + } + + /// A blank session id is denied without consulting anything. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void CanViewSession_BlankSessionId_Denied(string sessionId) + { + DashboardSessionAcl acl = CreateAcl(); + + Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId)); + } + + /// A null principal denies — the fail-closed reading of an unauthenticated hub context. + [Fact] + public void CanViewSession_NullPrincipal_Denied() + { + DashboardSessionAcl acl = CreateAcl(); + + Assert.False(acl.CanViewSession(null, UntaggedSessionId)); + } + + /// + /// Untagged sessions follow Dashboard:UntaggedSessionVisibility: hidden from Viewers + /// under the shipped default, visible under + /// the opt-in . + /// + /// The configured untagged-session visibility. + /// Whether a tagless Viewer may observe the untagged session. + [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)); + } + + /// + /// 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. + /// + /// The single tag the Viewer holds. + [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)); + } + + /// A Viewer holding only another tenant's tag is denied — the load-bearing negative. + [Fact] + public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied() + { + DashboardSessionAcl acl = CreateAcl(); + + Assert.False(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]), + TaggedSessionId)); + } + + /// + /// 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 . + /// + [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)); + } + + /// + /// 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 (DashboardSessionAdminService.CanManage). + /// + [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 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); + } + + /// Registry double serving exactly the two sessions the ACL cases need. + private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager + { + /// + public Task OpenSessionAsync( + SessionOpenRequest request, + string? clientIdentity, + string? ownerKeyId, + CancellationToken cancellationToken) => Task.FromResult(tagged); + + /// + public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session) + { + session = sessionId switch + { + TaggedSessionId => tagged, + UntaggedSessionId => untagged, + _ => null, + }; + + return session is not null; + } + + /// + public Task InvokeAsync( + string sessionId, + WorkerCommand command, + CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply()); + + /// + public Task CloseSessionAsync( + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + + /// + public Task KillWorkerAsync( + string sessionId, + string reason, + CancellationToken cancellationToken) => + Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); + + /// + public Task CloseExpiredLeasesAsync( + DateTimeOffset now, + CancellationToken cancellationToken) => Task.FromResult(0); + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs new file mode 100644 index 0000000..b0f2b83 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/EventsHubTests.cs @@ -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; + +/// +/// Covers the ACL gate on (SEC-25 / TST-15). +/// +/// +/// 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. +/// +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")); + + /// An allowed caller joins the group and registers as a viewer. + /// A task that represents the asynchronous operation. + [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)); + } + + /// + /// A denied caller gets a , is not joined, and is not registered. + /// + /// A task that represents the asynchronous operation. + [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(() => hub.SubscribeSession(SessionId)); + + Assert.Equal("Not authorized for this session.", error.Message); + Assert.Empty(groups.Added); + Assert.False(registry.HasViewers(SessionId)); + } + + /// + /// 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. + /// + /// The blank session id supplied by the caller. + /// A task that represents the asynchronous operation. + [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); + } + + /// The ACL is asked about the session the caller named, with the caller's own principal. + /// A task that represents the asynchronous operation. + [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 + { + /// Gets the principal passed to the most recent call. + public ClaimsPrincipal? LastPrincipal { get; private set; } + + /// Gets the session id passed to the most recent call. + public string? LastSessionId { get; private set; } + + /// + public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) + { + LastPrincipal = principal; + LastSessionId = sessionId; + + return allow; + } + } + + private sealed class RecordingGroupManager : IGroupManager + { + /// Gets the (connection id, group name) pairs added, in order. + public List<(string ConnectionId, string GroupName)> Added { get; } = []; + + /// + public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) + { + Added.Add((connectionId, groupName)); + + return Task.CompletedTask; + } + + /// + public Task RemoveFromGroupAsync( + string connectionId, + string groupName, + CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext + { + /// + public override string ConnectionId { get; } = connectionId; + + /// + public override string? UserIdentifier => User?.Identity?.Name; + + /// + public override ClaimsPrincipal? User { get; } = user; + + /// + public override IDictionary Items { get; } = new Dictionary(); + + /// + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override CancellationToken ConnectionAborted => CancellationToken.None; + + /// + public override void Abort() + { + // Nothing to abort in a unit-constructed context. + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs index 502cd9d..43a390c 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/HubTokenServiceTests.cs @@ -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,85 @@ public sealed class HubTokenServiceTests Assert.Null(service.Validate(expiredToken)); } + + /// + /// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are + /// resolved from the caller's LDAP-group claims through Dashboard:GroupToTag at + /// and rehydrated as + /// claims on the principal + /// reconstructs — which is the principal + /// IDashboardSessionAcl reads on the hub path. + /// + [Fact] + public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags() + { + HubTokenService service = CreateService(new Dictionary(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)); + } + + /// + /// 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. + /// + [Fact] + public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant() + { + HubTokenService service = CreateService(new Dictionary(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)); + } + + private static HubTokenService CreateService(Dictionary? groupToTag = null) + { + GatewayOptions options = new() + { + Dashboard = new DashboardOptions + { + GroupToTag = groupToTag ?? new Dictionary(StringComparer.OrdinalIgnoreCase), + }, + }; + + return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options)); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs new file mode 100644 index 0000000..7b500ef --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/SessionDetailsPageEventAclTests.cs @@ -0,0 +1,218 @@ +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.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; + +/// +/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15). +/// +/// +/// +/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a +/// session's events — , 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 +/// is never called, not merely that the +/// panel renders differently. +/// +/// +/// Rendered through the framework's static , the same idiom +/// SecretsNavRenderTests uses — no component-testing package, because the assertions are +/// about the emitted markup and the calls the lifecycle makes, not about interactivity. +/// +/// +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."; + + /// A denied caller gets the message and no subscription is opened. + /// A task that represents the asynchronous operation. + [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); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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); + } + + private static async Task RenderAsync(RecordingEventSubscriber subscriber, bool allow) + { + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(new StubSnapshotService()); + services.AddSingleton(new IdleSnapshotFeed()); + services.AddSingleton(new NonManagingSessionAdminService()); + services.AddSingleton(subscriber); + services.AddSingleton(new StubSessionAcl(allow)); + services.AddSingleton(new StubAuthenticationStateProvider()); + + await using ServiceProvider provider = services.BuildServiceProvider(); + await using HtmlRenderer renderer = new(provider, provider.GetRequiredService()); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + HtmlRootComponent output = await renderer.RenderComponentAsync( + ParameterView.FromDictionary(new Dictionary + { + [nameof(SessionDetailsPage.SessionId)] = SessionId, + })); + + return output.ToHtmlString(); + }); + } + + private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl + { + /// + public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow; + } + + private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber + { + /// Gets the session ids was called with, in order. + public List SubscribedSessionIds { get; } = []; + + /// + public IDashboardEventSubscription Subscribe(string sessionId) + { + SubscribedSessionIds.Add(sessionId); + + return new IdleSubscription(); + } + + // A subscription whose channel never yields and never completes, so the page's pump parks + // exactly as it would against a quiet session. + private sealed class IdleSubscription : IDashboardEventSubscription + { + private readonly Channel _channel = Channel.CreateUnbounded(); + + /// + public ChannelReader Reader => _channel.Reader; + + /// + public void Dispose() => _channel.Writer.TryComplete(); + } + } + + private sealed class StubSnapshotService : IDashboardSnapshotService + { + /// + 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!); + + /// + public IAsyncEnumerable 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 + { + /// + public async IAsyncEnumerable WatchAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + + yield break; + } + } + + private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService + { + /// + public bool CanManage(ClaimsPrincipal user) => false; + + /// + public Task CloseSessionAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); + + /// + public Task KillWorkerAsync( + ClaimsPrincipal user, + string sessionId, + CancellationToken cancellationToken) => + Task.FromResult(DashboardSessionAdminResult.Fail("not supported")); + } + + private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider + { + /// + public override Task 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)))); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs index e0e1674..369b672 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/TestSupport/FakeGatewayAlarmService.cs @@ -24,6 +24,9 @@ public sealed class FakeGatewayAlarmService : IGatewayAlarmService /// public IReadOnlyList CurrentAlarms { get; set; } = []; + /// + public bool SnapshotTruncated { get; set; } + /// public async IAsyncEnumerable StreamAsync( string? alarmFilterPrefix, diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs index bef898a..a2df0ef 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs @@ -371,6 +371,9 @@ public sealed class AlarmCommandExecutorTests /// Gets the last alarm filter prefix. public string? LastFilterPrefix { get; private set; } + /// Gets or sets the truncation verdict the executor stamps onto the reply payload. + public bool LastSnapshotTruncated { get; set; } + /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs index 5e95c0e..d44a07e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs @@ -473,6 +473,9 @@ public sealed class AlarmCommandHandlerTests /// public IReadOnlyList SnapshotActiveAlarms() => SnapshotResult; + /// Gets or sets the truncation verdict reported for the last fetch. + public bool LastSnapshotTruncated { get; set; } + /// Gets the number of times polled. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs index 4106c79..7b0474a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs @@ -438,6 +438,9 @@ public sealed class AlarmDispatcherTests return SnapshotResult; } + /// Gets or sets the truncation verdict the dispatcher stamps onto snapshots. + public bool LastSnapshotTruncated { get; set; } + /// Gets the count of poll operations. public int PollCount { get; private set; } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs index 71f5f8d..3625ab9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs @@ -80,6 +80,9 @@ public sealed class FailoverAlarmConsumerTests /// public IReadOnlyList SnapshotActiveAlarms() => Array.Empty(); + /// Gets or sets the truncation verdict this child reports, so delegation is observable. + public bool LastSnapshotTruncated { get; set; } + /// public void Dispose() { } @@ -132,6 +135,9 @@ public sealed class FailoverAlarmConsumerTests return Array.Empty(); } + /// Gets or sets the truncation verdict this child reports, so delegation is observable. + public bool LastSnapshotTruncated { get; set; } + /// public void Dispose() { } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index aace1d7..0e7fd07 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -643,6 +643,9 @@ public sealed class MxAccessStaSessionTests get { lock (gate) return lastPollThreadId; } } + /// Gets or sets the truncation verdict reported for the last fetch. + public bool LastSnapshotTruncated { get; set; } + /// public void Subscribe(SubscribeAlarmsCommand command, string sessionId) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs index 7dd9448..28c4293 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -688,6 +688,88 @@ public sealed class WnWrapAlarmConsumerXmlTests Assert.Equal(MxAlarmStateKind.UnackAlm, record.State); } + // ------------------------------------------------------------------------- + // Degraded-status signal. The truncation guard above keeps a capped fetch + // from broadcasting phantom Clears, but it does so silently: the retained + // snapshot simply stops shrinking. LastSnapshotTruncated is what makes that + // suppression visible to the QueryActiveAlarms reply and, through it, the + // dashboard banner — so its set/reset behaviour is the contract, not detail. + // ------------------------------------------------------------------------- + + /// + /// A capped fetch sets the retained truncation verdict. Without this the + /// signal never leaves the consumer and the reply builder stamps a + /// complete-looking snapshot over a capped one. + /// + [Fact] + public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + Assert.False(consumer.LastSnapshotTruncated); + + Dictionary next = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount); + Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + + consumer.FoldFetch(next, truncated: true, out int retainedCount); + + Assert.True(consumer.LastSnapshotTruncated); + Assert.Equal(Cap, retainedCount); + } + + /// + /// THE reset test. A sub-cap fetch is complete, so it restores absence + /// authority and must clear the verdict. Latching it instead would leave + /// the operator banner asserting "snapshot may be incomplete" forever + /// after a single burst above the cap, which trains operators to ignore + /// it — the opposite of what the signal is for. + /// + [Fact] + public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + Dictionary capped = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); + consumer.FoldFetch(capped, truncated: true, out _); + Assert.True(consumer.LastSnapshotTruncated); + + Dictionary complete = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount); + Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap)); + + consumer.FoldFetch(complete, truncated: false, out int retainedCount); + + Assert.False(consumer.LastSnapshotTruncated); + // The complete fetch also replaced the snapshot wholesale, which is what + // makes it authoritative about absence — pinned here so a future change + // cannot clear the verdict while keeping the merge semantics. + Assert.Equal(Cap - 1, retainedCount); + } + + /// + /// Consecutive capped fetches keep the verdict set. It is per-fetch state, + /// not an edge-triggered one-shot: an operator arriving mid-burst must + /// still see the caveat. + /// + [Fact] + public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet() + { + const int Cap = 8; + using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap); + + for (int pass = 0; pass < 3; pass++) + { + Dictionary capped = + WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _); + consumer.FoldFetch(capped, truncated: true, out _); + Assert.True(consumer.LastSnapshotTruncated); + } + } + private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state) { return new MxAlarmSnapshotRecord diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs index de60fce..534b9fa 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs @@ -342,6 +342,25 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler return filtered; } + /// + /// + /// Deliberately does not go through GetDispatcherOrThrow: an + /// unsubscribed handler has performed no fetch, and "no fetch" is not + /// truncated. Throwing here would turn a status read into a command + /// failure on a path the reply builder takes after the snapshot has + /// already been produced. + /// + public bool LastSnapshotTruncated + { + get + { + if (disposed) return false; + AlarmDispatcher? d; + lock (syncRoot) d = dispatcher; + return d is not null && d.LastSnapshotTruncated; + } + } + /// public void PollOnce() { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs index accc2da..c5906c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs @@ -158,16 +158,28 @@ public sealed class AlarmDispatcher : IDisposable public IReadOnlyList SnapshotActiveAlarms() { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); + // Read the truncation verdict before the snapshot, so a poll landing + // between the two can only widen the warning (a stale "truncated" over a + // complete snapshot), never narrow it into a false all-clear. + bool truncated = consumer.LastSnapshotTruncated; IReadOnlyList records = consumer.SnapshotActiveAlarms(); if (records.Count == 0) return Array.Empty(); List snapshots = new List(records.Count); foreach (MxAlarmSnapshotRecord record in records) { - snapshots.Add(MapToSnapshot(record)); + snapshots.Add(MapToSnapshot(record, truncated)); } return snapshots; } + /// + /// Whether the consumer's most recent fetch hit the per-fetch cap, so + /// the set returns may omit actives. + /// Stamped onto the QueryActiveAlarms reply payload, which is the only + /// carrier when the snapshot filters down to zero records. + /// + public bool LastSnapshotTruncated => !disposed && consumer.LastSnapshotTruncated; + private void OnTransition(object? sender, MxAlarmTransitionEvent transition) { if (disposed) return; @@ -196,7 +208,7 @@ public sealed class AlarmDispatcher : IDisposable degraded: record.Degraded); } - private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record) + private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated) { ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot { @@ -212,6 +224,12 @@ public sealed class AlarmDispatcher : IDisposable Description = string.Empty, Degraded = record.Degraded, SourceProvider = record.Degraded ? AlarmProviderMode.Subtag : AlarmProviderMode.Alarmmgr, + // Set-level status, stamped identically on every record of the + // snapshot: QueryActiveAlarms streams bare ActiveAlarmSnapshot + // messages with no envelope to hang it off. Independent of + // Degraded above — that is about this record's provider, this is + // about whether the set it belongs to is complete. + FromTruncatedSnapshot = truncated, }; if (record.TransitionTimestampUtc != DateTime.MinValue) { diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs index 89896d2..9c69645 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs @@ -266,6 +266,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer return ActiveChild.SnapshotActiveAlarms(); } + /// + /// + /// Delegated to the active child, matching + /// : the flag describes the snapshot + /// the same child produced, so reading it off the standby would pair a + /// verdict with a snapshot it does not belong to. A failover to the + /// subtag standby therefore reports not-truncated — correctly, since + /// that child performs no capped fetch. + /// + public bool LastSnapshotTruncated => !disposed && ActiveChild.LastSnapshotTruncated; + private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby; /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs index fa97078..bd8b6a9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs @@ -74,6 +74,16 @@ public interface IAlarmCommandHandler : IDisposable /// The currently active alarms matching the filter. IReadOnlyList QueryActive(string? alarmFilterPrefix); + /// + /// Whether the consumer's most recent fetch hit the per-fetch cap, so + /// the set draws from may omit active alarms. + /// Stamped on the QueryActiveAlarms reply payload — the only carrier + /// once a prefix filter (or an empty galaxy) leaves zero records to + /// carry the per-record flag. when there is no + /// active subscription: no fetch has happened, so nothing is capped. + /// + bool LastSnapshotTruncated { get; } + /// /// Drives a single poll of the underlying alarm consumer on the /// caller's thread. This is a no-op when there is no active diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index af7d1be..71f30e8 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -36,6 +36,20 @@ public interface IMxAccessAlarmConsumer : IDisposable /// event EventHandler? AlarmTransitionEmitted; + /// + /// Whether the most recent fetch that reached the retained snapshot came + /// back holding the per-fetch cap. While this is + /// the snapshot returned by is + /// authoritative about presence only: the provider may hold actives it + /// had no room to report, and the consumer has suspended the + /// absence-implies-Clear inference. Not latched — the first sub-cap fetch + /// after a run of capped ones clears it, because that fetch is complete + /// and the snapshot it produced is again authoritative about absence. + /// Consumers with no per-fetch cap (the subtag fallback, which is + /// event-driven) always report . + /// + bool LastSnapshotTruncated { get; } + /// /// Initializes the AVEVA alarm-client connection, registers as a /// consumer, and subscribes to the supplied alarm-provider expression. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 15019f4..f1ada4b 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -977,6 +977,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix); QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); payload.Snapshots.AddRange(snapshots); + // Set-level degraded status: the snapshot may omit actives because + // the provider fetch hit its cap. The records carry the same flag, + // but a prefix filter can leave none, so the payload states it too. + payload.SnapshotTruncated = alarmCommandHandler.LastSnapshotTruncated; MxCommandReply reply = CreateOkReply(command); reply.QueryActiveAlarms = payload; return reply; diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs index c02b519..754a36d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs @@ -45,6 +45,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer /// Fires once per synthesized alarm-state transition. public event EventHandler? AlarmTransitionEmitted; + /// + /// + /// Always . Subtag mode is advise-driven over a + /// fixed watch list — there is no bulk fetch and therefore no per-fetch + /// cap to hit. Subtag snapshots are lower-fidelity in other ways, which + /// MxAlarmSnapshotRecord.Degraded already reports; truncation is + /// not one of them. + /// + public bool LastSnapshotTruncated => false; + /// /// Initializes the consumer over a subtag source and a watch list of /// alarm targets. diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs index 041e901..0f07764 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -93,6 +93,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds; private long truncatedFetchCount; + private bool lastSnapshotTruncated; private wwAlarmConsumerClass? client; private wwAlarmConsumerClass? ackClient; private bool subscribed; @@ -125,6 +126,23 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer : DefaultMaxAlarmsPerFetch; } + /// + /// COM-free construction, for exercising the retained-snapshot state + /// machine ( / + /// / ) on a machine without AVEVA + /// installed. throws and + /// no-ops on an instance built this way — both need the wnwrap coclass, + /// which cannot be instantiated on the macOS/Linux test matrix. Internal + /// rather than public so it cannot be reached from production wiring. + /// + /// Maximum alarms per fetch call. + internal WnWrapAlarmConsumer(int maxAlarmsPerFetch) + { + this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0 + ? maxAlarmsPerFetch + : DefaultMaxAlarmsPerFetch; + } + /// /// Resolves the per-fetch cap from the launcher-provided environment /// variable. A missing, unparseable, or out-of-range value falls back @@ -373,6 +391,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// + /// Read without the disposed guard + /// carries: this is degraded-status metadata a reply builder stamps + /// alongside a snapshot, and throwing from it would fail a query whose + /// snapshot half succeeded. + /// + public bool LastSnapshotTruncated + { + get { lock (syncRoot) { return lastSnapshotTruncated; } } + } + /// /// Sink for the rate-limited truncated-fetch warning. Defaults to /// , the stream @@ -413,14 +443,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer // docs/AlarmProbeFindings.md.) bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); - IReadOnlyList transitions; - int retainedCount; - lock (syncRoot) - { - transitions = ComputeTransitions(latestSnapshot, next); - ApplySnapshotUpdate(latestSnapshot, next, truncated); - retainedCount = latestSnapshot.Count; - } + IReadOnlyList transitions = + FoldFetch(next, truncated, out int retainedCount); if (truncated) { @@ -436,6 +460,37 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer } } + /// + /// Folds one fetch into the retained state under a single lock: the + /// transition diff, the snapshot merge/replace, and the truncation + /// verdict move together. Splitting them would let a concurrent + /// / + /// pair read a capped snapshot alongside the previous poll's "complete" + /// verdict — precisely the false all-clear the signal exists to prevent. + /// The verdict is replaced, never latched: a sub-cap fetch is complete + /// and restores absence authority, so leaving the flag set would strand + /// the operator banner on after a single burst. + /// + /// The snapshot just parsed from the fetch. + /// Whether the fetch hit the per-fetch cap. + /// Size of the retained snapshot after the fold. + /// The transitions the fetch implies. + internal IReadOnlyList FoldFetch( + Dictionary next, + bool truncated, + out int retainedCount) + { + lock (syncRoot) + { + IReadOnlyList transitions = + ComputeTransitions(latestSnapshot, next); + ApplySnapshotUpdate(latestSnapshot, next, truncated); + lastSnapshotTruncated = truncated; + retainedCount = latestSnapshot.Count; + return transitions; + } + } + /// /// Decides whether a fetch that came back holding /// records hit the cap.