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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SnapshotTruncated
|
||||
{
|
||||
get { lock (_sync) { return _snapshotTruncated; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ActiveAlarmSnapshot> 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<ActiveAlarmSnapshot> snapshots, bool snapshotTruncated)
|
||||
{
|
||||
Dictionary<string, ActiveAlarmSnapshot> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ public interface IGatewayAlarmService
|
||||
/// <summary>A point-in-time copy of the current active-alarm set.</summary>
|
||||
IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the worker's most recent reconcile fetch hit the provider's
|
||||
/// per-fetch cap, so <see cref="CurrentAlarms"/> 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
|
||||
/// <see cref="State"/> and <see cref="LastError"/>. Cleared by the first
|
||||
/// reconcile whose fetch comes back under the cap.
|
||||
/// </summary>
|
||||
bool SnapshotTruncated { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to the central alarm feed. The returned stream yields one
|
||||
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user