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:
Joseph Doherty
2026-08-17 04:18:34 -04:00
parent b8b7b69ba0
commit 693a78db7d
41 changed files with 2217 additions and 309 deletions
@@ -34,6 +34,17 @@
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
}
@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
completeness of the set is in doubt, so this must not read as "alarms are broken". *@
@if (_snapshotTruncated)
{
<div class="alert alert-warning">
Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
the cap are not listed. Alarms already known stay listed rather than clearing. Raise
<code>MxGateway:Alarms:MaxAlarmsPerFetch</code> or narrow the subscription if this persists.
</div>
}
<section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
@@ -156,6 +167,7 @@
@code {
private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError;
private bool _snapshotTruncated;
private int? _workerPid;
private DateTimeOffset? _lastRefresh;
private int _unackedCount;
@@ -386,6 +398,7 @@
{
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error;
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear();
@@ -10,6 +10,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService
@inject IDashboardSessionEventSubscriber EventSubscriber
@inject IDashboardSessionAcl SessionAcl
<PageTitle>Dashboard Session</PageTitle>
@@ -114,7 +115,11 @@ else
<span>@(_eventsConnected ? "live" : "offline")</span>
</span>
</div>
@if (_recentEvents.Count == 0)
@if (!_eventsAuthorized)
{
<div class="empty-state">Not authorized for this session's events.</div>
}
else if (_recentEvents.Count == 0)
{
<div class="empty-state">
Waiting for events. The dashboard subscribes to this session's events directly, so
@@ -175,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<MxEvent> _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.
@@ -57,7 +57,14 @@ public sealed record DashboardActiveAlarm(
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
/// <param name="SnapshotTruncated">
/// True when the provider fetch behind <paramref name="Alarms"/> hit its per-fetch cap, so the
/// list may be missing active alarms. Distinct from <paramref name="Error"/>: the query
/// succeeded and every row shown is real — only the set's completeness is in doubt, which the
/// page states as a caveat rather than a failure.
/// </param>
public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error,
int? WorkerProcessId);
int? WorkerProcessId,
bool SnapshotTruncated = false);
@@ -36,6 +36,16 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary>
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
/// <c>MxGateway:Dashboard:GroupToTag</c>; read by <see cref="IDashboardSessionAcl"/>. A
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
/// default. Visibility only — it never grants data access.
/// </summary>
public const string DashboardTagClaimType = "zb:dashboardtag";
/// <summary>
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
@@ -1,7 +1,9 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -17,10 +19,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
/// </param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
IGroupRoleMapper<string> roleMapper,
IOptions<GatewayOptions> options,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -70,7 +77,8 @@ public sealed class DashboardAuthenticator(
ldapResult.Username,
ldapResult.DisplayName,
ldapResult.Groups,
roles));
roles,
options.Value.Dashboard.GroupToTag));
}
/// <summary>
@@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator(
/// is role-based), so the shape change is non-breaking for dashboard consumers.
/// </param>
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
/// <param name="groupToTag">
/// The configured <c>Dashboard:GroupToTag</c> map. The tags it grants are stamped as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims so a
/// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
/// session-details page's in-process subscribe seam reads exactly these claims.
/// </param>
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable<string> groups,
IEnumerable<string> roles)
IEnumerable<string> roles,
IReadOnlyDictionary<string, string[]> groupToTag)
{
// Materialized because the groups are read twice below (group claims and tag mapping) and
// the source is only guaranteed to be enumerable.
string[] groupNames = groups as string[] ?? [.. groups];
List<Claim> claims =
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
@@ -120,9 +139,14 @@ public sealed class DashboardAuthenticator(
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
claims.AddRange(groups.Select(group => new Claim(
claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
// and never logged: only the decision, never the tag values, reaches diagnostics.
claims.AddRange(DashboardGroupTagMapping
.MapGroupsToTags(groupNames, groupToTag)
.Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));
ClaimsIdentity claimsIdentity = new(
claims,
@@ -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
@@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
// Singleton and stateless: it reads the session registry and options per call, and is
// consulted from both subscribe seams (the EventsHub join and the session-details page's
// in-process subscription).
services.AddSingleton<IDashboardSessionAcl, DashboardSessionAcl>();
// Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus
// the /hubs/token endpoint: server-rendered pages read the in-process feeds, so
// nothing in this process builds a hub connection or needs a token for one.
@@ -0,0 +1,85 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
/// </summary>
/// <remarks>
/// <para>
/// Decision order (first match wins):
/// </para>
/// <list type="number">
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
/// is created for a phantom id.</description></item>
/// <item><description>Session carries no tags → allow only when
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
/// is an empty-grant Viewer.
/// </para>
/// <para>
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
/// while the subscription lives.
/// </para>
/// </remarks>
/// <param name="sessionManager">Registry the session id is resolved against.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
public sealed class DashboardSessionAcl(
ISessionManager sessionManager,
IOptions<GatewayOptions> options) : IDashboardSessionAcl
{
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
{
if (principal is null || string.IsNullOrWhiteSpace(sessionId))
{
return false;
}
if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
{
return true;
}
if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
{
return false;
}
if (session.Tags.Count == 0)
{
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
}
// Session.Tags is an ordinal-ignore-case set, so the containment test carries the
// comparison; scanning the claims (rather than materializing the grant) keeps this
// allocation-free beyond the claim enumerator.
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
{
if (session.Tags.Contains(tagClaim.Value))
{
return true;
}
}
return false;
}
}
@@ -2,14 +2,16 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Mints and validates short-lived bearer tokens for SignalR hub connections.
/// The token is a data-protected JSON payload containing the user's name and
/// role claims. Validity is enforced by the data-protection time-limited
/// protector; no separate signing keys are configured.
/// The token is a data-protected JSON payload containing the user's name, role
/// claims, and granted dashboard visibility tags. Validity is enforced by the
/// data-protection time-limited protector; no separate signing keys are configured.
/// </summary>
/// <remarks>
/// This service is registered as a singleton in
@@ -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<GatewayOptions> _options;
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
/// <param name="dataProtection">The data protection provider for token encryption.</param>
public HubTokenService(IDataProtectionProvider dataProtection)
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map used to resolve the
/// caller's granted visibility tags at mint time.
/// </param>
public HubTokenService(IDataProtectionProvider dataProtection, IOptions<GatewayOptions> options)
{
ArgumentNullException.ThrowIfNull(dataProtection);
ArgumentNullException.ThrowIfNull(options);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
_options = options;
}
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
/// <summary>Issues a bearer token carrying the user's identity, roles, and granted tags.</summary>
/// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
@@ -65,10 +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<string> grantedTags = DashboardGroupTagMapping.MapGroupsToTags(
user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType).Select(c => c.Value),
_options.Value.Dashboard.GroupToTag);
HubTokenPayload payload = new(
user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)],
[.. grantedTags]);
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
}
@@ -107,6 +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);
}
@@ -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.
@@ -0,0 +1,33 @@
using System.Security.Claims;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Decides whether a dashboard principal may observe one session's mirrored
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
/// session-details page.
/// </summary>
/// <remarks>
/// The dashboard authenticates LDAP users while sessions are owned by API keys —
/// two disjoint identity domains — so the bridge is the session <em>tag</em>: a
/// session inherits its owning key's tags, and a dashboard group grants tags via
/// <c>MxGateway:Dashboard:GroupToTag</c>. See
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c>.
/// </remarks>
public interface IDashboardSessionAcl
{
/// <summary>
/// Returns whether <paramref name="principal"/> may observe the events of the
/// session identified by <paramref name="sessionId"/>.
/// </summary>
/// <param name="principal">
/// The dashboard caller. <see langword="null"/>, unauthenticated, or claim-less
/// principals (including the anonymous-localhost path) are treated as Viewers
/// holding an empty tag grant.
/// </param>
/// <param name="sessionId">Session id the caller wants to observe.</param>
/// <returns><see langword="true"/> when the caller may observe the session; otherwise <see langword="false"/>.</returns>
bool CanViewSession(ClaimsPrincipal? principal, string sessionId);
}