2ef7b93761
Committed together because the two changes share the same doc paragraphs (the ACL section of GatewayDashboardDesign.md, the GroupToTag / AllowAnonymousLocalhost rows of GatewayConfiguration.md, and CLAUDE.md's authentication paragraph), and splitting them would leave one of the two describing behaviour that does not yet exist. SEC-41 — tag grants no longer trusted from login-stamped claims --------------------------------------------------------------- DashboardSessionAcl read the zb:dashboardtag claims stamped into the cookie at login. GatewayOptions binds once at startup, so applying a GroupToTag edit needs a restart — but the restart does not clear the auth cookie (data-protection keys persist), so a browser returning afterwards re-authenticated with tag claims resolved under the OLD map. A revoked grant therefore survived on the cookie seam for the sliding 8-hour idle life, renewable indefinitely by activity, while the hub-token seam had no such gap because its mint re-resolves. CanViewSession now re-resolves the grant on every subscribe from the caller's mxgateway:ldap_group claims through DashboardGroupTagMapping.MapGroupsToTags against the currently bound map — the same one call HubTokenService.Issue makes. Fallback (documented on the class and in the design doc): a principal carrying NO group claims falls back to its stamped tag claims. That arm exists for the hub-token principal — HubTokenService.Validate rehydrates roles and tags but not groups, so re-resolution already happened at mint, one lifetime ago at worst. It cannot re-open the cookie hole: DashboardAuthenticator stamps group and tag claims from one group list, so a cookie principal holding a tag claim always holds the groups it came from, and a login yielding no groups yields no tags. A principal with neither kind of claim stays an empty-grant Viewer. SEC-40 — the alarm feed requires a real authenticated dashboard role -------------------------------------------------------------------- AlarmsHub sat behind HubClientsPolicy, which the read-only environment bypasses satisfy, so an anonymous-localhost caller or a Mode=Disabled deployment received galaxy-wide alarm metadata — alarm reference (a full tag path), state, severity, category, area, acknowledging operator — for the whole subscription expression. Decided: tighten the gate, keep the scope. New AuthenticatedDashboardRoleRequirement + its handler, and a MxGateway.Dashboard.Alarms policy built from it. Because DashboardAuthorizationHandler is typed to DashboardAuthorizationRequirement, it is never invoked for the new type, so neither Authentication:Mode=Disabled nor anonymous localhost can satisfy the policy — the same structural device the mounted Secrets UI already relies on. The policy keeps both schemes so external hub clients can still authenticate with a bearer token, and DisableLogin auto-login is unaffected (it mints a genuinely authenticated both-roles principal). The /alarms page carries the same policy, not just the hub: it reads the same central monitor in process, so gating only the hub would have left the disclosure one navigation away. The side rail hides the Alarms link for callers the policy denies, matching the Secrets link precedent. AlarmsHubPublisher is unchanged and stays unfiltered by design — audience is decided at hub admission, and the monitor carries no per-session provenance to narrow by. Alarm metadata deliberately remains galaxy-wide for authenticated Viewers: an AlarmFeedMessage has no session id or owning key for the SEC-25 tag intersection to work against, and equating a visibility tag with an alarm-reference prefix would turn GroupToTag into the data-access constraint it explicitly is not. Tests ----- DashboardSessionAclTests: the SEC-41 regression — a cookie-style principal whose stale tag claim is no longer granted by any of its groups under the current map is denied; plus the allowing direction (including with no stamped tag at all, which proves the decision comes from the groups), the ignore-case corner, group claims that map to nothing, and the hub-token fallback asserted against an EMPTY map so dropping the fallback would fail loudly. AlarmFeedAuthorizationTests (new): both handlers run together in the most permissive environment (Mode=Disabled + loopback + AllowAnonymousLocalhost) do not satisfy the alarm requirement, with the bypassable requirement as the control so the negative cannot pass because the bypass was misconfigured; authenticated Viewer/Admin do satisfy it; an unauthenticated role claim does not; the registered policy carries the non-bypassable requirement and both schemes; and both readers — the hub type's [Authorize] and the /alarms endpoint metadata — carry it. SecretsNavRenderTests: registers the new policy (an unregistered policy name throws rather than denying in an AuthorizeView) and gains the Alarms-link negative/control pair. Verified: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx (zero warnings); filtered run of the touched classes — 92 passed, 0 failed.
1039 lines
55 KiB
Markdown
1039 lines
55 KiB
Markdown
# Gateway Dashboard Detailed Design
|
|
|
|
## Purpose
|
|
|
|
The gateway should host a basic web dashboard for operators and developers. The
|
|
dashboard is diagnostic and operational visibility only for v1. It should show
|
|
gateway health, active MXAccess worker instances, session state, and basic
|
|
statistics in real time.
|
|
|
|
## Technology Choice
|
|
|
|
Decision: Blazor Server with Bootstrap CSS/JS.
|
|
|
|
Allowed UI stack:
|
|
|
|
- ASP.NET Core Blazor Server,
|
|
- Bootstrap CSS,
|
|
- Bootstrap JavaScript,
|
|
- small local CSS for layout and status styling,
|
|
- built-in Blazor components.
|
|
|
|
Not allowed for v1:
|
|
|
|
- MudBlazor,
|
|
- Radzen,
|
|
- Syncfusion,
|
|
- Telerik,
|
|
- other Blazor UI component libraries,
|
|
- client-side SPA framework replacement.
|
|
|
|
Rationale: Blazor Server keeps the dashboard in the gateway process, avoids a
|
|
separate frontend build, and gives real-time UI updates through the Blazor
|
|
SignalR circuit. Bootstrap is sufficient for a basic dashboard.
|
|
|
|
## Hosting Model
|
|
|
|
The dashboard is hosted by `ZB.MOM.WW.MxGateway.Server` alongside the gRPC API. When
|
|
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` mounts the
|
|
Blazor Server app at the host root and registers the login, logout, denied,
|
|
SignalR hub, and hub-token endpoints beside it. When dashboard hosting is
|
|
disabled, none of those routes are mapped — the same listener still serves
|
|
gRPC.
|
|
|
|
Endpoint layout:
|
|
|
|
```text
|
|
/
|
|
/sessions
|
|
/sessions/{sessionId}
|
|
/workers
|
|
/events
|
|
/alarms
|
|
/galaxy
|
|
/browse
|
|
/apikeys
|
|
/settings
|
|
/login (POST also)
|
|
/logout (POST)
|
|
/denied
|
|
/hubs/snapshot
|
|
/hubs/alarms
|
|
/hubs/events
|
|
/hubs/token
|
|
/_blazor
|
|
```
|
|
|
|
The `/galaxy` page surfaces the Galaxy Repository browse summary
|
|
(deployed object hierarchy size, last deploy timestamp, attribute totals,
|
|
template usage, and connectivity sync info). The summary is fed by
|
|
`GalaxySummaryCache`, which is refreshed off the request path by
|
|
`GalaxySummaryRefreshService` on the
|
|
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` cadence so the dashboard
|
|
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
|
|
the underlying gRPC service.
|
|
|
|
## High-Level Components
|
|
|
|
```text
|
|
ZB.MOM.WW.MxGateway.Server
|
|
Dashboard/
|
|
Components/
|
|
App.razor
|
|
Routes.razor
|
|
DashboardPageBase.cs
|
|
DashboardDisplay.cs
|
|
Layout/
|
|
DashboardLayout.razor
|
|
Pages/
|
|
DashboardHome.razor
|
|
SessionsPage.razor
|
|
SessionDetailsPage.razor
|
|
WorkersPage.razor
|
|
EventsPage.razor
|
|
ApiKeysPage.razor
|
|
SettingsPage.razor
|
|
Shared/
|
|
MetricCard.razor
|
|
StatusBadge.razor
|
|
FaultList.razor
|
|
DashboardSnapshotService.cs
|
|
DashboardSnapshotFeed.cs
|
|
DashboardAuthorizationHandler.cs
|
|
DashboardAuthenticator.cs
|
|
DashboardApiKeyAuthorization.cs
|
|
DashboardApiKeyManagementService.cs
|
|
DashboardApiKeySummary.cs
|
|
DashboardSnapshot.cs
|
|
DashboardSessionSummary.cs
|
|
DashboardWorkerSummary.cs
|
|
DashboardMetricSummary.cs
|
|
```
|
|
|
|
The dashboard exposes three named SignalR hubs in addition to Blazor Server's
|
|
internal circuit. The hubs are the **remote** surface: they publish snapshot,
|
|
alarm, and per-session event updates to clients outside the gateway process.
|
|
Server-rendered Blazor pages do not use them. A page runs inside this process,
|
|
so it consumes the producing services directly through in-process seams —
|
|
`IDashboardSnapshotFeed`, `IDashboardSessionEventSubscriber`, and
|
|
`IGatewayAlarmService` — instead of opening a loopback WebSocket back into its
|
|
own heap. `DashboardHubConnectionFactory`, the helper a circuit used to open
|
|
those connections, has been deleted along with the `Microsoft.AspNetCore.SignalR.Client`
|
|
package reference: nothing in this process dials a hub, and a registered-but-unused
|
|
client factory only invites a page to reintroduce the loopback. Remote consumers
|
|
build their own connection; the hubs, `/hubs/token`, and `HubTokenService` remain
|
|
for them.
|
|
|
|
## Dashboard Data Source
|
|
|
|
The dashboard should consume read-only snapshots from gateway services:
|
|
|
|
- `SessionRegistry`,
|
|
- `SessionManager`,
|
|
- `WorkerClient`,
|
|
- `GatewayMetrics`,
|
|
- health checks,
|
|
- structured fault/event counters.
|
|
|
|
Do not let Razor components directly mutate gateway session or worker objects.
|
|
Create a small read-only dashboard service that projects gateway state into
|
|
plain DTOs.
|
|
|
|
`GatewayMetrics.GetSnapshot()` is the metrics input for the first dashboard
|
|
projection. It carries current session and worker gauges, command and event
|
|
counters, queue depth, and fault totals. The dashboard reads that snapshot
|
|
instead of reading raw `Meter` instruments because exporter configuration is an
|
|
operations concern, not a UI dependency.
|
|
|
|
Suggested service:
|
|
|
|
```csharp
|
|
public interface IDashboardSnapshotService
|
|
{
|
|
DashboardSnapshot GetSnapshot();
|
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
|
CancellationToken cancellationToken);
|
|
}
|
|
```
|
|
|
|
Snapshot updates can be driven by:
|
|
|
|
- periodic timer, default every 1 second,
|
|
- session lifecycle notifications,
|
|
- worker heartbeat updates,
|
|
- event counter updates,
|
|
- fault notifications.
|
|
|
|
Use immutable snapshot DTOs so Razor components can render without locking
|
|
gateway internals.
|
|
|
|
## Realtime Updates
|
|
|
|
Realtime data reaches two audiences over two seams:
|
|
|
|
- **in-process**, for the server-rendered Blazor pages, which run inside the
|
|
gateway process and read the producing services directly;
|
|
- **SignalR hubs**, for clients outside the process.
|
|
|
|
Pages originally took the hub path too, which put a loopback WebSocket, a
|
|
hub-token mint, and a serialize/deserialize round trip between a Blazor component
|
|
and an object already in its own heap. The in-process seams remove that hop. The
|
|
hubs stay for the audience that genuinely needs a wire.
|
|
|
|
### In-process page feeds
|
|
|
|
| Page | Seam | Producer |
|
|
|---|---|---|
|
|
| every page deriving from `DashboardPageBase` | `IDashboardSnapshotFeed.WatchAsync` | `DashboardSnapshotFeed` (singleton) multicasting one `IDashboardSnapshotService.WatchSnapshotsAsync` enumeration |
|
|
| `SessionDetailsPage` | `IDashboardSessionEventSubscriber.Subscribe(sessionId)` | `DashboardEventBroadcaster` — the same singleton the session mirror publishes to, registered behind both interfaces |
|
|
| `AlarmsPage` | `IGatewayAlarmService.StreamAsync` | the central alarm monitor, **gateway-status frames only** — `provider_status` for the badge and `snapshot_status` for the truncated-snapshot banner; the alarm rows still come from the 3 s `QueryAlarmsAsync` poll |
|
|
|
|
The snapshot feed multicasts rather than handing each page its own enumeration:
|
|
`WatchSnapshotsAsync` is not multicast on its own — each enumeration owns a timer
|
|
and builds its own snapshot per tick — so a subscription per page would multiply
|
|
the snapshot cost by the number of open pages. Each subscriber reads through a
|
|
capacity-1 drop-oldest channel, so a circuit that renders slowly skips snapshots
|
|
instead of buffering without bound or stalling the pump.
|
|
|
|
`DashboardPageBase` seeds `Snapshot` synchronously from
|
|
`IDashboardSnapshotService.GetSnapshot()` in `OnInitializedAsync` so the first
|
|
render is non-empty, then calls `InvokeAsync(StateHasChanged)` for every snapshot
|
|
the feed yields.
|
|
|
|
A subscription is not a lifetime, so the watch is a loop, not a single enumeration:
|
|
the feed detaches its subscribers whenever a pump's source faults or completes, and
|
|
a page that treated that as terminal would sit on its last snapshot until the
|
|
operator navigated. On any end other than its own cancellation the page waits one
|
|
second — honouring its own token, so teardown is not delayed — and resubscribes. The
|
|
fault is logged at Warning once per fault *transition*, not once per retry: a feed
|
|
that is down stays down for many iterations, and one line per second per open page
|
|
is noise. The last rendered snapshot stays on screen throughout, and the next page
|
|
load still seeds from `IDashboardSnapshotService.GetSnapshot()`.
|
|
|
|
That resubscribe is also the feed's primary recovery path, not just the page's:
|
|
only a subscriber that finds no live generation starts a pump, so a page coming back
|
|
is what restarts the enumeration. `Reset`'s belt-and-braces restart — if subscribers
|
|
of *other* generations are still attached when a generation dies, it starts a fresh
|
|
pump for them and re-tags them — remains the backstop for the case where no
|
|
subscriber is left to drive recovery, but it is no longer the only thing standing
|
|
between a faulted feed and a permanently stale page.
|
|
|
|
On dispose the page cancels the watch and waits at most **5 seconds**
|
|
for the loop to drain, logging a warning on timeout. The bound is deliberate: the
|
|
loop marshals renders through the renderer's dispatcher and disposal can run on
|
|
that same dispatcher, so an unconditional wait would hang on a wedged dispatcher.
|
|
The accepted cost is that an abandoned loop still holds its feed subscription — the
|
|
feed's idle gate stays open until it unwinds — and the warning is the operator's
|
|
only signal that a circuit teardown wedged.
|
|
|
|
`SessionDetailsPage` subscribes for the current session id and renders the most
|
|
recent N events (default 50) in a "Recent events" table. Its pump drains everything
|
|
queued and renders once per batch rather than once per event, and it re-checks
|
|
**inside the renderer dispatch** — where the subscription field is written, making
|
|
the check an unsynchronized read of dispatcher-owned state — that the batch's
|
|
subscription is still the live one. A batch read before a session switch would
|
|
otherwise render the previous session's events under the new session's heading.
|
|
Detaching cancels the pump, disposes the subscription (which releases the viewer
|
|
registration and completes the channel, so the pump has an exit even if
|
|
cancellation is missed), then drains under its own timeout.
|
|
|
|
The page's live/offline pill tracks that pump rather than a connection: it is set
|
|
live on attach and cleared when the pump exits, through the same dispatcher-owned
|
|
identity check the render batch uses, so a stale pump cannot darken the pill of the
|
|
subscription that replaced it. Because detach clears the subscription field before
|
|
cancelling, a detach-driven exit leaves the pill to the incoming subscription; what
|
|
the pill therefore reports is the case it exists for — the channel completing under
|
|
a page that is still watching.
|
|
|
|
`AlarmsPage` owns two loops of its own (the 3 s alarm poll and the status feed that
|
|
drives the provider badge and the truncated-snapshot banner) and bounds their drain
|
|
at 5 seconds on dispose, for the same reason
|
|
`DashboardPageBase` bounds its watch drain: both loops render through the renderer's
|
|
dispatcher, and disposal can run on it. The two are drained concurrently, so the
|
|
bound on disposal is 5 seconds in total rather than per loop — a wedged dispatcher
|
|
blocks both loops at once, and draining them in sequence would time out twice.
|
|
|
|
Both loops handle faults *inside* the loop and retry: only cancellation ends them.
|
|
A failing alarm query or a render that faults on one tick leaves the page's last
|
|
rows in place and is retried on the next tick — a fault that stopped polling for the
|
|
life of the page would leave stale rows behind with nothing to say so. The poll loop
|
|
also surfaces the fault in the same `Alarm query failed` banner that a query error
|
|
uses, and clears it on the first tick that succeeds; that dispatch is itself
|
|
best-effort, because the fault being reported may be an `InvokeAsync` against a
|
|
disposed renderer, in which case reporting fails the same way and the loop simply
|
|
exits on its next cancellation check. Neither loop method can fault, which is what
|
|
the bounded drain relies on — it must never observe a faulted loop task, since that
|
|
would surface out of `DisposeAsync` and skip the `CancellationTokenSource` dispose.
|
|
|
|
### SignalR hubs (remote clients)
|
|
|
|
Updates for out-of-process clients flow over three SignalR hubs, all guarded by the
|
|
`MxGateway.Dashboard.HubClients` policy (cookie OR `MxGateway.Dashboard.HubToken`
|
|
bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
|
|
|
|
| Hub | Path | Producer | Payload | Routing |
|
|
|---|---|---|---|---|
|
|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
|
|
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition` / `provider_status` / `snapshot_status`) | Guarded by `MxGateway.Dashboard.Alarms`, not `HubClients`: a real authenticated dashboard role is required, so the `Authentication:Mode=Disabled` and anonymous-localhost bypasses are excluded (SEC-40). Connected clients auto-join `__alarms__`; all admitted clients receive every message — the feed is galaxy-wide and carries no session provenance, so no per-caller scoping applies (see "Alarm-feed visibility"). Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
|
|
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. `SubscribeSession` is gated by `IDashboardSessionAcl` (SEC-25 / TST-15): a denied caller gets a `HubException`, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a *permitted* subscriber sees. |
|
|
|
|
### Default cadences
|
|
|
|
Both seams consume the same producing services, so they share these cadences:
|
|
|
|
- snapshot service produces one snapshot per
|
|
`MxGateway:Dashboard:SnapshotIntervalMilliseconds` (default 1s);
|
|
- alarm publisher emits on each transition observed by the central monitor;
|
|
- event publisher emits per event fanned by the session's `SessionEventDistributor`
|
|
to its internal dashboard-mirror subscriber (independent of any gRPC `StreamEvents`);
|
|
- the alarms page's status feed resubscribes one second after its
|
|
`IGatewayAlarmService.StreamAsync` enumeration ends — the monitor completes a
|
|
subscriber's stream only when that subscriber has fallen behind, which resubscribing
|
|
recovers (a monitor restart keeps the channel and pushes cleared status frames through
|
|
it) — and the badge and banner hold their last values in between. That feed carries both gateway-status frames: `provider_status` drives the
|
|
badge, and `snapshot_status` drives the truncated-snapshot banner, so the caveat
|
|
appears on the monitor's verdict change rather than up to three seconds later. Every
|
|
subscriber is primed with a `snapshot_status` frame at open, so a page attaching
|
|
mid-truncation needs no priming logic of its own. The page's alarm rows are
|
|
independent of that stream and refresh on the 3 s poll, which also re-asserts the
|
|
truncation verdict as its reconcile baseline — both sources read the same monitor
|
|
verdict, and neither is synthesized page-side.
|
|
|
|
### Idle gating and snapshot cost
|
|
|
|
A snapshot is not free: each one takes a session-registry snapshot and sorts it,
|
|
copies the metrics dictionaries under the global metrics lock, and projects
|
|
sessions, workers, faults, and the Galaxy summary. Without gating that work ran
|
|
once a second for the life of the process even when nothing was watching.
|
|
|
|
Gating is two-tier, because the two seams have independent audiences and each must
|
|
be able to reach zero on its own.
|
|
|
|
**Hub tier.** `DashboardSnapshotHub` counts live connections into the singleton
|
|
`DashboardSnapshotHubConnectionCounter` (`OnConnectedAsync` / `OnDisconnectedAsync`,
|
|
clamped at zero). `DashboardSnapshotPublisher` reads that count before advancing the
|
|
snapshot enumerator: while it is zero the publisher does not call `MoveNextAsync` at
|
|
all, so the producing iterator stays suspended at its `yield` and builds nothing —
|
|
the gate removes the snapshot *build*, not just the broadcast. The publisher
|
|
re-checks once a second while idle, so the first client to connect resumes the tick
|
|
within roughly one snapshot interval, and `OnConnectedAsync` pushes the current
|
|
snapshot to that connection immediately. Now that no in-repo page connects to the
|
|
hub, this tier stays idle unless a remote client connects.
|
|
|
|
**In-process tier.** `DashboardSnapshotFeed` gates its own pump on its subscriber
|
|
list: the first subscriber starts the pump, the last one leaving cancels it and
|
|
awaits it, so a gateway with no page open runs no timer and builds no snapshots on
|
|
this seam either. Successive pumps are chained through the previous pump's task, so
|
|
an unsubscribe immediately followed by a resubscribe restarts a fresh pump without
|
|
ever running two enumerations at once. A page does not wait for the pump's first
|
|
tick — `DashboardPageBase` seeds its first render synchronously from
|
|
`IDashboardSnapshotService.GetSnapshot()`.
|
|
|
|
That gate is generation-scoped rather than a plain subscriber count. Each pump owns
|
|
a generation, each subscriber is tagged with the generation it joined under, a pump
|
|
ends its generation the instant its source faults or completes — before the possibly
|
|
slow enumerator disposal — and a dying pump only ever detaches its own generation's
|
|
subscribers. Two races motivate the extra state:
|
|
|
|
- a subscriber arriving mid-teardown must start a fresh generation rather than
|
|
attach to a pump that is about to detach everybody and leave nobody watching;
|
|
- an unsubscribe must compare its own generation against the live one before it
|
|
cancels anything. Subscribers of an ending generation linger in the list until
|
|
that pump's reset runs, so counting the whole list would let them hold the idle
|
|
gate open, and cancelling on their behalf would stop a live pump that other
|
|
viewers depend on.
|
|
|
|
Two per-tick costs inside the snapshot itself are bounded independently of either gate:
|
|
|
|
- the effective configuration (`EffectiveGatewayConfiguration`) is built once and
|
|
cached. It is a projection of `IOptions<GatewayOptions>`, which the gateway binds
|
|
at startup and never reloads, so rebuilding the whole option tree every tick
|
|
produced an identical object;
|
|
- the API key summaries are refreshed at most once every 15 seconds
|
|
(`ApiKeySummaryRefreshInterval`) instead of on every tick. The list is a SQLite
|
|
read whose content changes only when an operator creates, rotates, or revokes a
|
|
key, so a key change reaches the dashboard within that interval. Only a
|
|
*successful* refresh restarts the interval, so a failed or timed-out read is
|
|
retried on the next tick and the previous summaries stay on screen.
|
|
|
|
Avoid pushing every MXAccess data-change event into a wider broadcast group.
|
|
Events are routed strictly per session (`session:{id}` groups on the hub,
|
|
per-session subscriber lists in process); the snapshot seams continue to carry
|
|
aggregate event counters and rates.
|
|
|
|
### Mirror gating
|
|
|
|
Each session's dashboard-mirror subscriber calls
|
|
`DashboardEventBroadcaster.Publish` for every event the session produces,
|
|
independently of whether anything is watching that session. `Publish` returns
|
|
immediately when `EventsHubViewerRegistry.HasViewers(sessionId)` is false,
|
|
**before** the redaction clone. That matters because redaction is on by default
|
|
(`Dashboard:ShowTagValues` false), so the unwatched steady state — nobody on any
|
|
session-details page — previously paid a deep protobuf clone plus a send to an
|
|
empty group for every event of every session. Behaviour for a watched session is
|
|
unchanged.
|
|
|
|
The registry counts both audiences, which is what lets one gate serve both seams.
|
|
`EventsHub` mirrors its own `AddToGroup` / `RemoveFromGroup` calls into it —
|
|
SignalR does not expose group membership, so the broadcaster cannot ask whether
|
|
`session:{id}` is empty — and `OnDisconnectedAsync` releases every subscription a
|
|
dropped connection held, the only reliable signal for a browser tab that closes
|
|
without unsubscribing. An in-process subscription registers the same way under a
|
|
synthetic `inproc-`-prefixed connection id, which cannot collide with a SignalR
|
|
connection id and makes the origin obvious in a debugger; disposing it removes the
|
|
viewer and releases the synthetic connection, because that id never reconnects and
|
|
nothing else would ever release its per-connection entry. Both paths use the same
|
|
ordering — register before becoming a delivery target, deregister after ceasing to
|
|
be one — so the widest a race window opens is a redaction clone that reaches
|
|
nobody, never a dropped event that was owed to a live viewer.
|
|
|
|
Redaction happens once per event, not once per audience: with
|
|
`Dashboard:ShowTagValues` false (the default) `Publish` produces a single redacted
|
|
clone and hands that same instance to the in-process subscribers and to the hub
|
|
group. With `ShowTagValues` true there is no clone at all — the original `MxEvent`
|
|
instance is handed to both audiences — so the "one clone per event" cost holds only
|
|
in the redacting configuration, and in the value-showing one both audiences share a
|
|
reference to the session pipeline's own event object. In-process delivery runs first
|
|
and synchronously — it cannot
|
|
throw, and it must not be skipped by the guard clause around the hub send — into
|
|
per-subscriber bounded drop-oldest channels, so a page that falls behind loses its
|
|
oldest queued events rather than blocking the session's event pipeline.
|
|
|
|
The mirror subscriber itself is still registered on the `SessionEventDistributor`
|
|
for the session's whole lifetime; only the per-event work is gated. Starting and
|
|
stopping the mirror lease lazily with the first and last viewer was considered
|
|
and deliberately not done — it entangles the dashboard with distributor
|
|
subscribe/unsubscribe lifetime (and with the replay/sequence bookkeeping that
|
|
attaching a subscriber mid-stream implies) for no additional saving beyond the
|
|
clone and send this gate already removes.
|
|
|
|
## Pages
|
|
|
|
### Dashboard home
|
|
|
|
Show top-level status:
|
|
|
|
- gateway status,
|
|
- gateway version,
|
|
- uptime,
|
|
- open sessions,
|
|
- workers running,
|
|
- sessions faulted,
|
|
- command rate,
|
|
- command failure count,
|
|
- event rate,
|
|
- event queue depth,
|
|
- worker restart/kill count.
|
|
|
|
Use Bootstrap cards for individual metric summaries. Keep the layout compact
|
|
and operational.
|
|
|
|
### Sessions page
|
|
|
|
Show active and recent sessions in a table:
|
|
|
|
- session id,
|
|
- client identity or API key display name,
|
|
- state,
|
|
- backend,
|
|
- worker process id,
|
|
- open time,
|
|
- last client activity,
|
|
- last worker heartbeat,
|
|
- active event subscribers,
|
|
- pending commands,
|
|
- event queue depth,
|
|
- last fault summary.
|
|
|
|
Rows should link to session details.
|
|
|
|
### Session details page
|
|
|
|
Show:
|
|
|
|
- session metadata,
|
|
- worker metadata,
|
|
- command counters by method,
|
|
- event counters by family,
|
|
- active server handles and item counts if gateway shadow state has them,
|
|
- latest faults,
|
|
- last heartbeat payload,
|
|
- admin Close session / Kill worker controls (Admin role only).
|
|
|
|
The Sessions list, the Workers list, and this details page all render the same
|
|
admin controls when the signed-in principal carries the `Admin` role; viewers
|
|
and the localhost-anonymous bypass see no action affordances and the server
|
|
re-checks the role on every invocation. Every destructive admin action is
|
|
gated by a confirmation dialog before it reaches `ISessionManager`.
|
|
|
|
- **Close session** routes through `ISessionManager.CloseSessionAsync`: the
|
|
worker is asked to shut down gracefully and is killed only as a fallback if
|
|
shutdown fails.
|
|
- **Kill worker** routes through `ISessionManager.KillWorkerAsync`: the worker
|
|
is killed immediately with no graceful-shutdown attempt. The session is
|
|
removed from the registry and the open-session slot is released either way.
|
|
|
|
Both actions write a canonical `AuditEvent` through `IAuditWriter` into the
|
|
`audit_event` store (category `SessionAdmin`, actions `dashboard-close-session`
|
|
and `dashboard-kill-worker`) in addition to the operational `ILogger` line — so a
|
|
worker killed mid-production leaves a durable, queryable row rather than only a
|
|
rotatable log entry. The event records the LDAP actor, the session id (`Target`),
|
|
the remote address, and an outcome of `Success`, `Failure`, or `Denied`
|
|
(an unauthorized attempt is still audited as `Denied`). This mirrors the API-key
|
|
management audit path.
|
|
|
|
### Workers page
|
|
|
|
Show:
|
|
|
|
- worker process id,
|
|
- session id,
|
|
- executable path/version,
|
|
- state,
|
|
- startup duration,
|
|
- memory and CPU if available,
|
|
- last heartbeat,
|
|
- current command correlation id,
|
|
- pending command count,
|
|
- event queue depth,
|
|
- restart/kill reason if terminal.
|
|
|
|
### Events page
|
|
|
|
Show aggregate event diagnostics:
|
|
|
|
- event rate by session,
|
|
- event rate by event family,
|
|
- total events since start,
|
|
- queue overflow count,
|
|
- stream disconnect count,
|
|
- recent terminal faults.
|
|
|
|
Do not display full tag values by default. If value display is later added, make
|
|
it opt-in and redacted.
|
|
|
|
### Browse page
|
|
|
|
`/dashboard/browse` lets an operator explore the Galaxy tag hierarchy and watch
|
|
live values. The tree is built in-process by `DashboardBrowseTreeBuilder` from
|
|
`IGalaxyHierarchyCache.Current` — the same cache the Galaxy page reads — so a
|
|
render costs no gRPC call and no SQL round-trip. Each node shows its child
|
|
objects and, when expanded, its attributes with attribute name, data type
|
|
(including array dimension), and the alarm / historized flags. Galaxy SQL
|
|
carries no attribute description, so none is shown. A filter box switches the
|
|
tree to a flat list of matching attributes.
|
|
|
|
Right-clicking an attribute (or double-clicking it) adds it to the subscription
|
|
panel. The panel shows each subscribed tag's live value, MXAccess data type,
|
|
quality and source timestamp, refreshed every two seconds — but the value column
|
|
obeys `Dashboard:ShowTagValues` like every other dashboard value surface. With
|
|
the flag false (the default) `DashboardLiveDataService` hands the page
|
|
`[redacted]` in place of the formatted value of each **successfully read** tag —
|
|
a failed read keeps its `-` placeholder, since there was no value to suppress —
|
|
while data type, quality, source
|
|
timestamp and any read error are untouched, so the panel still answers "is this
|
|
tag advising and healthy" without disclosing the value. The substitution happens
|
|
at the service, not in the page: one decision point, and the value never enters
|
|
the render tree. Set the flag true to make the panel the explicit opt-in
|
|
tag-value surface it was originally designed as.
|
|
|
|
### Alarms page
|
|
|
|
`/dashboard/alarms` lists the alarms the gateway's central alarm monitor
|
|
currently holds as Active or ActiveAcked, refreshed every three seconds. It
|
|
defaults to showing unacknowledged `Active` alarms; filters add acknowledged
|
|
alarms and narrow by area, severity range, and a reference/source/description
|
|
text search. Cleared alarms are not retained — the gateway holds no
|
|
alarm-history store, so the page reflects only the live active set. The page is
|
|
read-only; it does not acknowledge alarms. A provider-status badge tracks the
|
|
central monitor's health from `IGatewayAlarmService.StreamAsync` in process — the
|
|
alarm service is already a multi-subscriber fan-out, so the badge needs no SignalR
|
|
client, no loopback socket, and no hub token — and the same subscription carries the
|
|
`snapshot_status` frame behind the truncated-snapshot banner ("Alarm snapshot may be
|
|
incomplete"), so a capped provider fetch is caveated the moment the monitor decides
|
|
it. The alarm rows themselves still come from the three-second poll, which also
|
|
re-asserts the truncation verdict as the reconcile baseline. If `MxGateway:Alarms:Enabled` is
|
|
false the central monitor never starts, and the page says so instead of showing
|
|
an empty list with no explanation.
|
|
|
|
The page carries `[Authorize(Policy = MxGateway.Dashboard.Alarms)]` rather than relying on the
|
|
`Viewer` policy `MapRazorComponents<App>()` applies to every component route (SEC-40), so it
|
|
needs a real authenticated dashboard role: the `Authentication:Mode=Disabled` and
|
|
anonymous-localhost bypasses reach every other read-only page but not this one. It is the same
|
|
policy `AlarmsHub` carries — the two read one central monitor and must not disagree about who
|
|
may see it — and the side rail hides the Alarms link for callers the policy denies. What a
|
|
permitted caller sees is galaxy-wide and not tag-scoped; see "Alarm-feed visibility".
|
|
|
|
### Live data source
|
|
|
|
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
|
through `IDashboardLiveDataService` (`DashboardLiveDataService`). For tag data
|
|
it owns one shared gateway session for the whole dashboard, opened lazily on
|
|
first use via `ISessionManager` and re-opened transparently when it faults or
|
|
its lease expires. One session means one worker process backs every dashboard
|
|
circuit; all access is serialised so the worker sees one in-flight command at a
|
|
time. Tag reads go through `GatewaySession.SubscribeBulkAsync` / `ReadBulkAsync`.
|
|
|
|
The advise set that backs those reads is capped at 256 tags (one browse page plus
|
|
headroom) and evicted least-recently-read-first. Without the cap every tag any
|
|
viewer ever inspected stayed advised on the single dashboard worker until the
|
|
session faulted, so browsing a large galaxy accreted unbounded live MXAccess
|
|
subscriptions — and the event churn they feed — on one x86 process. Reading a tag
|
|
already in the set marks it most-recently-read; subscribing past the cap unadvises
|
|
the oldest entries with `GatewaySession.UnsubscribeBulkAsync` in one batch before
|
|
the new ones are advised. Tags read in the same call are never evicted to make
|
|
room for each other. A failed unadvise does not fail the read: the tags are
|
|
dropped from tracking anyway (they re-subscribe if read again), because the
|
|
session-invalidation path already handles gateway/worker drift.
|
|
|
|
The cap is per-read, not absolute. A read may never evict a tag it is itself about
|
|
to return, so one read of more distinct tags than the cap leaves the set that
|
|
large; what the eviction pass guarantees is
|
|
|
|
> after any read, the advise set holds at most `max(256, distinct tags in that read)`
|
|
> tags.
|
|
|
|
The overshoot is not sticky: the next read that subscribes anything measures the
|
|
overflow against the oversized set and evicts the whole excess in one pass (a
|
|
300-tag set plus one new tag evicts 45 and lands back at 256). A read that
|
|
subscribes nothing new evicts nothing, but neither can it grow the set. A browse
|
|
page requests far fewer tags than the cap, so in practice the set settles at 256.
|
|
|
|
The Alarms page does **not** use the dashboard session: alarm data comes from
|
|
the gateway's always-on central monitor. `QueryAlarmsAsync` reads
|
|
`IGatewayAlarmService.CurrentAlarms` — the monitor's in-process cache — so the
|
|
dashboard sees the same active-alarm set as every `StreamAlarms` client, with
|
|
no per-dashboard alarm subscription. When `MxGateway:Alarms:Enabled` is false
|
|
the monitor never starts and the cache stays empty.
|
|
|
|
### API keys page
|
|
|
|
`/dashboard/apikeys` lists the gateway's API keys and, for authorized
|
|
operators, manages them. It reads key metadata through the same
|
|
`IApiKeyAdminStore` the `apikey` CLI uses, so the dashboard and the CLI act
|
|
on one source of truth.
|
|
|
|
The table shows one row per key:
|
|
|
|
- key id,
|
|
- status (`Active` or `Revoked`),
|
|
- display name,
|
|
- scopes,
|
|
- constraints (rendered as `unconstrained` when none are set),
|
|
- created timestamp,
|
|
- last-used timestamp.
|
|
|
|
Key secrets are never listed. Only the peppered hash is stored, and the page
|
|
never reconstructs a key. See [Authorization](./Authorization.md#constraint-enforcement)
|
|
for what each constraint means and how it is enforced on the gRPC path.
|
|
|
|
#### Management actions
|
|
|
|
Create, Rotate, Revoke, and Delete controls render only when the signed-in
|
|
user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an
|
|
authenticated principal carrying the `Admin` role claim (resolved at login
|
|
from the user's LDAP groups via `MxGateway:Dashboard:GroupToRole`). A
|
|
`Viewer` role can read the table but sees no action controls, and an
|
|
anonymous localhost session shows the same read-only view.
|
|
|
|
- **Create** opens a dialog for the key id, display name, scope checkboxes
|
|
(the `GatewayScopes` catalog), and the optional constraint fields: read and
|
|
write subtrees, read and write tag globs, browse subtrees, max write
|
|
classification, and the read-alarm-only / read-historized-only flags.
|
|
- **Rotate** issues a new secret for an existing key id and invalidates the
|
|
old one. Active keys only — rotating a revoked key would un-revoke it, so
|
|
the button is not shown on revoked rows.
|
|
- **Revoke** marks a key revoked; a revoked key cannot be un-revoked.
|
|
- **Delete** permanently removes a key row from the auth database, but only
|
|
when the key is already revoked. `IApiKeyAdminStore.DeleteAsync` rejects
|
|
active keys (returns false) so the revoke event lands in the audit log
|
|
before the row disappears. Revoked rows show a Delete button in place of
|
|
the previous "No actions" placeholder.
|
|
|
|
Every destructive action (Rotate / Revoke / Delete) is gated by the shared
|
|
`ConfirmDialog` component before reaching the service; Create uses its own
|
|
form modal as the implicit confirmation step.
|
|
|
|
Create and Rotate return the assembled `mxgw_<keyId>_<secret>` token **once**,
|
|
in a one-time banner. It is never shown again, so the operator must copy it
|
|
immediately. This mirrors the `apikey create-key` / `rotate-key` CLI.
|
|
|
|
Every management action appends an `api_key_audit` entry
|
|
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
|
|
`dashboard-delete-key`) with the key id and the caller's remote address.
|
|
Secrets and pepper values are never logged.
|
|
|
|
### Settings page
|
|
|
|
Show read-only effective configuration:
|
|
|
|
- worker executable path,
|
|
- configured timeouts,
|
|
- queue capacities,
|
|
- auth mode,
|
|
- SQLite auth database path with sensitive parts redacted if needed,
|
|
- dashboard enabled state,
|
|
- the LDAP group mappings that decide what a signed-in user may see —
|
|
`Dashboard:GroupToRole` (group → `Administrator` / `Viewer`) and
|
|
`Dashboard:GroupToTag` (group → the session tags it grants),
|
|
- `Dashboard:UntaggedSessionVisibility`, which decides who sees a session whose
|
|
owning API key carries no dashboard tags,
|
|
- protocol version.
|
|
|
|
Do not show API key secrets or pepper values.
|
|
|
|
## Authentication And Authorization
|
|
|
|
Dashboard authentication is LDAP-backed, distinct from the API-key model used
|
|
on the gRPC API. Users sign in with directory credentials; the gateway maps
|
|
their LDAP groups to one of two dashboard roles (`Administrator` or `Viewer`) and
|
|
issues a cookie carrying those role claims. `Administrator` is the canonical
|
|
role value — the exact string `GroupToRole` values and the validator accept
|
|
(`DashboardRoles.Admin`); the shorthand "Admin" used elsewhere in this document
|
|
names the same role and the `MxGateway.Dashboard.Admin` policy, not a distinct
|
|
config value.
|
|
|
|
Implemented behavior:
|
|
|
|
- a static `/login` HTML form posts username/password to the gateway;
|
|
- `DashboardAuthenticator` binds against `MxGateway:Ldap` (service-account bind,
|
|
user search, candidate bind) using `Novell.Directory.Ldap.NETStandard`;
|
|
- the user's groups arrive from the LDAP provider already stripped to short
|
|
names (the CN / first-RDN value of each `memberOf` entry) and are matched
|
|
against the short-name keys of `MxGateway:Dashboard:GroupToRole` — a full-DN
|
|
key there never matches; the resolved role(s) are emitted as
|
|
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
|
|
claims;
|
|
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
|
|
(HttpOnly, SameSite=Strict, Secure); the cookie is named
|
|
`__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
|
|
true (default) and no `MxGateway:Dashboard:CookieName` override is set,
|
|
otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
|
|
only honoured on a Secure cookie);
|
|
- a user with no matching group cannot sign in — the login screen returns the
|
|
generic credential-rejected message;
|
|
- antiforgery tokens guard the login and logout POSTs.
|
|
|
|
Four authorization policies are registered:
|
|
|
|
- `MxGateway.Dashboard.Viewer` — Razor component routes. Satisfied by Admin or
|
|
Viewer.
|
|
- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD).
|
|
- `MxGateway.Dashboard.HubClients` — the snapshot and events SignalR hubs. Accepts
|
|
the dashboard cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket
|
|
upgrades where the cookie can't be forwarded).
|
|
- `MxGateway.Dashboard.Alarms` — the alarm feed: `AlarmsHub` and the `/alarms`
|
|
page. Same two schemes as `HubClients`, but built from
|
|
`AuthenticatedDashboardRoleRequirement` instead of
|
|
`DashboardAuthorizationRequirement` (SEC-40). Because `DashboardAuthorizationHandler`
|
|
handles only the latter type, the read-only environment bypasses below cannot
|
|
satisfy this policy — see "Alarm-feed visibility".
|
|
|
|
The first three requirement types are satisfiable by the environment bypasses;
|
|
`MxGateway.Dashboard.Alarms` and the mounted Secrets UI's `secrets:manage` are not,
|
|
both for the same structural reason — the bypass handler is typed to a requirement
|
|
they do not use.
|
|
|
|
The in-process page feeds carry no authentication of their own, and need none:
|
|
`MapRazorComponents<App>()` applies `RequireAuthorization(ViewerPolicy)` to the
|
|
component endpoints, so a page can only run inside a circuit whose principal is
|
|
already an authorized Viewer. The hub-token flow below therefore covers only the
|
|
remote hub surface.
|
|
|
|
Neither policy scopes a Viewer to particular sessions — that is
|
|
`IDashboardSessionAcl`'s job (SEC-25 / TST-15), consulted by both subscribe seams:
|
|
`EventsHub.SubscribeSession` for remote hub clients and the session-details page's
|
|
in-process subscribe, which renders an inline denial instead of subscribing. The
|
|
decision is: authenticated Administrator → allow (checked before the session is
|
|
looked up, so an Administrator naming a session that just closed is still allowed);
|
|
unknown session id → deny; untagged session → `Dashboard:UntaggedSessionVisibility`
|
|
(`AdminOnly` by default); otherwise allow iff the session's tags intersect the
|
|
caller's granted tags, ordinal-ignore-case. A session's tags are inherited from its
|
|
owning API key's `--dashboard-tags` constraint and are immutable for the session's
|
|
life, so a subscribe-time decision cannot go stale while the subscription lives and
|
|
no per-event re-check is needed. A Viewer's grant comes from
|
|
`Dashboard:GroupToTag` applied to their LDAP groups. Since SEC-41 the ACL **re-resolves**
|
|
that grant on every subscribe, from the caller's `mxgateway:ldap_group` claims against the
|
|
currently bound `GroupToTag` map — the same one `DashboardGroupTagMapping.MapGroupsToTags`
|
|
call the hub-token mint makes — rather than trusting the `zb:dashboardtag` claims stamped
|
|
into the cookie at login. Those stamped claims were resolved under whatever map was bound
|
|
when the user signed in, and the auth cookie outlives the gateway restart that applies a
|
|
`GroupToTag` edit, so trusting them let a revoked grant survive the restart meant to remove
|
|
it. The stamped claims remain the **fallback** for a principal carrying no group claims,
|
|
which in practice means the hub-token principal: the token payload carries roles and tags
|
|
but no LDAP groups, and the mint already re-resolved them. That fallback cannot re-open the
|
|
cookie hole, because `DashboardAuthenticator` stamps group and tag claims from one group
|
|
list — a cookie principal holding a tag claim always holds the groups it came from, and a
|
|
login yielding no groups yields no tags. A principal carrying neither kind of claim —
|
|
anonymous localhost included — is an empty-grant Viewer. The mirror's value redaction is an
|
|
independent layer: it bounds what a permitted subscriber sees, not who may subscribe.
|
|
|
|
**Revocation bounds.** Three different revocations propagate on three different clocks, and
|
|
the difference is intrinsic to where each fact lives:
|
|
|
|
| Revocation | Takes effect |
|
|
|---|---|
|
|
| A `GroupToTag` entry removed from config | At the next subscribe after the gateway restart that rebinds options — identically on both seams, since both re-resolve against current options. `GatewayOptions` binds once at startup, so the restart is what applies the edit; nothing further is needed. |
|
|
| An LDAP group-membership revocation | When the principal is next rebuilt: at re-login for the cookie seam, bounded by the cookie's sliding 8-hour idle life (renewable by activity); within one 5-minute token lifetime for the hub-token seam. Neither seam re-queries LDAP per subscribe. |
|
|
| Either, for an **already-open** subscription | Never — the subscription lives until it drops. The ACL is a subscribe-time gate by design (session tags are immutable, so no per-event re-check is warranted); closing an in-flight stream on a grant change would need a revocation channel the dashboard does not have. Force it by closing the session, or by restarting the gateway, which tears down every circuit and hub connection. |
|
|
|
|
Two environmental bypasses still apply, both scoped to **read-only** access:
|
|
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
|
(default `true`, loopback only) each satisfy a requirement that includes the Viewer
|
|
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
|
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
|
role claim, so the "anonymous localhost is read-only" contract holds at the policy layer
|
|
rather than only in downstream service re-checks. Since SEC-40 the bypasses also do not
|
|
reach the **alarm feed** (`MxGateway.Dashboard.Alarms`) — read-only is not the same as
|
|
"may see the whole plant's alarm metadata"; see "Alarm-feed visibility" below. Remote requests (outside the `Disabled`
|
|
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
|
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
|
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
|
|
|
### Alarm-feed visibility
|
|
|
|
The alarm feed is **not** tag-scoped, and that is a deliberate, bounded decision rather than
|
|
an oversight in the SEC-25 ACL.
|
|
|
|
The gateway's alarm monitor is central and session-less: one subscription expression, one
|
|
`IGatewayAlarmService` feed, consumed by `AlarmsHubPublisher` (which re-broadcasts every
|
|
message to the `__alarms__` group) and by the `/alarms` page in process. An `AlarmFeedMessage`
|
|
carries no session id and no owning API key, so there is nothing for the per-session tag
|
|
intersection to intersect — a tag ACL cannot be applied to it without inventing provenance the
|
|
feed does not have. Deriving a filter from the caller's tags would also mean equating a
|
|
visibility tag with an alarm-reference prefix, which `GroupToTag` deliberately does not do
|
|
(tags are visibility labels, never data-access constraints).
|
|
|
|
So **alarm metadata stays galaxy-wide for every authenticated dashboard Viewer**: alarm
|
|
reference (a full tag path), state, severity, priority, category, area, and acknowledging
|
|
operator are all visible to any real dashboard role, regardless of which sessions their tag
|
|
grant covers. Alarm *values* (`current_value` / `limit_value`) are still stripped when
|
|
`Dashboard:ShowTagValues` is false (the default), on the hub seam as on the events seam.
|
|
|
|
What SEC-40 tightened is the entry gate rather than the scope. Both readers of the monitor —
|
|
`AlarmsHub` and the `/alarms` page — carry `MxGateway.Dashboard.Alarms`, whose requirement type
|
|
the environment-bypass handler does not handle. Consequences:
|
|
|
|
- `MxGateway:Authentication:Mode = Disabled` and anonymous localhost (`AllowAnonymousLocalhost`,
|
|
default `true`) **no longer receive the alarm feed at all**. They keep read-only access to every
|
|
other dashboard page; `/alarms` and `/hubs/alarms` deny, and the side rail hides the Alarms link
|
|
rather than offering a dead end.
|
|
- `Dashboard:DisableLogin` auto-login is unaffected: it mints a genuinely authenticated principal
|
|
holding both roles, so it satisfies the policy like any signed-in user.
|
|
- Gating only the hub was rejected: the page reads the same monitor in process, so the disclosure
|
|
would have been one navigation away.
|
|
|
|
Operators who *want* the alarm feed on an unauthenticated local console must sign in (or, in a dev
|
|
environment, set `Dashboard:DisableLogin=true`); there is deliberately no config flag that re-opens
|
|
the feed to the bypasses.
|
|
|
|
### DisableLogin dev bypass
|
|
|
|
`MxGateway:Dashboard:DisableLogin` (default `false`) is a third bypass for
|
|
dev and test environments where LDAP is unavailable or irrelevant.
|
|
|
|
When the flag is `true`, the `DashboardAuthenticator`-backed cookie handler is
|
|
replaced by `DashboardAutoLoginAuthenticationHandler`, registered under the
|
|
same scheme name (`MxGateway.Dashboard`). The handler auto-authenticates every
|
|
incoming request — including requests from remote browsers, not just loopback —
|
|
as a principal for `MxGateway:Dashboard:AutoLoginUser` (default `multi-role`)
|
|
holding both the `Administrator` and `Viewer` role claims.
|
|
|
|
The same-scheme-name swap is intentional: every authorization policy
|
|
(`MxGateway.Dashboard.Viewer`, `MxGateway.Dashboard.Admin`,
|
|
`MxGateway.Dashboard.HubClients`) resolves the `MxGateway.Dashboard` scheme,
|
|
so the handler replacement requires zero changes to policies, Razor page
|
|
attributes, or hub authorization attributes. `UseAuthentication()` stamps the
|
|
principal on `HttpContext.User` for the full HTTP pipeline, the Blazor circuit,
|
|
and the SignalR hubs uniformly — there is no separate path for each surface.
|
|
|
|
This differs from `AllowAnonymousLocalhost`: that flag satisfies the Viewer
|
|
authorization requirement on loopback without minting an authenticated
|
|
principal, so role-gated write affordances (Admin-only API-key CRUD, Close/Kill
|
|
controls) stay hidden. `DisableLogin` mints a real multi-role principal, so
|
|
those affordances appear — which is the point for dev scenarios where a
|
|
developer needs the full Admin surface without standing up LDAP.
|
|
|
|
A loud one-time startup warning is logged when `DisableLogin` is `true`. The
|
|
gRPC API-key authentication path is untouched; only the dashboard cookie
|
|
surface is affected. Never enable in production.
|
|
|
|
### Hub bearer flow
|
|
|
|
This flow serves remote hub clients only; in-process pages are authorized by the
|
|
component endpoint's `ViewerPolicy` and never mint a token.
|
|
|
|
SignalR connections cannot reuse the `__Host-` cookie when the JS client
|
|
upgrades to WebSocket — the cookie's `SameSite=Strict; Path=/` keeps it from
|
|
being forwarded by the browser's WebSocket layer in some edge cases. The
|
|
dashboard mints short-lived bearer tokens for the connection:
|
|
|
|
1. The cookie-authenticated client calls `GET /hubs/token`
|
|
(gated by `ViewerPolicy`, cookie-only).
|
|
2. `HubTokenService.Issue(user)` serializes the user's name, NameIdentifier,
|
|
and role claims to JSON, encrypts with the ASP.NET Core data-protection
|
|
time-limited protector under purpose
|
|
`ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1`, and returns the protected
|
|
string. Lifetime is **5 minutes**.
|
|
3. The SignalR client passes the token as either `Authorization: Bearer …` or
|
|
`?access_token=…` (WebSocket upgrade query string). The query-string form is
|
|
the standard SignalR carriage for WebSocket upgrades, which cannot attach a
|
|
custom header; because it is easy to capture (proxy logs, browser history),
|
|
it must never be request-logged (see the remarks on
|
|
`HubTokenAuthenticationHandler`).
|
|
4. `HubTokenAuthenticationHandler` validates the protected payload and
|
|
rebuilds the `ClaimsPrincipal` with the carried roles.
|
|
5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting
|
|
identity.
|
|
|
|
There is no in-repo hub client: the helper that once wrapped `HubConnectionBuilder`
|
|
for a circuit was deleted when the pages moved to the in-process seams. An external
|
|
client re-fetches `/hubs/token` on every (re)connect itself, which is what makes the
|
|
short 5-minute lifetime transparent.
|
|
|
|
Caveat — logout does not revoke outstanding tokens. Logout clears the dashboard
|
|
cookie, but hub bearer tokens are self-contained, data-protection-encrypted, and
|
|
carry no server-side revocation state (no jti denylist). A token captured before
|
|
logout remains valid until it expires, and a role change or key revocation does
|
|
not take effect on an already-issued token until then. The 5-minute lifetime is
|
|
the deliberate mitigation: it bounds that exposure window without the cost of a
|
|
revocation store. It now bounds a stale *tag* grant the same way: the token carries
|
|
the tags resolved from the caller's LDAP groups at mint time, so removing a
|
|
`GroupToTag` entry takes effect for token-authenticated hub connections within one
|
|
lifetime. Since SEC-41 the cookie seam is no longer the weaker one: the ACL
|
|
re-resolves a cookie principal's grant from its group claims against current
|
|
options on every subscribe, so a `GroupToTag` edit takes effect on both seams at
|
|
the next subscribe after the restart that rebinds options — the stamped
|
|
`zb:dashboardtag` claims are consulted only for a principal with no group claims
|
|
to re-resolve from, i.e. the hub-token principal itself. What the token's lifetime
|
|
still bounds uniquely is a stale **role** set and a stale LDAP **group** list, since
|
|
both are copied into the payload at mint. See the revocation-bounds table under
|
|
"Authentication And Authorization". A jti denylist stays deferred — the short
|
|
lifetime already bounds every claim the token carries.
|
|
|
|
## Configuration
|
|
|
|
Effective configuration:
|
|
|
|
```json
|
|
{
|
|
"MxGateway": {
|
|
"Dashboard": {
|
|
"Enabled": true,
|
|
"AllowAnonymousLocalhost": true,
|
|
"SnapshotIntervalMilliseconds": 1000,
|
|
"RecentFaultLimit": 100,
|
|
"RecentSessionLimit": 200,
|
|
"ShowTagValues": false,
|
|
"GroupToRole": {
|
|
"GwAdmin": "Administrator",
|
|
"GwReader": "Viewer"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
See [Gateway Configuration](./GatewayConfiguration.md#dashboard-options) for
|
|
the full option table and the policies/hubs that derive from these values.
|
|
|
|
## Security Rules
|
|
|
|
- Do not display API key secrets.
|
|
- Do not display credential-bearing MXAccess command values.
|
|
- Do not display full tag values by default.
|
|
- Do not expose worker pipe names with nonce or sensitive details.
|
|
- Protect dashboard auth cookies with `HttpOnly`, `Secure`, and `SameSite`.
|
|
- Require TLS for remote dashboard access.
|
|
- Use anti-forgery protection for login/logout and any future admin actions.
|
|
|
|
## Styling
|
|
|
|
The dashboard serves Bootstrap 5.3.3 assets from
|
|
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
|
|
from `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/dashboard.css`.
|
|
|
|
Recommended visual language:
|
|
|
|
- compact tables,
|
|
- status badges,
|
|
- metric cards,
|
|
- Bootstrap alerts for faults,
|
|
- restrained colors,
|
|
- no decorative hero sections,
|
|
- no charting dependency for v1.
|
|
|
|
If charts are added later, prefer simple server-generated data tables first. Do
|
|
not add a JavaScript charting dependency without a specific need.
|
|
|
|
The reusable visual rules for replicating this interface in other projects are
|
|
documented in [Dashboard Interface Design](./DashboardInterfaceDesign.md).
|
|
|
|
## Testing
|
|
|
|
Dashboard unit/component tests should cover:
|
|
|
|
- snapshot projection,
|
|
- dashboard auth authorization decisions,
|
|
- login API-key validation behavior,
|
|
- pages render with empty state,
|
|
- pages render with active sessions,
|
|
- pages render with faulted sessions,
|
|
- realtime subscription disposal,
|
|
- redaction of API keys and credential values.
|
|
|
|
Use bUnit if component testing is added. Otherwise keep the first tests focused
|
|
on snapshot services and authorization logic.
|
|
|
|
Integration tests should verify:
|
|
|
|
- dashboard disabled returns not found or configured fallback,
|
|
- dashboard requires auth when enabled,
|
|
- a user in an Admin-mapped LDAP group can access the dashboard and the
|
|
API-key CRUD surface,
|
|
- a user in a Viewer-mapped LDAP group can render every page but cannot
|
|
invoke the Admin-only management actions,
|
|
- a user with no mapped LDAP group cannot sign in at all,
|
|
- live snapshot updates when a fake session changes state reach a page through
|
|
the in-process `IDashboardSnapshotFeed` and reach a remote client through the
|
|
`/hubs/snapshot` push — neither by polling;
|
|
- the snapshot feed's idle gate: no subscribers means no pump, the last
|
|
subscriber of the live generation stops it, and a subscriber arriving
|
|
mid-teardown gets a fresh generation rather than a dead one;
|
|
- the event mirror's viewer gate counts in-process subscriptions as well as hub
|
|
connections, and a disposed in-process subscription releases its viewer count.
|
|
|
|
## Initial Implementation Slice
|
|
|
|
The first dashboard slice implements:
|
|
|
|
1. Blazor Server hosting in `ZB.MOM.WW.MxGateway.Server`.
|
|
2. local Bootstrap static assets.
|
|
3. dashboard configuration binding.
|
|
4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie.
|
|
5. `DashboardSnapshotService` projecting gateway state for read views.
|
|
6. home page with metric cards.
|
|
7. sessions page with active session table and session details.
|
|
8. workers page with worker table.
|
|
9. events page with aggregate counters.
|
|
10. settings page with redacted effective configuration.
|
|
11. periodic realtime refresh through Blazor Server.
|
|
12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot
|
|
projection/redaction tests.
|
|
|
|
Subsequent slices added Admin-gated destructive actions: API-key
|
|
Create/Rotate/Revoke (and Delete on revoked keys), and session/worker
|
|
Close/Kill via `IDashboardSessionAdminService` → `ISessionManager`. Every
|
|
destructive action passes through the shared `ConfirmDialog` component
|
|
before reaching its service.
|
|
|
|
## Related Documentation
|
|
|
|
- [Dashboard Interface Design](./DashboardInterfaceDesign.md)
|
|
- [Gateway Process Detailed Design](./GatewayProcessDesign.md)
|
|
- [Authentication](./Authentication.md)
|
|
- [Authorization](./Authorization.md)
|
|
- [Sessions](./Sessions.md)
|
|
- [Metrics](./Metrics.md)
|
|
- [Diagnostics](./Diagnostics.md)
|