diff --git a/CLAUDE.md b/CLAUDE.md index 898ab3f..c1aea2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session. -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 cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`; untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with no tag claims (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production. +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 cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`; `/hubs/alarms` and the `/alarms` page additionally require a **real authenticated dashboard role** (`MxGateway.Dashboard.Alarms`, a requirement type the environment-bypass handler does not handle), because the alarm feed is gateway-central with no per-session provenance and so cannot be tag-scoped — its metadata stays galaxy-wide for authenticated Viewers, but anonymous localhost and `Authentication:Mode=Disabled` do not receive it at all. Dashboard event visibility is **tag-scoped per session** (`IDashboardSessionAcl`, gating both the events hub and the session-details page's in-process subscribe): an Administrator sees every session, while any other caller sees a session only when its tags — inherited from the owning API key's `apikey --dashboard-tags`, never from the client's request — intersect the tags their LDAP groups grant via `Dashboard:GroupToTag`. That grant is **re-resolved from the caller's LDAP group claims against current options on every subscribe** (not read from the tag claims stamped at login, which would outlive a config-revoked grant for the cookie's 8-hour sliding life); login-stamped tag claims are the fallback only for a principal carrying no group claims — i.e. the hub-token principal, whose grant the mint already re-resolved. Untagged sessions follow `Dashboard:UntaggedSessionVisibility` (default `AdminOnly`), and a principal with neither claim kind (anonymous localhost included) is an empty-grant Viewer. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD, session Close/Kill, or the alarm feed (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production. ## Process / Platform Notes diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 5970259..8621852 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -186,7 +186,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | Option | Default | Description | |--------|---------|-------------| | `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:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. The bypass is **read-only** — it satisfies a requirement including the `Viewer` role but never `AdminOnly`, so API-key CRUD and session Close/Kill stay closed — and since SEC-40 it does **not** reach the alarm feed: `/alarms` and `/hubs/alarms` carry `MxGateway.Dashboard.Alarms`, a policy built from a requirement type the bypass handler does not handle, so an anonymous loopback caller is denied the page, denied the hub, and not offered the Alarms link. The alarm monitor is gateway-central and its messages carry no session provenance, so alarm metadata cannot be tag-scoped the way session events are — requiring a real role claim is the narrowing that exists. Sign in (or use `DisableLogin` in dev, which mints a genuine principal) to see alarms; no setting re-opens the feed to the bypass. The same exclusion applies to `Authentication:Mode=Disabled`. | | `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. | | `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. | | `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | @@ -194,7 +194,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed. | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror** — `DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub** — `AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for the value text of each **successfully read** tag; a failed read keeps its `-` placeholder (there was no value to suppress, and the row's error is left untouched, so the two cannot contradict each other). Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. | | `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys must be **short** LDAP group names — the CN / first-RDN value, e.g. `GwAdmin`, not `ou=GwAdmin,ou=groups,dc=zb,dc=local` — matched case-insensitively. The shared `ZB.MOM.WW.Auth.Ldap` provider delivers a user's groups already stripped to short names, so a full-DN key can never match and the group silently maps to nothing. (The mapper does try the full literal string before falling back to the leading-RDN value, but on the live login path both branches see the same short name; the fallback only matters to non-library callers of the `IGroupRoleMapper` seam.) Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. | -| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` — short LDAP group names (CN / first-RDN value), matched case-insensitively; a full-DN key never matches the pre-stripped groups the LDAP provider returns. Values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. | +| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` — short LDAP group names (CN / first-RDN value), matched case-insensitively; a full-DN key never matches the pre-stripped groups the LDAP provider returns. Values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. **Applies to session event streams only:** the alarm feed (`/alarms`, `/hubs/alarms`) is deliberately *not* tag-scoped — the alarm monitor is gateway-central and session-less, so an `AlarmFeedMessage` carries no session or owning key to intersect a tag grant against. Alarm metadata (reference, state, severity, category, area, acknowledging operator) is therefore galaxy-wide for every authenticated dashboard role, regardless of tag grant; alarm *values* still follow `ShowTagValues`. What SEC-40 narrowed there is admission rather than scope: the environment bypasses (`AllowAnonymousLocalhost`, `Authentication:Mode=Disabled`) no longer receive the feed at all. **Revocation:** the ACL re-resolves a caller's grant from their LDAP group claims against this map on every subscribe, so removing an entry takes effect at the next subscribe after the gateway restart that rebinds options (options bind once at startup); a change to the user's LDAP group membership takes effect when their principal is rebuilt — at re-login for cookie sessions, within one 5-minute hub-token lifetime for token clients. An already-open subscription is never re-checked and lives until it drops. | | `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. | | `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. | | `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. | diff --git a/docs/GatewayDashboardDesign.md b/docs/GatewayDashboardDesign.md index abbc3f8..be2544f 100644 --- a/docs/GatewayDashboardDesign.md +++ b/docs/GatewayDashboardDesign.md @@ -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()` 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()` 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 diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleHandler.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleHandler.cs new file mode 100644 index 0000000..8d77bdc --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleHandler.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Handler for : succeeds only when the caller +/// is authenticated AND holds one of the requirement's roles. It reads no options at all, which +/// is the point — there is no configuration value that can turn this into a bypass (SEC-40). +/// +public sealed class AuthenticatedDashboardRoleHandler + : AuthorizationHandler +{ + /// + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + AuthenticatedDashboardRoleRequirement requirement) + { + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + foreach (string role in requirement.RequiredRoles) + { + if (context.User.IsInRole(role)) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleRequirement.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleRequirement.cs new file mode 100644 index 0000000..8364ec6 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/AuthenticatedDashboardRoleRequirement.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ZB.MOM.WW.MxGateway.Server.Dashboard; + +/// +/// Requirement satisfied ONLY by an authenticated principal holding one of +/// as a real role claim. Unlike +/// there is no environment bypass: it is a +/// distinct requirement type, so — +/// an AuthorizationHandler<DashboardAuthorizationRequirement> — is never invoked for +/// it and its Authentication:Mode=Disabled / anonymous-localhost arms cannot reach it. +/// The type distinction is the enforcement; adding a bypass arm to the other handler cannot leak +/// into this one by accident. +/// +/// +/// Introduced for SEC-40 to gate the alarm feed (AlarmsHub and the /alarms page), +/// which is gateway-central and session-less and therefore carries no per-session provenance the +/// SEC-25 tag ACL could scope it by. Requiring a real dashboard role claim is the narrowing that +/// is actually available: it keeps galaxy-wide alarm metadata — alarm reference (a full tag path), +/// state, severity, category, area, acknowledging operator — away from the read-only environment +/// bypasses, which exist for local development and unauthenticated deployments rather than as a +/// grant of plant-wide visibility. The same pattern the shared Secrets UI relies on: a policy the +/// dashboard bypass handler cannot satisfy because the requirement type differs. +/// +/// Roles any one of which satisfies the requirement. +public sealed class AuthenticatedDashboardRoleRequirement(IReadOnlyList requiredRoles) + : IAuthorizationRequirement +{ + /// Gets the roles, any one of which satisfies this requirement. + public IReadOnlyList RequiredRoles { get; } = requiredRoles; + + /// + /// Gets the requirement satisfied by an authenticated caller holding either dashboard role. + /// Same role set as ; the + /// difference is entirely that no environment bypass applies. + /// + public static AuthenticatedDashboardRoleRequirement AnyDashboardRole { get; } = + new([DashboardRoles.Admin, DashboardRoles.Viewer]); +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor index 2a18a07..76a2778 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor @@ -12,7 +12,14 @@ - + @* Gated on the SAME policy the /alarms page and AlarmsHub enforce (SEC-40), for the + same reason the Secrets link below is: the page denies an environment-bypass + caller outright, so an ungated link would be a dead end. *@ + + + + + diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor index d12ecbd..b85b34e 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/AlarmsPage.razor @@ -1,6 +1,15 @@ @page "/alarms" @implements IAsyncDisposable +@using Microsoft.AspNetCore.Authorization @using ZB.MOM.WW.MxGateway.Server.Alarms +@* SEC-40: the alarm feed needs a real authenticated dashboard role, not the read-only + environment bypasses (Authentication:Mode=Disabled, anonymous localhost) that satisfy the + ViewerPolicy MapRazorComponents() applies. This page reads the gateway-central alarm + monitor in process, exactly the surface AlarmsHub serves to external clients, so it carries the + SAME policy as the hub — gating only the hub would leave the disclosure one navigation away. + Alarm metadata stays galaxy-wide for an authenticated Viewer: the monitor has no per-session + provenance, so the SEC-25 tag ACL has nothing to intersect. *@ +@attribute [Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)] @inject IDashboardLiveDataService LiveData @inject IOptions GatewayOptions @inject IGatewayAlarmService AlarmService diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs index 7f00a59..1a74af2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs @@ -33,6 +33,18 @@ public static class DashboardAuthenticationDefaults /// public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients"; + /// + /// Policy guarding the alarm feed — the AlarmsHub SignalR surface and the + /// /alarms page that reads the same central monitor in process (SEC-40). Accepts the + /// same two schemes as but carries + /// , a requirement type + /// does not handle, so the environment bypasses + /// (Authentication:Mode=Disabled and anonymous localhost) cannot satisfy it. The alarm + /// feed is galaxy-wide and session-less, so unlike a session event stream there is no tag + /// intersection to narrow it with — requiring a real role claim is the available narrowing. + /// + public const string AlarmsPolicy = "MxGateway.Dashboard.Alarms"; + public const string LdapGroupClaimType = "mxgateway:ldap_group"; public const string KeyPrefixClaimType = "mxgateway:key_prefix"; @@ -40,9 +52,13 @@ public static class DashboardAuthenticationDefaults /// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped /// at cookie login by and at hub-token mint by /// , both resolving the caller's LDAP groups through - /// MxGateway:Dashboard:GroupToTag; read by . A - /// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed - /// default. Visibility only — it never grants data access. + /// MxGateway:Dashboard:GroupToTag. Since SEC-41 reads + /// this claim only as a FALLBACK, for a principal that carries no + /// claims to re-resolve from (the hub-token principal, whose + /// payload carries tags but not groups); a cookie principal is re-resolved against current + /// options instead, so a stamped tag cannot outlive a config-revoked grant. A principal + /// carrying neither kind of claim is an empty-grant Viewer, which is the fail-closed default. + /// Visibility only — it never grants data access. /// public const string DashboardTagClaimType = "zb:dashboardtag"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index f9367d9..5c753e7 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -177,8 +177,20 @@ public static class DashboardServiceCollectionExtensions DashboardAuthenticationDefaults.AuthenticationScheme, DashboardAuthenticationDefaults.HubAuthenticationScheme) .AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole)); + // SEC-40. Same two schemes as HubClientsPolicy (the alarm feed has an in-page reader + // and an external hub reader), but a requirement type DashboardAuthorizationHandler + // does not handle, so neither Authentication:Mode=Disabled nor anonymous localhost + // can satisfy it. Galaxy-wide alarm metadata therefore needs a real dashboard role. + authorization.AddPolicy( + DashboardAuthenticationDefaults.AlarmsPolicy, + policy => policy + .AddAuthenticationSchemes( + DashboardAuthenticationDefaults.AuthenticationScheme, + DashboardAuthenticationDefaults.HubAuthenticationScheme) + .AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole)); }); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs index 6a28aec..5d45c98 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSessionAcl.cs @@ -26,22 +26,53 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// (ordinal-ignore-case). /// /// -/// Granted tags are read from the caller's -/// claims, stamped at login () or at hub-token mint -/// (). A principal with no such claims — anonymous localhost included — -/// is an empty-grant Viewer. +/// Where the grant comes from (SEC-41). The grant is re-resolved on every call +/// from the caller's claims +/// through MxGateway:Dashboard:GroupToTag as it stands in the currently bound options — +/// the same single call +/// makes at token mint. It deliberately does +/// NOT trust the claims +/// stamped into the cookie at login: those were resolved under whatever map was bound when the +/// user signed in, and the auth cookie survives the gateway restart that applies a +/// GroupToTag edit (options bind once at startup; data-protection keys persist). Trusting +/// them let a revoked grant live on for the sliding 8-hour cookie life, renewable indefinitely by +/// activity, on the one seam — cookie-authenticated — that the 5-minute hub-token bound did not +/// cover. +/// +/// +/// Fallback. A principal carrying no group claims at all falls back to its +/// stamped tag claims. That arm exists for the hub-token principal: +/// re-resolves the grant at mint and rehydrates it as tag claims only — the token payload carries +/// no LDAP groups — so re-resolution has already happened for that caller, one token lifetime ago +/// at worst. The fallback cannot re-open the cookie hole it replaces, because a cookie principal +/// with a tag claim always also carries the group claims it was derived from +/// ( stamps both from one group list), and a login that +/// yielded no groups yields no tags either. A principal with neither kind of claim — anonymous +/// localhost included — is an empty-grant Viewer. +/// +/// +/// Revocation bounds this produces (mirrored in +/// docs/GatewayDashboardDesign.md): a GroupToTag entry removed from config takes +/// effect on the next subscribe after the restart that rebinds options, on both seams; an LDAP +/// group-membership revocation takes effect when the principal is rebuilt — at re-login (bounded +/// by the cookie's sliding 8-hour idle life) for the cookie seam, and within one 5-minute token +/// lifetime for the hub-token seam; an already-open subscription is never re-checked and lives +/// until it drops. /// /// /// This sits on the subscribe path, not the per-event path, and must stay cheap enough to -/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no -/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR +/// keep it there: the only per-call work is a claim scan, the group→tag map lookup, and a +/// session-registry lookup. A per-event re-check is deliberately not needed — a joined SignalR /// group and an in-process subscription are both per-session, and /// is immutable for the session's life, so the decision taken at subscribe time cannot go stale /// while the subscription lives. /// /// /// Registry the session id is resolved against. -/// Gateway options supplying Dashboard:UntaggedSessionVisibility. +/// +/// Gateway options supplying Dashboard:UntaggedSessionVisibility and the +/// Dashboard:GroupToTag map the caller's grant is re-resolved through. +/// public sealed class DashboardSessionAcl( ISessionManager sessionManager, IOptions options) : IDashboardSessionAcl @@ -64,14 +95,53 @@ public sealed class DashboardSessionAcl( return false; } + DashboardOptions dashboard = options.Value.Dashboard; + if (session.Tags.Count == 0) { - return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers; + return dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers; } - // Session.Tags is an ordinal-ignore-case set, so the containment test carries the - // comparison; scanning the claims (rather than materializing the grant) keeps this - // allocation-free beyond the claim enumerator. + return GrantIntersectsSessionTags(principal, session, dashboard); + } + + private static bool GrantIntersectsSessionTags( + ClaimsPrincipal principal, + GatewaySession session, + DashboardOptions dashboard) + { + // Collected rather than streamed because MapGroupsToTags takes the whole group list; + // the list stays null (no allocation) for the hub-token principal, which carries none. + List? groups = null; + foreach (Claim groupClaim in principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)) + { + (groups ??= []).Add(groupClaim.Value); + } + + if (groups is not null) + { + // SEC-41: current options, not the login-time snapshot. MapGroupsToTags returns an + // OrdinalIgnoreCase set, so probing it with the session's tags carries the + // case-insensitive comparison in the same direction the claim scan below does. + IReadOnlySet granted = DashboardGroupTagMapping.MapGroupsToTags( + groups, + dashboard.GroupToTag); + + foreach (string sessionTag in session.Tags) + { + if (granted.Contains(sessionTag)) + { + return true; + } + } + + return false; + } + + // No group claims: the hub-token arm documented on the class. Session.Tags is an + // ordinal-ignore-case set, so the containment test carries the comparison; scanning the + // claims (rather than materializing the grant) keeps this allocation-free beyond the + // claim enumerator. foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)) { if (session.Tags.Contains(tagClaim.Value)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs index dd22bb0..e0e68b2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHub.cs @@ -9,7 +9,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// on connect and receive every /// AlarmFeedMessage the monitor emits. /// -[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] +/// +/// The feed is galaxy-wide: the alarm monitor is gateway-central and its messages carry no +/// session provenance, so the SEC-25 per-session tag ACL that scopes EventsHub has nothing +/// to intersect here and is deliberately not applied — every authenticated Viewer sees the whole +/// subscription expression's alarm metadata. What SEC-40 tightened instead is the entry gate: +/// rather than the +/// the other hubs use, so the +/// read-only environment bypasses (Authentication:Mode=Disabled, anonymous localhost) no +/// longer receive the feed. The /alarms page carries the same policy — the two readers of +/// one monitor must not disagree about who may see it. +/// +[Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)] public sealed class AlarmsHub : Hub { public const string AllAlarmsGroup = "__alarms__"; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs index 7277985..db8368b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs @@ -22,6 +22,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; /// The source message is never mutated: it fans out from one feed to the gRPC /// StreamAlarms subscribers and the alarms page as well, and none of /// those audiences is subject to this dashboard-display flag. +/// +/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub +/// admission — carries +/// , so only an authenticated caller +/// with a real dashboard role is ever in — and the alarm +/// monitor is gateway-central with no per-session provenance, so there is no per-connection +/// narrowing this loop could apply even in principle. Adding a filter here would imply a +/// per-caller scope the feed does not carry. +/// /// /// The gateway's central alarm feed. /// Hub context used to broadcast to the alarms group. diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs index bcdba9c..1b4bbb8 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; @@ -13,7 +14,9 @@ using ZB.MOM.WW.Secrets.Ui; namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; /// -/// Renders and asserts whether the side rail emits the Secrets link. +/// Renders and asserts which policy-gated links the side rail emits — +/// Secrets (secrets:manage) and, since SEC-40, Alarms +/// (). /// /// /// @@ -39,6 +42,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; public sealed class SecretsNavRenderTests { private const string SecretsHref = "/admin/secrets"; + private const string AlarmsHref = "/alarms"; /// /// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered @@ -95,11 +99,48 @@ public sealed class SecretsNavRenderTests Assert.Contains("/apikeys", html, StringComparison.Ordinal); } + /// + /// SEC-40's nav half: an anonymous caller — the read-only localhost path — is not offered the + /// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets + /// negative above: this is the assertion that distinguishes a gate from an inert one. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_OmitsAlarmsLink_ForAnonymous() + { + string html = await RenderRailAsync(); + + Assert.DoesNotContain(AlarmsHref, html, StringComparison.Ordinal); + } + + /// + /// The control for that negative: an authenticated Viewer still gets the Alarms link. SEC-40 + /// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a + /// real dashboard role because the central monitor carries no per-session provenance. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_EmitsAlarmsLink_ForViewer() + { + string html = await RenderRailAsync(DashboardRoles.Viewer); + + Assert.Contains(AlarmsHref, html, StringComparison.Ordinal); + } + private static async Task RenderRailAsync(params string[] roles) { var services = new ServiceCollection(); services.AddLogging(); - services.AddAuthorization(options => options.AddSecretsAuthorization()); + services.AddAuthorization(options => + { + options.AddSecretsAuthorization(); + // The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws + // rather than denying, so it must be present for any rail render (SEC-40). + options.AddPolicy( + DashboardAuthenticationDefaults.AlarmsPolicy, + policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole)); + }); + services.AddSingleton(); services.AddCascadingAuthenticationState(); services.AddSingleton(new StubAuthenticationStateProvider(roles)); services.AddSingleton(); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmFeedAuthorizationTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmFeedAuthorizationTests.cs new file mode 100644 index 0000000..fb85b24 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/AlarmFeedAuthorizationTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Reflection; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.MxGateway.Server; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; +using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.AuthenticationOptions; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; + +/// +/// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only +/// environment bypasses (Authentication:Mode=Disabled, anonymous localhost) no longer +/// receive galaxy-wide alarm metadata. +/// +/// +/// The enforcement is a requirement TYPE distinction — +/// is an AuthorizationHandler<DashboardAuthorizationRequirement> and therefore never +/// runs for — so the tests below assert both +/// halves: that both handlers together still deny a bypassed caller, and that the policy the two +/// alarm surfaces actually carry is built from that requirement rather than the bypassable one. +/// Asserting only the handler would pass even if the hub were left on the old policy. +/// +public sealed class AlarmFeedAuthorizationTests +{ + /// + /// The bypass that satisfies + /// — auth disabled AND a loopback request with AllowAnonymousLocalhost on, i.e. both + /// arms at once — does not satisfy the alarm requirement, with the real bypass handler present + /// in the same evaluation. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task AlarmRequirement_AnonymousLocalhostAndAuthDisabled_DoesNotSucceed() + { + AuthorizationHandlerContext context = await EvaluateAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + AuthenticatedDashboardRoleRequirement.AnyDashboardRole); + + Assert.False(context.HasSucceeded); + } + + /// + /// Control: the SAME evaluation environment satisfies the bypassable requirement. Without this + /// pairing the test above could pass because the bypass was misconfigured rather than because + /// the requirement type excludes it. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task ViewerRequirement_AnonymousLocalhostAndAuthDisabled_StillSucceeds() + { + AuthorizationHandlerContext context = await EvaluateAsync( + new ClaimsPrincipal(new ClaimsIdentity()), + DashboardAuthorizationRequirement.AnyDashboardRole); + + Assert.True(context.HasSucceeded); + } + + /// + /// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the + /// alarm requirement: SEC-40 excludes the environment bypasses, not Viewers. Alarm metadata + /// stays galaxy-wide for them because the monitor is gateway-central and carries no + /// per-session provenance to tag-scope by. + /// + /// The dashboard role the authenticated caller holds. + /// A task that represents the asynchronous operation. + [Theory] + [InlineData(DashboardRoles.Viewer)] + [InlineData(DashboardRoles.Admin)] + public async Task AlarmRequirement_AuthenticatedDashboardRole_Succeeds(string role) + { + AuthorizationHandlerContext context = await EvaluateAsync( + CreatePrincipal(role), + AuthenticatedDashboardRoleRequirement.AnyDashboardRole); + + Assert.True(context.HasSucceeded); + } + + /// + /// A principal carrying a dashboard role claim but no authentication type does not satisfy the + /// requirement: "real role claim" means an authenticated identity, matching how the session + /// ACL and DashboardSessionAdminService read a role. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task AlarmRequirement_UnauthenticatedRoleClaim_DoesNotSucceed() + { + ClaimsPrincipal principal = new(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, DashboardRoles.Admin)], + authenticationType: null, + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role)); + + AuthorizationHandlerContext context = await EvaluateAsync( + principal, + AuthenticatedDashboardRoleRequirement.AnyDashboardRole); + + Assert.False(context.HasSucceeded); + } + + /// + /// The registered policy is built from the non-bypassable requirement and from neither + /// nor a role list the bypass handler could + /// reach, and it still accepts both hub-capable schemes so an external hub client can + /// authenticate with a bearer token. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task AlarmsPolicy_IsRegisteredWithNonBypassableRequirement() + { + await using WebApplication app = GatewayApplication.Build([]); + IAuthorizationPolicyProvider provider = app.Services + .GetRequiredService(); + + AuthorizationPolicy? policy = await provider + .GetPolicyAsync(DashboardAuthenticationDefaults.AlarmsPolicy); + + Assert.NotNull(policy); + Assert.Contains(policy.Requirements, r => r is AuthenticatedDashboardRoleRequirement); + Assert.DoesNotContain(policy.Requirements, r => r is DashboardAuthorizationRequirement); + Assert.Equal( + [ + DashboardAuthenticationDefaults.AuthenticationScheme, + DashboardAuthenticationDefaults.HubAuthenticationScheme, + ], + policy.AuthenticationSchemes); + } + + /// + /// Both readers of the one central monitor carry the alarm policy: the hub (external clients) + /// and the /alarms Razor endpoint (the in-page reader). Gating only the hub would leave + /// the same metadata one navigation away for a bypassed caller. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task AlarmsHubAndAlarmsPage_BothCarryTheAlarmsPolicy() + { + AuthorizeAttribute? hubAttribute = typeof(AlarmsHub) + .GetCustomAttribute(inherit: false); + + Assert.Equal(DashboardAuthenticationDefaults.AlarmsPolicy, hubAttribute?.Policy); + + await using WebApplication app = GatewayApplication.Build([]); + RouteEndpoint alarmsPage = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .Single(endpoint => endpoint.RoutePattern.RawText == "/alarms"); + + Assert.Contains( + alarmsPage.Metadata.OfType(), + data => data.Policy == DashboardAuthenticationDefaults.AlarmsPolicy); + } + + /// + /// Runs BOTH dashboard authorization handlers over one requirement in the most permissive + /// environment the gateway supports (auth disabled, loopback, anonymous localhost allowed), + /// mirroring how the authorization service invokes every registered handler. + /// + /// The caller. + /// The single requirement under evaluation. + /// The context after both handlers have run. + private static async Task EvaluateAsync( + ClaimsPrincipal principal, + IAuthorizationRequirement requirement) + { + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; + + IOptions options = Options.Create(new GatewayOptions + { + Authentication = new AuthenticationOptions { Mode = AuthenticationMode.Disabled }, + Dashboard = new DashboardOptions { AllowAnonymousLocalhost = true }, + }); + + AuthorizationHandlerContext context = new([requirement], principal, httpContext); + + await new DashboardAuthorizationHandler( + new HttpContextAccessor { HttpContext = httpContext }, + options).HandleAsync(context); + await new AuthenticatedDashboardRoleHandler().HandleAsync(context); + + return context; + } + + private static ClaimsPrincipal CreatePrincipal(string role) => + new(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, role)], + DashboardAuthenticationDefaults.AuthenticationScheme, + ClaimTypes.Name, + ClaimTypes.Role)); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs index 91411d8..5d872ca 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSessionAclTests.cs @@ -178,12 +178,112 @@ public sealed class DashboardSessionAclTests Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId)); } + /// + /// SEC-41, the load-bearing regression: a cookie-style principal whose tag claim was stamped + /// at login under an older GroupToTag map is DENIED once that map no longer grants the + /// tag to any group the principal belongs to. The auth cookie survives the restart that + /// rebinds options, so trusting the stamped claim let a config-revoked grant live on for the + /// sliding 8-hour cookie life; the ACL now re-resolves from the group claims the same + /// principal carries, against the currently bound map. + /// + [Fact] + public void CanViewSession_StampedTagClaimRevokedFromCurrentGroupToTag_Denied() + { + // Current config grants team-a to nobody the caller belongs to: "plant-ops" was the group + // whose entry was removed, and the surviving entry is for an unrelated group. + DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["other-team"] = ["team-b"] }); + + ClaimsPrincipal stale = Principal( + roles: [DashboardRoles.Viewer], + tags: ["team-a"], + groups: ["plant-ops"]); + + Assert.False(acl.CanViewSession(stale, TaggedSessionId)); + } + + /// + /// The re-resolution's allowing direction: the same principal is allowed when the current map + /// still grants the session's tag to one of its groups — and is allowed even when it carries + /// NO stamped tag claim at all, which is what proves the decision comes from the group claims + /// rather than from the stamped ones. + /// + [Fact] + public void CanViewSession_GroupClaimGrantsTagUnderCurrentMap_Allowed() + { + DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["plant-ops"] = ["team-a"] }); + + Assert.True(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]), + TaggedSessionId)); + + Assert.True(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]), + TaggedSessionId)); + } + + /// + /// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which + /// owns) and the resolved tag against the session's tag + /// set. Pinned because the pre-SEC-41 claim scan got the second comparison from + /// Session.Tags's own comparer, and the re-resolution arm probes the grant set in the + /// opposite direction. + /// + [Fact] + public void CanViewSession_GroupAndTagCasingDiffer_Allowed() + { + DashboardSessionAcl acl = CreateAcl(groupToTag: new() { ["PLANT-OPS"] = ["TEAM-A"] }); + + Assert.True(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: null, groups: ["plant-ops"]), + TaggedSessionId)); + } + + /// + /// A principal carrying group claims that map to nothing is an empty-grant Viewer, even though + /// it holds a stamped tag claim: once there is a group list to re-resolve from, the stamped + /// claims are not consulted at all. This is the arm that closes the revocation gap for a user + /// whose LDAP groups changed shape rather than whose config entry was removed. + /// + [Fact] + public void CanViewSession_GroupClaimsPresentButUnmapped_IgnoresStampedTagClaim() + { + DashboardSessionAcl acl = CreateAcl(groupToTag: []); + + Assert.False(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: ["plant-ops"]), + TaggedSessionId)); + } + + /// + /// The documented fallback: a hub-token principal carries the tags + /// re-resolved at mint but no LDAP group claims to re-resolve from, so its stamped tag claims + /// are honoured. Asserted with an EMPTY current map, which is the state that would deny if the + /// fallback were ever dropped — and would silently break every token-authenticated hub join. + /// + [Fact] + public void CanViewSession_HubTokenStylePrincipalWithNoGroupClaims_UsesStampedTags() + { + DashboardSessionAcl acl = CreateAcl(groupToTag: []); + + Assert.True(acl.CanViewSession( + Principal(roles: [DashboardRoles.Viewer], tags: ["team-a"], groups: null), + TaggedSessionId)); + } + private static DashboardSessionAcl CreateAcl( - UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly) + UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly, + Dictionary? groupToTag = null) { GatewayOptions options = new() { - Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility }, + Dashboard = new DashboardOptions + { + UntaggedSessionVisibility = visibility, + // Mirrors the binder's comparer so a test's group key behaves like a config one. + GroupToTag = groupToTag is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(groupToTag, StringComparer.OrdinalIgnoreCase), + }, }; return new DashboardSessionAcl( @@ -193,13 +293,26 @@ public sealed class DashboardSessionAclTests Options.Create(options)); } - private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null) + /// + /// Builds a dashboard principal. is the SEC-41 discriminator: a + /// principal WITH group claims is re-resolved against the current map (the cookie seam), one + /// WITHOUT falls back to its stamped tag claims (the hub-token seam). Existing cases pass + /// tags only, so they continue to exercise the fallback arm exactly as before. + /// + /// Role claims to stamp. + /// Login-stamped visibility tag claims, if any. + /// LDAP group claims, if any. + /// An authenticated principal carrying the requested claims. + private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null, string[]? groups = null) { List claims = [new Claim(ClaimTypes.Name, "viewer-user")]; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); claims.AddRange((tags ?? []).Select(tag => new Claim( DashboardAuthenticationDefaults.DashboardTagClaimType, tag))); + claims.AddRange((groups ?? []).Select(group => new Claim( + DashboardAuthenticationDefaults.LdapGroupClaimType, + group))); return new ClaimsPrincipal(new ClaimsIdentity( claims,