fix(SEC-41, SEC-40): re-resolve dashboard tag grants; gate the alarm feed on a real role

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.
This commit is contained in:
Joseph Doherty
2026-08-20 09:29:44 -04:00
parent b177636d7a
commit 2ef7b93761
15 changed files with 681 additions and 43 deletions
+98 -19
View File
@@ -274,7 +274,7 @@ 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`) | Connected clients auto-join `__alarms__`; all clients receive every message. 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. |
| `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
@@ -556,6 +556,14 @@ re-asserts the truncation verdict as the reconcile baseline. If `MxGateway:Alarm
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
@@ -706,14 +714,25 @@ Implemented behavior:
generic credential-rejected message;
- antiforgery tokens guard the login and logout POSTs.
Three authorization policies are registered:
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` — 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.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
@@ -733,12 +752,30 @@ caller's granted tags, ordinal-ignore-case. A session's tags are inherited from
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, stamped as
`zb:dashboardtag` claims at cookie login and re-resolved (not copied) at hub-token
mint, so the token's 5-minute lifetime bounds how long a revoked grant survives. A
principal carrying no tag claims — 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.
`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`
@@ -746,11 +783,50 @@ Two environmental bypasses still apply, both scoped to **read-only** access:
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. Remote requests (outside the `Disabled`
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
@@ -824,13 +900,16 @@ 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. That 5-minute staleness bound covers token-authenticated connections only:
a cookie principal carries the `zb:dashboardtag` claims stamped at login for the
cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a
revoked `GroupToTag` grant takes effect at the user's next login, not within five
minutes. That is where the per-session ACL's revocation need landed — a jti
denylist stays deferred, since the short lifetime already bounds every grant the
token carries.
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