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:
@@ -0,0 +1,85 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
|
||||
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
|
||||
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Decision order (first match wins):
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
|
||||
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
|
||||
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
|
||||
/// is created for a phantom id.</description></item>
|
||||
/// <item><description>Session carries no tags → allow only when
|
||||
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
|
||||
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
|
||||
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
|
||||
/// (ordinal-ignore-case).</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
|
||||
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
|
||||
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
|
||||
/// is an empty-grant Viewer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
|
||||
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
|
||||
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
|
||||
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
|
||||
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
|
||||
/// while the subscription lives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionManager">Registry the session id is resolved against.</param>
|
||||
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
|
||||
public sealed class DashboardSessionAcl(
|
||||
ISessionManager sessionManager,
|
||||
IOptions<GatewayOptions> options) : IDashboardSessionAcl
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
|
||||
{
|
||||
if (principal is null || string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.Tags.Count == 0)
|
||||
{
|
||||
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
|
||||
}
|
||||
|
||||
// Session.Tags is an ordinal-ignore-case set, so the containment test carries the
|
||||
// comparison; scanning the claims (rather than materializing the grant) keeps this
|
||||
// allocation-free beyond the claim enumerator.
|
||||
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
|
||||
{
|
||||
if (session.Tags.Contains(tagClaim.Value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user