From d6922321916f870058ffe2637ef865d688c20e58 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 02:07:30 -0400 Subject: [PATCH] =?UTF-8?q?dashboard:=20clear=20deferred=20items=20?= =?UTF-8?q?=E2=80=94=20EventsHub=20publisher=20+=20doc=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventsHub publisher (closes the v2.1 follow-up flagged in the previous commit) EventStreamService now mirrors every MxEvent it forwards to a gRPC client into the `EventsHub` group for the session. The fan-out goes through a new singleton `IDashboardEventBroadcaster`: * IDashboardEventBroadcaster — abstraction so EventStreamService doesn't take a direct dependency on SignalR. * DashboardEventBroadcaster — singleton implementation that hands the SendAsync to IHubContext as fire-and-forget. Errors are logged at debug and dropped so the source gRPC stream is never blocked. EventStreamService now takes IDashboardEventBroadcaster as a ctor parameter and calls Publish(sessionId, publicEvent) once per event after sequence filtering, before the bounded queue write. Test fixtures and the live integration harness pass NullDashboardEventBroadcaster.Instance so the broadcaster is a no-op in unit tests. SessionDetailsPage adds a "Recent events" panel: * implements IAsyncDisposable * opens a second HubConnection via DashboardHubConnectionFactory targeting /hubs/events * calls SubscribeSession(SessionId) on Start * renders the most recent 50 events in a small table (worker seq, family, server/item handle, alarm reference when the event is OnAlarmTransition) * shows a live/offline conn-pill driven by HubConnection.Closed / Reconnected events The dashboard mirror is intentionally passive — events appear only while a gRPC client is also consuming that session's events. Documented as such in the empty-state copy and in GatewayDashboardDesign.md. Documentation refresh Every doc that referenced the retired options (PathBase, RequireAdminScope, RequiredGroup) and the old API-key-cookie auth flow is updated to describe the new model: * CLAUDE.md — Authentication section now explains LDAP bind + GroupToRole + HubToken bearer flow. * gateway.md — Dashboard section: root-mounted routes, snapshot/alarms/ events SignalR hubs, LDAP cookie + bearer scheme. * docs/GatewayConfiguration.md — drop PathBase / RequireAdminScope rows, add GroupToRole row, append "Authorization policies" and "SignalR hubs" subsections describing the three policies and the /hubs/* endpoints. * docs/GatewayDashboardDesign.md — hosting model (root mount, new endpoint layout), Realtime Updates rewritten as a hub table (DashboardSnapshotHub / AlarmsHub / EventsHub with producers, payloads, and routing), Authentication And Authorization rewritten around LDAP + role mapping + the hub bearer flow, Configuration block updated. * docs/GatewayProcessDesign.md — security-section dashboard paragraph and the example config block both refreshed to LDAP/role auth. * docs/ImplementationPlanGateway.md — dashboard-auth deliverable list updated (LDAP bind + GroupToRole + /hubs/token bearer mint replace the API-key login flow). * docs/GatewayTesting.md — DashboardLdapLiveTests blurb describes the GroupToRole fixture (`{ GwAdmin: Admin }`) instead of the retired RequiredGroup default; success-path assertion explains the role-claim check. Verification: 475 server tests, 275 worker tests (+ 9 dev-rig skips), 18 integration tests (live MxAccess + LDAP + Galaxy) all pass — including the live worker smoke test fixture that now constructs EventStreamService with the new broadcaster parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- docs/GatewayConfiguration.md | 51 ++++- docs/GatewayDashboardDesign.md | 193 ++++++++++++------ docs/GatewayProcessDesign.md | 43 ++-- docs/GatewayTesting.md | 27 +-- docs/ImplementationPlanGateway.md | 16 +- gateway.md | 43 ++-- .../WorkerLiveMxAccessSmokeTests.cs | 7 + .../Components/Pages/SessionDetailsPage.razor | 142 +++++++++++++ .../DashboardServiceCollectionExtensions.cs | 1 + .../Hubs/DashboardEventBroadcaster.cs | 41 ++++ .../Hubs/IDashboardEventBroadcaster.cs | 14 ++ .../Grpc/EventStreamService.cs | 7 + .../GatewayEndToEndFakeWorkerSmokeTests.cs | 6 + .../Gateway/Grpc/EventStreamServiceTests.cs | 7 + 15 files changed, 471 insertions(+), 129 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3f37e33..726a48a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ External analysis sources referenced by design docs: Gateway gRPC clients authenticate with an API key in metadata: `authorization: Bearer mxgw__`. Keys are stored hashed (with a peppered SHA) in a gateway-owned SQLite DB (default `C:\ProgramData\MxGateway\gateway-auth.db`). Scopes (`session`, `invoke`, `event`, `metadata`, `admin`) gate specific RPCs; missing → `Unauthenticated`, insufficient → `PermissionDenied`. The `apikey` subcommand on the server exe manages keys; see `src/MxGateway.Server/Security/Authentication/`. -Dashboard auth uses the same verifier but exchanges the API key for an HTTP-only secure cookie at `/dashboard/login`. `Dashboard:AllowAnonymousLocalhost` bypasses cookie auth on loopback when explicitly enabled. +Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled. ## Process / Platform Notes diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 76f9463..e5c7799 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -45,13 +45,15 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid. }, "Dashboard": { "Enabled": true, - "PathBase": "/dashboard", - "RequireAdminScope": true, "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, - "ShowTagValues": false + "ShowTagValues": false, + "GroupToRole": { + "GwAdmin": "Admin", + "GwReader": "Viewer" + } }, "Protocol": { "WorkerProtocolVersion": 1, @@ -142,17 +144,52 @@ the affected stream while the MXAccess session remains active. | Option | Default | Description | |--------|---------|-------------| -| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. | -| `MxGateway:Dashboard:PathBase` | `/dashboard` | Base path for dashboard routes. When the dashboard is enabled, this value is required and must start with `/`. | -| `MxGateway:Dashboard:RequireAdminScope` | `true` | Requires API keys used for dashboard login to carry the `admin` scope. | +| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. | | `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. | -| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by realtime Blazor pages. | +| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. | +| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | `SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit` and `RecentSessionLimit` must be greater than or equal to zero. +`GroupToRole` values are validated at startup; invalid role names fail +validation. Emptiness is allowed (a closed deployment that admits no LDAP +users) but practical deployments populate at least one Admin group. + +### Authorization policies + +Three authorization policies are registered out of these options: + +- `MxGateway.Dashboard.Viewer` — gates the Razor component routes. Satisfied by + either dashboard role (Admin or Viewer), by `AllowAnonymousLocalhost` on + loopback, or by `Authentication.Mode = Disabled`. +- `MxGateway.Dashboard.Admin` — gates write-capable surfaces (API-key CRUD). + Satisfied only by the Admin role (same environmental bypasses). +- `MxGateway.Dashboard.HubClients` — attached to the SignalR hubs. Accepts + either the dashboard cookie scheme or the `MxGateway.Dashboard.HubToken` + bearer scheme (used by SignalR's WebSocket upgrade path where the HttpOnly + cookie can't be forwarded). + +### SignalR hubs + +When the dashboard is enabled, three hubs are mapped under `/hubs/*`: + +- `GET /hubs/snapshot` — pushes `DashboardSnapshot` whenever the snapshot + service produces a new one. Drives every page that inherits + `DashboardPageBase`; replaces the earlier polling loop. +- `GET /hubs/alarms` — re-broadcasts the `AlarmFeedMessage` stream from the + central alarm monitor to all connected clients (group `__alarms__`). +- `GET /hubs/events` — per-session MxEvent feed. Clients call + `SubscribeSession(sessionId)` to join `session:{id}`. Events are mirrored + from the corresponding gRPC `StreamEvents` call as a fire-and-forget + side-effect; the dashboard only sees events while a gRPC client is also + subscribed to that session. + +`GET /hubs/token` (cookie-only) mints a 30-minute data-protected bearer +token for the calling user; the Blazor pages use it via +`DashboardHubConnectionFactory` to authenticate the SignalR connection. ## Protocol Options diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index 18f475c..158caad 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -35,26 +35,36 @@ 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()` maps the -configured `Dashboard:PathBase` to the Blazor Server app and maps the login, -logout, and access-denied HTTP endpoints beside it. When dashboard hosting is -disabled, those routes are not mapped. +`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 -/dashboard -/dashboard/sessions -/dashboard/sessions/{sessionId} -/dashboard/workers -/dashboard/events -/dashboard/galaxy -/dashboard/apikeys -/dashboard/settings -/dashboard/_blazor +/ +/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 `/dashboard/galaxy` page surfaces the Galaxy Repository browse summary +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 @@ -63,9 +73,6 @@ template usage, and connectivity sync info). The summary is fed by never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for the underlying gRPC service. -The app should redirect `/` to `/dashboard` only if the deployment wants the -dashboard as the default web page. Otherwise leave gRPC/API hosting unaffected. - ## High-Level Components ```text @@ -102,8 +109,10 @@ ZB.MOM.WW.MxGateway.Server DashboardMetricSummary.cs ``` -Blazor Server provides the SignalR circuit for UI updates. The implementation -does not add a separate public dashboard hub. +The dashboard exposes three named SignalR hubs in addition to Blazor Server's +internal circuit; pages connect to those hubs from within the circuit via the +`DashboardHubConnectionFactory` helper. The hubs publish snapshot, alarm, and +per-session event updates that the pages render in place of polling. ## Dashboard Data Source @@ -150,23 +159,36 @@ gateway internals. ## Realtime Updates -Use Blazor Server component state updates for real-time dashboard refresh. +Updates 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)]`. -Implemented pattern: +| Hub | Path | Producer | Payload | Routing | +|---|---|---|---|---| +| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick; 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`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. | +| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by `EventStreamService` for each event it forwards to a gRPC client | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. Events appear only while a gRPC client is also consuming that session's events — the dashboard is a passive mirror, not a separate worker subscriber. | -1. Page/component subscribes to `WatchSnapshotsAsync`. -2. Snapshot service emits updates from a bounded channel or timer. -3. Component stores the latest snapshot. -4. Component calls `InvokeAsync(StateHasChanged)`. -5. Component cancels subscription on dispose. +`DashboardPageBase` opens a `DashboardSnapshotHub` connection via the connection +factory in `OnInitializedAsync`, seeds `Snapshot` synchronously from +`IDashboardSnapshotService.GetSnapshot()` so the first render is non-empty, and +calls `InvokeAsync(StateHasChanged)` on every `SnapshotUpdated` push. SignalR's +`WithAutomaticReconnect` handles transient disconnects. -Default update cadence: +`SessionDetailsPage` additionally opens an `EventsHub` connection for the +current session id and renders the most recent N events (default 50) in a +"Recent events" table with a live/offline connection pill. -- periodic metrics refresh every 1 second, -- event counters update on the next snapshot tick. +Default cadences: -Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event -counts and rates instead. +- 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 forwarded by `StreamEvents`. + +Avoid pushing every MXAccess data-change event into a wider broadcast group. +The current design routes events strictly through `session:{id}` groups; the +snapshot hub continues to carry aggregate event counters and rates. ## Pages @@ -326,9 +348,10 @@ for what each constraint means and how it is enforced on the gRPC path. Create, Rotate, and Revoke controls render only when the signed-in user is authorized. `DashboardApiKeyAuthorization.CanManage` requires an authenticated -principal that is a member of the LDAP `MxGateway:Ldap:RequiredGroup` — the -same group the dashboard login enforces. An anonymous localhost viewer can read -the table but sees no action controls. +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 @@ -363,58 +386,92 @@ Do not show API key secrets or pepper values. ## Authentication And Authorization -Dashboard access uses the same API-key authentication model as gRPC where -practical. +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 (`Admin` or `Viewer`) and +issues a cookie carrying those role claims. -Implemented v1 behavior: +Implemented behavior: -- when enabled, require API key auth, -- require `admin` scope for dashboard access, -- accept API key through a secure cookie established by a simple login form, -- do not put API keys in query strings, -- validate anti-forgery tokens for login and logout posts. +- 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 `memberOf` (or short CN) is matched against + `MxGateway:Dashboard:GroupToRole`; 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 + (`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure); +- 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. -The implementation path is: +Three authorization policies are registered: -1. Add `/dashboard/login`. -2. User submits API key over HTTPS. -3. Gateway validates key and `admin` scope. -4. Gateway issues an HTTP-only secure auth cookie for the dashboard. -5. Dashboard pages require that cookie. -6. Logout clears the cookie. +- `MxGateway.Dashboard.Viewer` — Razor component routes. Satisfied by Admin or + Viewer. +- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD). +- `MxGateway.Dashboard.HubClients` — SignalR hubs. Accepts the dashboard + cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades + where the cookie can't be forwarded). -For local development, `Dashboard:AllowAnonymousLocalhost` defaults to `true`. -The bypass applies only to loopback requests; remote dashboard requests still -use the API-key-backed cookie flow. +Two environmental bypasses still apply: `MxGateway:Authentication:Mode = Disabled` +authorizes every request, and `MxGateway:Dashboard:AllowAnonymousLocalhost` +(default `true`) authorizes any loopback request without a role check. Remote +requests always require an authenticated principal carrying at least the +Viewer role. -`DashboardAuthenticator` keeps API-key validation outside UI components. It -formats the submitted key as a bearer authorization header for -`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is -enabled, and creates the dashboard cookie principal without storing raw API key -material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and -explicit loopback bypass decisions for all protected dashboard routes. +### Hub bearer flow + +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 Blazor page 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 30 minutes. +3. The SignalR client passes the token as either `Authorization: Bearer …` or + `?access_token=…` (WebSocket upgrade query string). +4. `HubTokenAuthenticationHandler` validates the protected payload and + rebuilds the `ClaimsPrincipal` with the carried roles. +5. The hubs' `[Authorize(Policy = HubClientsPolicy)]` accepts the resulting + identity. + +`DashboardHubConnectionFactory` (scoped to the Blazor circuit) wraps the +HubConnectionBuilder and supplies a fresh token via `AccessTokenProvider` on +every (re)connect. ## Configuration -Suggested configuration: +Effective configuration: ```json { "MxGateway": { "Dashboard": { "Enabled": true, - "PathBase": "/dashboard", - "RequireAdminScope": true, "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, - "ShowTagValues": false + "ShowTagValues": false, + "GroupToRole": { + "GwAdmin": "Admin", + "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. @@ -467,9 +524,13 @@ Integration tests should verify: - dashboard disabled returns not found or configured fallback, - dashboard requires auth when enabled, -- admin-scoped key can access dashboard, -- non-admin key is denied, -- live snapshot updates when a fake session changes state. +- 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 are delivered + via the `/hubs/snapshot` push, not by polling. ## Initial Implementation Slice @@ -478,7 +539,7 @@ 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 API key login and HTTP-only cookie. +4. dashboard auth using LDAP bind + role-mapped HTTP-only cookie. 5. read-only `DashboardSnapshotService`. 6. home page with metric cards. 7. sessions page with active session table and session details. diff --git a/docs/GatewayProcessDesign.md b/docs/GatewayProcessDesign.md index c1332d1..ae5b5ca 100644 --- a/docs/GatewayProcessDesign.md +++ b/docs/GatewayProcessDesign.md @@ -289,11 +289,14 @@ Default refresh policy: - periodic metrics refresh every 1 second, - event-rate windows updated every 1 second. -Dashboard access should require API-key-backed authentication with `admin` scope -when enabled. A simple `/dashboard/login` form can validate an API key and issue -an HTTP-only secure cookie for dashboard pages. Do not put API keys in query -strings. Anonymous localhost access may exist only behind an explicit -configuration option that defaults to false. +Dashboard access requires LDAP-backed authentication with role mapping when +enabled. A simple `/login` form binds against the configured directory, maps +the user's groups to `Admin` or `Viewer` via +`MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure cookie. +SignalR hub connections accept either that cookie or a short-lived +data-protected bearer minted at `/hubs/token`. Anonymous localhost access is +gated by `MxGateway:Dashboard:AllowAnonymousLocalhost` (defaults to true for +local development; remote requests always require auth). ## Session State Machine @@ -674,15 +677,17 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in `Authentication:Mode` set to `Disabled` bypasses API-key verification for local development only. -Dashboard authentication reuses the API-key verifier and scope model. The -dashboard login endpoint accepts the key in a form post, checks `admin` scope -when `Dashboard:RequireAdminScope` is enabled, and signs in with the -`ZB.MOM.WW.MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict -SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears -that cookie. Login and logout posts use anti-forgery validation, and dashboard -API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost` -allows only loopback requests to bypass the dashboard cookie requirement and -defaults to `true`. +Dashboard authentication uses LDAP bind + role mapping (separate from the +API-key model used on the gRPC API). The login endpoint accepts username and +password in a form post, calls `DashboardAuthenticator` to bind against +`MxGateway:Ldap`, resolves the user's LDAP groups through +`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in +with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, +secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout +clears it. Login and logout posts validate antiforgery tokens. SignalR +connections additionally accept a 30-minute data-protected bearer minted at +`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests +to bypass the cookie requirement and defaults to `true`. Recommended scopes: @@ -870,13 +875,15 @@ Suggested configuration shape: }, "Dashboard": { "Enabled": true, - "PathBase": "/dashboard", - "RequireAdminScope": true, "AllowAnonymousLocalhost": true, "SnapshotIntervalMilliseconds": 1000, "RecentFaultLimit": 100, "RecentSessionLimit": 200, - "ShowTagValues": false + "ShowTagValues": false, + "GroupToRole": { + "GwAdmin": "Admin", + "GwReader": "Viewer" + } }, "Protocol": { "WorkerProtocolVersion": 1 @@ -968,7 +975,7 @@ The first gateway slice should implement: 13. Blazor Server dashboard with Bootstrap assets. 14. Dashboard home, sessions, and workers pages. 15. Dashboard realtime snapshot refresh. -16. Dashboard API-key login with admin-scope check. +16. Dashboard LDAP login mapped to Admin / Viewer roles. 17. Basic structured logs. This proves the process model before the full command surface is implemented. diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index 71312a6..f099dc8 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -205,20 +205,23 @@ SQL-injection framing does not apply to a constant-query layer. `MXGATEWAY_RUN_LIVE_LDAP_TESTS=1` is set because it binds against the GLAuth service described in `glauth.md`. -The suite builds the authenticator with a default `GatewayOptions`, so -`LdapOptions.RequiredGroup` keeps its `GwAdmin` default. `GwAdmin` is the -gateway-specific dashboard-admin role and is **not** part of the five baseline -GLAuth role groups — it must be provisioned before the LDAP live tests pass. -`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips) when -GLAuth has only the baseline groups, so this is a hard prerequisite beyond "LDAP -is up." See the "Adding a gw-specific group" section of `glauth.md` for the -provisioning step that adds `GwAdmin` and grants it to `admin`. +The suite builds the authenticator with `GatewayOptions.Dashboard.GroupToRole` +set to `{ GwAdmin: Admin }`. `GwAdmin` is the gateway-specific +dashboard-admin role and is **not** part of the five baseline GLAuth role +groups — it must be provisioned before the LDAP live tests pass. +`AuthenticateAsync_AdminInGwAdminGroup_Succeeds` fails (rather than skips) +when GLAuth has only the baseline groups, so this is a hard prerequisite +beyond "LDAP is up." See the "Adding a gw-specific group" section of +`glauth.md` for the provisioning step that adds `GwAdmin` and grants it to +`admin`. The suite covers both the success path and the `DashboardAuthenticator` failure -branches: `admin` in `GwAdmin` succeeds; `readonly` is denied for missing group; -`admin` with a wrong password is rejected by the candidate bind without leaking -the password into `FailureMessage`; an unknown username yields no candidate; and -an unreachable LDAP server is absorbed into a failed result rather than throwing. +branches: `admin` whose LDAP groups resolve to the `Admin` role succeeds and +emits the role claim; `readonly` is denied because no group in their `memberOf` +appears in `GroupToRole`; `admin` with a wrong password is rejected by the +candidate bind without leaking the password into `FailureMessage`; an unknown +username yields no candidate; and an unreachable LDAP server is absorbed into a +failed result rather than throwing. Run the LDAP live tests explicitly: diff --git a/docs/ImplementationPlanGateway.md b/docs/ImplementationPlanGateway.md index 8874b67..04bd134 100644 --- a/docs/ImplementationPlanGateway.md +++ b/docs/ImplementationPlanGateway.md @@ -447,12 +447,16 @@ Labels: `area:dashboard`, `area:auth`, `type:feature`, `priority:p1` Deliverables: -- `/dashboard/login`, -- API-key validation with `admin` scope, -- HTTP-only secure cookie, -- logout, -- anti-forgery protection, -- optional explicit anonymous-localhost dev mode defaulting false. +- `/login` (root-mounted), +- LDAP bind against `MxGateway:Ldap`, +- LDAP-group → role mapping (`Admin` / `Viewer`) via + `MxGateway:Dashboard:GroupToRole`, +- HTTP-only secure cookie (`__Host-MxGatewayDashboard`), +- `/hubs/token` bearer mint for SignalR connections, +- `/logout`, +- antiforgery protection, +- `MxGateway:Dashboard:AllowAnonymousLocalhost` loopback bypass + (defaults to true for local development). Acceptance criteria: diff --git a/gateway.md b/gateway.md index 23ae0dc..f350767 100644 --- a/gateway.md +++ b/gateway.md @@ -112,18 +112,20 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can project without binding to a metrics exporter. `DashboardSnapshotService` projects sessions, workers, metrics, faults, and effective configuration into immutable DTOs for read-only dashboard rendering. -The Blazor Server dashboard renders those snapshots at `/dashboard`, -`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, -`/dashboard/galaxy`, and `/dashboard/settings`. Components subscribe to -`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured -snapshot interval without mutating session or worker state. The dashboard uses -local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not -use a Blazor UI component library. +The Blazor Server dashboard mounts at the host root and renders those snapshots +at `/`, `/sessions`, `/workers`, `/events`, `/galaxy`, `/alarms`, `/apikeys`, +and `/settings`. Pages connect to `/hubs/snapshot` (a SignalR hub published by +`DashboardSnapshotPublisher`) and refresh on every push instead of polling. +`/hubs/alarms` broadcasts `AlarmFeedMessage` values from the central alarm +monitor; `/hubs/events` mirrors per-session `MxEvent` traffic from +`EventStreamService` to clients subscribed to `session:{id}`. The dashboard +uses local Bootstrap CSS and JavaScript plus a small local stylesheet; it does +not use a Blazor UI component library. -`/dashboard/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed -tag values live through `IDashboardLiveDataService`, which owns one shared, -lazily-opened gateway session for the whole dashboard. `/dashboard/alarms` -reads the central alarm monitor's in-process cache directly. See +`/browse` walks the `IGalaxyHierarchyCache` tree and reads subscribed tag +values live through `IDashboardLiveDataService`, which owns one shared, +lazily-opened gateway session for the whole dashboard. `/alarms` reads the +central alarm monitor's in-process cache directly. See `docs/GatewayDashboardDesign.md`. The gateway runs an always-on central alarm monitor (`GatewayAlarmMonitor`): @@ -138,14 +140,17 @@ session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see `docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule for the alarm subsystem. -Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login` -accepts the API key in a form body, validates the configured `admin` scope, -and issues an HTTP-only secure cookie for subsequent dashboard requests. -`/dashboard/logout` clears that cookie. Login and logout posts validate -anti-forgery tokens, and API keys are never accepted through query strings. -`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for -loopback requests only when explicitly enabled. Setting -`MxGateway:Dashboard:Enabled` to `false` leaves the dashboard routes unmapped. +Dashboard authentication is LDAP-backed (distinct from the API-key model on +the gRPC API). `/login` accepts username and password in a form body, binds +against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer` +via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure +`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout +posts validate antiforgery tokens. SignalR hub connections accept either the +cookie or a 30-minute data-protected bearer minted at `/hubs/token`. +`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the +cookie requirement; remote requests always require an authenticated principal +with at least the Viewer role. Setting `MxGateway:Dashboard:Enabled` to +`false` leaves the dashboard and hub routes unmapped. ### Worker Process diff --git a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index e7c8f17..43506aa 100644 --- a/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -1082,6 +1082,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) options, mapper, _metrics, + NullDashboardEventBroadcaster.Instance, _loggerFactory.CreateLogger()); Service = new MxAccessGatewayService( @@ -1593,4 +1594,10 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) ConstraintFailure failure, CancellationToken cancellationToken) => Task.CompletedTask; } + + private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster + { + public static readonly NullDashboardEventBroadcaster Instance = new(); + public void Publish(string sessionId, ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent mxEvent) { } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor index 0eddbbc..4a0814d 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/SessionDetailsPage.razor @@ -1,5 +1,9 @@ @page "/sessions/{SessionId}" @inherits DashboardPageBase +@implements IAsyncDisposable +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.MxGateway.Contracts.Proto +@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs Dashboard Session @@ -59,12 +63,150 @@ else + +
+
+

Recent events

+ + + @(_eventsConnected ? "live" : "offline") + +
+ @if (_recentEvents.Count == 0) + { +
+ Waiting for events. The dashboard mirrors the session's gRPC event stream — events + appear here only while a gRPC client is also consuming this session's events. +
+ } + else + { +
+ + + + + + + + + + + + @foreach (MxEvent evt in _recentEvents) + { + + + + + + + + } + +
Worker seqFamilyServerItemStatus
@evt.WorkerSequence@evt.Family@evt.ServerHandle@evt.ItemHandle@DashboardDisplay.Text(EventStatusLabel(evt))
+
+ } +
} @code { + private const int MaxRecentEvents = 50; + [Parameter] public string SessionId { get; set; } = string.Empty; private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session => string.Equals(session.SessionId, SessionId, StringComparison.Ordinal)); + + private HubConnection? _eventsHub; + private bool _eventsConnected; + private string? _subscribedSessionId; + private readonly LinkedList _recentEvents = new(); + + protected override async Task OnParametersSetAsync() + { + if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal)) + { + await DetachEventsHubAsync().ConfigureAwait(false); + await AttachEventsHubAsync().ConfigureAwait(false); + } + } + + private async Task AttachEventsHubAsync() + { + if (string.IsNullOrWhiteSpace(SessionId)) + { + return; + } + + _eventsHub = HubFactory.Create("/hubs/events"); + _eventsHub.On(EventsHub.EventMessage, async mxEvent => + { + _recentEvents.AddFirst(mxEvent); + while (_recentEvents.Count > MaxRecentEvents) + { + _recentEvents.RemoveLast(); + } + + await InvokeAsync(StateHasChanged).ConfigureAwait(false); + }); + + _eventsHub.Closed += _ => + { + _eventsConnected = false; + return InvokeAsync(StateHasChanged); + }; + _eventsHub.Reconnected += _ => + { + _eventsConnected = true; + return InvokeAsync(StateHasChanged); + }; + + try + { + await _eventsHub.StartAsync().ConfigureAwait(false); + await _eventsHub.SendAsync("SubscribeSession", SessionId).ConfigureAwait(false); + _eventsConnected = true; + _subscribedSessionId = SessionId; + } + catch + { + _eventsConnected = false; + } + } + + private async Task DetachEventsHubAsync() + { + HubConnection? hub = _eventsHub; + _eventsHub = null; + _eventsConnected = false; + _subscribedSessionId = null; + _recentEvents.Clear(); + + if (hub is not null) + { + try + { + await hub.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Disposal-time errors are best-effort. + } + } + } + + private static string EventStatusLabel(MxEvent evt) + { + return evt.Family == MxEventFamily.OnAlarmTransition + ? evt.OnAlarmTransition?.AlarmFullReference ?? string.Empty + : string.Empty; + } + + public new async ValueTask DisposeAsync() + { + await DetachEventsHubAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index 350540f..1382c11 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static class DashboardServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddScoped(); + services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); services.AddHttpContextAccessor(); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs new file mode 100644 index 0000000..3ea0045 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.SignalR; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// Broadcasts MxEvents to clients subscribed to the +/// session's group. Fire-and-forget: we hand the send to the hub context +/// and return immediately so the source gRPC stream is never blocked. +/// Errors are logged once and dropped — keeping the SignalR mirror best-effort +/// preserves the gRPC contract that exists today. +/// +public sealed class DashboardEventBroadcaster( + IHubContext hubContext, + ILogger logger) : IDashboardEventBroadcaster +{ + public void Publish(string sessionId, MxEvent mxEvent) + { + if (string.IsNullOrEmpty(sessionId) || mxEvent is null) + { + return; + } + + Task send = hubContext.Clients + .Group(EventsHub.GroupName(sessionId)) + .SendAsync(EventsHub.EventMessage, mxEvent); + + if (!send.IsCompletedSuccessfully) + { + _ = send.ContinueWith( + t => + { + if (t.Exception is { } ex) + { + logger.LogDebug(ex, "Dashboard event mirror to session {SessionId} failed.", sessionId); + } + }, + TaskScheduler.Default); + } + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs new file mode 100644 index 0000000..06f732e --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/IDashboardEventBroadcaster.cs @@ -0,0 +1,14 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; + +/// +/// Fan-out point for MxEvents that should be mirrored to dashboard +/// SignalR clients subscribed to a session's events group. Implementations +/// must never throw — broadcast failures are best-effort and must not +/// disrupt the source gRPC stream. +/// +public interface IDashboardEventBroadcaster +{ + void Publish(string sessionId, MxEvent mxEvent); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs b/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs index 5d76c9e..7147474 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Grpc/EventStreamService.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; @@ -14,6 +15,7 @@ public sealed class EventStreamService( IOptions options, MxAccessGrpcMapper mapper, GatewayMetrics metrics, + IDashboardEventBroadcaster dashboardEventBroadcaster, ILogger logger) : IEventStreamService { /// @@ -118,6 +120,11 @@ public sealed class EventStreamService( continue; } + // Mirror the event to the dashboard EventsHub group for this + // session. Fire-and-forget — broadcast errors must not affect + // the source gRPC stream. + dashboardEventBroadcaster.Publish(session.SessionId, publicEvent); + if (!writer.TryWrite(publicEvent)) { string message = $"Session {session.SessionId} event stream queue overflowed."; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index a1de85d..7a88517 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -178,6 +178,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests options, mapper, _metrics, + NullDashboardEventBroadcaster.Instance, NullLogger.Instance); Service = new MxAccessGatewayService( @@ -413,4 +414,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests } } + private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster + { + public static readonly NullDashboardEventBroadcaster Instance = new(); + public void Publish(string sessionId, ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent mxEvent) { } + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index ab3f7cb..72bd4ca 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -278,9 +278,16 @@ public sealed class EventStreamServiceTests }), new MxAccessGrpcMapper(), metrics ?? new GatewayMetrics(), + NullDashboardEventBroadcaster.Instance, NullLogger.Instance); } + private sealed class NullDashboardEventBroadcaster : ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs.IDashboardEventBroadcaster + { + public static readonly NullDashboardEventBroadcaster Instance = new(); + public void Publish(string sessionId, MxEvent mxEvent) { } + } + private static async Task> CollectEventsAsync( EventStreamService service, string sessionId)