feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
This commit is contained in:
@@ -15,8 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// registry to skip all mirror work for sessions nobody is watching.
|
||||
/// </remarks>
|
||||
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
|
||||
/// <param name="sessionAcl">Per-session visibility gate consulted before any group join.</param>
|
||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
||||
public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
|
||||
public sealed class EventsHub(
|
||||
EventsHubViewerRegistry viewerRegistry,
|
||||
IDashboardSessionAcl sessionAcl) : Hub
|
||||
{
|
||||
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
|
||||
public const string EventMessage = "MxEvent";
|
||||
@@ -33,27 +36,21 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
|
||||
/// client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In v1 the hub-level <see cref="AuthorizeAttribute"/>
|
||||
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
|
||||
/// the dashboard roles (Admin or Viewer); both roles may subscribe to
|
||||
/// any session id they choose. This is acceptable today because (a) the
|
||||
/// dashboard's per-session views show non-secret session metadata that
|
||||
/// any authenticated dashboard user can already see, and (b) tag values
|
||||
/// are stripped from the mirrored events by
|
||||
/// <see cref="DashboardEventBroadcaster"/> when
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), so the
|
||||
/// most sensitive payload cannot leak through this seam regardless of the
|
||||
/// still-missing ACL. The per-session ACL that gates the gRPC
|
||||
/// <c>StreamEvents</c> RPC is intentionally not yet mirrored here.
|
||||
/// TODO(per-session-acl): tracked as remediation roadmap item 12
|
||||
/// (SEC-25). Once a role/scope is introduced that scopes a Viewer to a
|
||||
/// specific session or tenant, add a session-access check at this seam —
|
||||
/// either inline (consult the per-user allowed-session set on
|
||||
/// <c>Context.User</c> claims / <c>Context.Items</c>) or via a dedicated
|
||||
/// authorization policy applied to the hub method itself.
|
||||
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
|
||||
/// only checks that the caller carries one of the dashboard roles, which by
|
||||
/// itself would let any Viewer subscribe to any session id they name. The
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15): 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 <see cref="EventsHubViewerRegistry"/>, so the mirror
|
||||
/// stays off for a session nobody is legitimately watching. The same ACL
|
||||
/// gates the in-process seam used by the session-details page, so neither
|
||||
/// path is the weaker one.
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
||||
/// <returns>A task representing the subscription operation.</returns>
|
||||
/// <exception cref="HubException">The caller may not observe this session.</exception>
|
||||
public Task SubscribeSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
@@ -61,6 +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.
|
||||
|
||||
Reference in New Issue
Block a user