Merge fix/c3-dashboard-acl: SEC-41 ACL re-resolves tag grants from group claims, SEC-40 alarms hub/page behind non-bypassable authenticated-role policy, GWC-38 browse scope fails closed without identity under ApiKey mode
This commit is contained in:
@@ -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.
|
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
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ Supported constraints are:
|
|||||||
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
|
| `read_historized_only` | Read/subscription commands must target objects with historized attributes. |
|
||||||
| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. |
|
| `dashboard_tags` | Dashboard event-visibility tags. **Not a data-access constraint** — see below. |
|
||||||
|
|
||||||
|
`browse_subtrees` is applied by `GatewayBrowseScopeProvider`, the host seam the
|
||||||
|
shared `ZB.MOM.WW.GalaxyRepository` package calls through `IGalaxyBrowseScopeProvider`;
|
||||||
|
it reads the constraint off the ambient per-call identity that
|
||||||
|
`GatewayGrpcAuthorizationInterceptor` pushed earlier in the same call. An empty
|
||||||
|
`browse_subtrees` list is the library's "no scoping" signal, so the provider has to
|
||||||
|
distinguish two states that look alike (GWC-38): an authenticated key with no browse
|
||||||
|
constraint is legitimately unscoped and returns the empty list, whereas **no ambient
|
||||||
|
identity at all** means the interceptor did not run before the Galaxy service and the
|
||||||
|
request's scope cannot be established. In that second case, with
|
||||||
|
`MxGateway:Authentication:Mode = ApiKey`, the provider logs a warning naming the broken
|
||||||
|
ordering invariant and fails the call with `PermissionDenied` rather than serving the full
|
||||||
|
hierarchy — a narrowing control's silent absence is invisible in normal operation, so it is
|
||||||
|
made loud instead. Under `Mode = Disabled` an absent identity is the expected steady state
|
||||||
|
(nothing authenticates, so no key and no constraints exist) and browse stays unscoped.
|
||||||
|
|
||||||
`dashboard_tags` is the one member of the blob that constrains nothing on the
|
`dashboard_tags` is the one member of the blob that constrains nothing on the
|
||||||
gRPC data path. No read, write, browse, or subscribe check consults it, and
|
gRPC data path. No read, write, browse, or subscribe check consults it, and
|
||||||
`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag
|
`HasReadConstraints` / `HasWriteConstraints` deliberately ignore it: adding a tag
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
|||||||
| Option | Default | Description |
|
| 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: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: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: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. |
|
| `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: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: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<string>` 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: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<string>` 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: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: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`. |
|
| `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`. |
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
|
|||||||
| Hub | Path | Producer | Payload | Routing |
|
| 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`. |
|
| `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. |
|
| `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
|
### 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
|
false the central monitor never starts, and the page says so instead of showing
|
||||||
an empty list with no explanation.
|
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
|
### Live data source
|
||||||
|
|
||||||
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
Both the Browse subscription panel and the Alarms page read live MXAccess data
|
||||||
@@ -706,14 +714,25 @@ Implemented behavior:
|
|||||||
generic credential-rejected message;
|
generic credential-rejected message;
|
||||||
- antiforgery tokens guard the login and logout POSTs.
|
- 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
|
- `MxGateway.Dashboard.Viewer` — Razor component routes. Satisfied by Admin or
|
||||||
Viewer.
|
Viewer.
|
||||||
- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD).
|
- `MxGateway.Dashboard.Admin` — Admin-only write surfaces (API-key CRUD).
|
||||||
- `MxGateway.Dashboard.HubClients` — SignalR hubs. Accepts the dashboard
|
- `MxGateway.Dashboard.HubClients` — the snapshot and events SignalR hubs. Accepts
|
||||||
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
|
the dashboard cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket
|
||||||
where the cookie can't be forwarded).
|
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:
|
The in-process page feeds carry no authentication of their own, and need none:
|
||||||
`MapRazorComponents<App>()` applies `RequireAuthorization(ViewerPolicy)` to the
|
`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
|
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
|
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
|
no per-event re-check is needed. A Viewer's grant comes from
|
||||||
`Dashboard:GroupToTag` applied to their LDAP groups, stamped as
|
`Dashboard:GroupToTag` applied to their LDAP groups. Since SEC-41 the ACL **re-resolves**
|
||||||
`zb:dashboardtag` claims at cookie login and re-resolved (not copied) at hub-token
|
that grant on every subscribe, from the caller's `mxgateway:ldap_group` claims against the
|
||||||
mint, so the token's 5-minute lifetime bounds how long a revoked grant survives. A
|
currently bound `GroupToTag` map — the same one `DashboardGroupTagMapping.MapGroupsToTags`
|
||||||
principal carrying no tag claims — anonymous localhost included — is an
|
call the hub-token mint makes — rather than trusting the `zb:dashboardtag` claims stamped
|
||||||
empty-grant Viewer. The mirror's value redaction is an independent layer: it bounds
|
into the cookie at login. Those stamped claims were resolved under whatever map was bound
|
||||||
what a permitted subscriber sees, not who may subscribe.
|
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:
|
Two environmental bypasses still apply, both scoped to **read-only** access:
|
||||||
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
`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
|
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
||||||
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
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
|
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
|
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
||||||
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
||||||
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
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
|
### DisableLogin dev bypass
|
||||||
|
|
||||||
`MxGateway:Dashboard:DisableLogin` (default `false`) is a third bypass for
|
`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
|
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
|
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
|
`GroupToTag` entry takes effect for token-authenticated hub connections within one
|
||||||
lifetime. That 5-minute staleness bound covers token-authenticated connections only:
|
lifetime. Since SEC-41 the cookie seam is no longer the weaker one: the ACL
|
||||||
a cookie principal carries the `zb:dashboardtag` claims stamped at login for the
|
re-resolves a cookie principal's grant from its group claims against current
|
||||||
cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a
|
options on every subscribe, so a `GroupToTag` edit takes effect on both seams at
|
||||||
revoked `GroupToTag` grant takes effect at the user's next login, not within five
|
the next subscribe after the restart that rebinds options — the stamped
|
||||||
minutes. That is where the per-session ACL's revocation need landed — a jti
|
`zb:dashboardtag` claims are consulted only for a principal with no group claims
|
||||||
denylist stays deferred, since the short lifetime already bounds every grant the
|
to re-resolve from, i.e. the hub-token principal itself. What the token's lifetime
|
||||||
token carries.
|
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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for <see cref="AuthenticatedDashboardRoleRequirement"/>: 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthenticatedDashboardRoleHandler
|
||||||
|
: AuthorizationHandler<AuthenticatedDashboardRoleRequirement>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requirement satisfied ONLY by an authenticated principal holding one of
|
||||||
|
/// <see cref="RequiredRoles"/> as a real role claim. Unlike
|
||||||
|
/// <see cref="DashboardAuthorizationRequirement"/> there is no environment bypass: it is a
|
||||||
|
/// distinct requirement type, so <see cref="DashboardAuthorizationHandler"/> —
|
||||||
|
/// an <c>AuthorizationHandler<DashboardAuthorizationRequirement></c> — is never invoked for
|
||||||
|
/// it and its <c>Authentication:Mode=Disabled</c> / 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Introduced for SEC-40 to gate the alarm feed (<c>AlarmsHub</c> and the <c>/alarms</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="requiredRoles">Roles any one of which satisfies the requirement.</param>
|
||||||
|
public sealed class AuthenticatedDashboardRoleRequirement(IReadOnlyList<string> requiredRoles)
|
||||||
|
: IAuthorizationRequirement
|
||||||
|
{
|
||||||
|
/// <summary>Gets the roles, any one of which satisfies this requirement.</summary>
|
||||||
|
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the requirement satisfied by an authenticated caller holding either dashboard role.
|
||||||
|
/// Same role set as <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>; the
|
||||||
|
/// difference is entirely that no environment bypass applies.
|
||||||
|
/// </summary>
|
||||||
|
public static AuthenticatedDashboardRoleRequirement AnyDashboardRole { get; } =
|
||||||
|
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
|
||||||
|
}
|
||||||
@@ -12,7 +12,14 @@
|
|||||||
<NavRailItem Href="/sessions" Text="Sessions" />
|
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||||
<NavRailItem Href="/workers" Text="Workers" />
|
<NavRailItem Href="/workers" Text="Workers" />
|
||||||
<NavRailItem Href="/events" Text="Events" />
|
<NavRailItem Href="/events" Text="Events" />
|
||||||
<NavRailItem Href="/alarms" Text="Alarms" />
|
@* 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. *@
|
||||||
|
<AuthorizeView Policy="@DashboardAuthenticationDefaults.AlarmsPolicy">
|
||||||
|
<Authorized>
|
||||||
|
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
</NavRailSection>
|
</NavRailSection>
|
||||||
<NavRailSection Title="Galaxy" Key="galaxy">
|
<NavRailSection Title="Galaxy" Key="galaxy">
|
||||||
<NavRailItem Href="/galaxy" Text="Repository" />
|
<NavRailItem Href="/galaxy" Text="Repository" />
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
@page "/alarms"
|
@page "/alarms"
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Alarms
|
@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<App>() 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 IDashboardLiveDataService LiveData
|
||||||
@inject IOptions<GatewayOptions> GatewayOptions
|
@inject IOptions<GatewayOptions> GatewayOptions
|
||||||
@inject IGatewayAlarmService AlarmService
|
@inject IGatewayAlarmService AlarmService
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ public static class DashboardAuthenticationDefaults
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
|
public const string HubClientsPolicy = "MxGateway.Dashboard.HubClients";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Policy guarding the alarm feed — the <c>AlarmsHub</c> SignalR surface and the
|
||||||
|
/// <c>/alarms</c> page that reads the same central monitor in process (SEC-40). Accepts the
|
||||||
|
/// same two schemes as <see cref="HubClientsPolicy"/> but carries
|
||||||
|
/// <see cref="AuthenticatedDashboardRoleRequirement"/>, a requirement type
|
||||||
|
/// <see cref="DashboardAuthorizationHandler"/> does not handle, so the environment bypasses
|
||||||
|
/// (<c>Authentication:Mode=Disabled</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public const string AlarmsPolicy = "MxGateway.Dashboard.Alarms";
|
||||||
|
|
||||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
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
|
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
|
||||||
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
|
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
|
||||||
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
|
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
|
||||||
/// <c>MxGateway:Dashboard:GroupToTag</c>; read by <see cref="IDashboardSessionAcl"/>. A
|
/// <c>MxGateway:Dashboard:GroupToTag</c>. Since SEC-41 <see cref="IDashboardSessionAcl"/> reads
|
||||||
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
|
/// this claim only as a FALLBACK, for a principal that carries no
|
||||||
/// default. Visibility only — it never grants data access.
|
/// <see cref="LdapGroupClaimType"/> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string DashboardTagClaimType = "zb:dashboardtag";
|
public const string DashboardTagClaimType = "zb:dashboardtag";
|
||||||
|
|
||||||
|
|||||||
@@ -104,9 +104,11 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
.WithName("DashboardAccessDenied");
|
.WithName("DashboardAccessDenied");
|
||||||
|
|
||||||
// SignalR hubs. Authorization is enforced on the hub class via
|
// SignalR hubs. Authorization is enforced on the hub class via [Authorize], so both
|
||||||
// [Authorize(Policy = HubClientsPolicy)] so the policy accepts either
|
// policies below accept either the dashboard cookie or a HubToken bearer. Snapshot and
|
||||||
// the dashboard cookie or a HubToken bearer.
|
// events use HubClientsPolicy; AlarmsHub uses AlarmsPolicy, which additionally requires a
|
||||||
|
// real authenticated dashboard role (SEC-40) — see AlarmsHub for why the alarm feed is
|
||||||
|
// gated harder than the session-scoped ones.
|
||||||
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
endpoints.MapHub<DashboardSnapshotHub>("/hubs/snapshot");
|
||||||
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
endpoints.MapHub<AlarmsHub>("/hubs/alarms");
|
||||||
endpoints.MapHub<EventsHub>("/hubs/events");
|
endpoints.MapHub<EventsHub>("/hubs/events");
|
||||||
|
|||||||
@@ -177,8 +177,20 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
DashboardAuthenticationDefaults.HubAuthenticationScheme)
|
||||||
.AddRequirements(DashboardAuthorizationRequirement.AnyDashboardRole));
|
.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<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||||
|
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,22 +26,53 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// (ordinal-ignore-case).</description></item>
|
/// (ordinal-ignore-case).</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
|
/// <strong>Where the grant comes from (SEC-41).</strong> The grant is re-resolved on every call
|
||||||
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
|
/// from the caller's <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/> claims
|
||||||
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
|
/// through <c>MxGateway:Dashboard:GroupToTag</c> as it stands in the currently bound options —
|
||||||
/// is an empty-grant Viewer.
|
/// the same single <see cref="DashboardGroupTagMapping.MapGroupsToTags"/> call
|
||||||
|
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> makes at token mint. It deliberately does
|
||||||
|
/// NOT trust the <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> 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
|
||||||
|
/// <c>GroupToTag</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Fallback.</strong> A principal carrying no group claims at all falls back to its
|
||||||
|
/// stamped tag claims. That arm exists for the hub-token principal: <see cref="HubTokenService"/>
|
||||||
|
/// 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
|
||||||
|
/// (<see cref="DashboardAuthenticator"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Revocation bounds this produces</strong> (mirrored in
|
||||||
|
/// <c>docs/GatewayDashboardDesign.md</c>): a <c>GroupToTag</c> 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.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
|
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
|
||||||
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
|
/// keep it there: the only per-call work is a claim scan, the group→tag map lookup, and a
|
||||||
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
|
/// 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 <see cref="GatewaySession.Tags"/>
|
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
|
||||||
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
|
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
|
||||||
/// while the subscription lives.
|
/// while the subscription lives.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sessionManager">Registry the session id is resolved against.</param>
|
/// <param name="sessionManager">Registry the session id is resolved against.</param>
|
||||||
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
|
/// <param name="options">
|
||||||
|
/// Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c> and the
|
||||||
|
/// <c>Dashboard:GroupToTag</c> map the caller's grant is re-resolved through.
|
||||||
|
/// </param>
|
||||||
public sealed class DashboardSessionAcl(
|
public sealed class DashboardSessionAcl(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IOptions<GatewayOptions> options) : IDashboardSessionAcl
|
IOptions<GatewayOptions> options) : IDashboardSessionAcl
|
||||||
@@ -64,14 +95,53 @@ public sealed class DashboardSessionAcl(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DashboardOptions dashboard = options.Value.Dashboard;
|
||||||
|
|
||||||
if (session.Tags.Count == 0)
|
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
|
return GrantIntersectsSessionTags(principal, session, dashboard);
|
||||||
// comparison; scanning the claims (rather than materializing the grant) keeps this
|
}
|
||||||
// allocation-free beyond the claim enumerator.
|
|
||||||
|
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<string>? 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<string> 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))
|
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
|
||||||
{
|
{
|
||||||
if (session.Tags.Contains(tagClaim.Value))
|
if (session.Tags.Contains(tagClaim.Value))
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
|||||||
/// <see cref="AllAlarmsGroup"/> on connect and receive every
|
/// <see cref="AllAlarmsGroup"/> on connect and receive every
|
||||||
/// <c>AlarmFeedMessage</c> the monitor emits.
|
/// <c>AlarmFeedMessage</c> the monitor emits.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
|
/// <remarks>
|
||||||
|
/// 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 <c>EventsHub</c> 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:
|
||||||
|
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/> rather than the
|
||||||
|
/// <see cref="DashboardAuthenticationDefaults.HubClientsPolicy"/> the other hubs use, so the
|
||||||
|
/// read-only environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no
|
||||||
|
/// longer receive the feed. The <c>/alarms</c> page carries the same policy — the two readers of
|
||||||
|
/// one monitor must not disagree about who may see it.
|
||||||
|
/// </remarks>
|
||||||
|
[Authorize(Policy = DashboardAuthenticationDefaults.AlarmsPolicy)]
|
||||||
public sealed class AlarmsHub : Hub
|
public sealed class AlarmsHub : Hub
|
||||||
{
|
{
|
||||||
public const string AllAlarmsGroup = "__alarms__";
|
public const string AllAlarmsGroup = "__alarms__";
|
||||||
|
|||||||
@@ -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
|
/// The source message is never mutated: it fans out from one feed to the gRPC
|
||||||
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
||||||
/// those audiences is subject to this dashboard-display flag.
|
/// those audiences is subject to this dashboard-display flag.
|
||||||
|
/// <para>
|
||||||
|
/// The broadcast stays deliberately unfiltered (SEC-40). Audience is decided once, at hub
|
||||||
|
/// admission — <see cref="AlarmsHub"/> carries
|
||||||
|
/// <see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>, so only an authenticated caller
|
||||||
|
/// with a real dashboard role is ever in <see cref="AlarmsHub.AllAlarmsGroup"/> — 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.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="alarmService">The gateway's central alarm feed.</param>
|
/// <param name="alarmService">The gateway's central alarm feed.</param>
|
||||||
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
|
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ public static class GatewayApplication
|
|||||||
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
|
||||||
// Register the gateway's browse-scope provider before AddZbGalaxyRepository so the
|
// Register the gateway's browse-scope provider before AddZbGalaxyRepository so the
|
||||||
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
|
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
|
||||||
|
// The provider depends on GatewayGrpcAuthorizationInterceptor (registered globally above)
|
||||||
|
// having pushed the ambient identity before the library's galaxy service runs. That
|
||||||
|
// ordering is still required, but it is no longer trusted silently: with
|
||||||
|
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
||||||
|
// invariant when the identity is absent, instead of resolving to the full hierarchy
|
||||||
|
// (GWC-38). Under Mode=Disabled identity-absent is expected and stays unscoped.
|
||||||
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
||||||
Security.Authorization.GatewayBrowseScopeProvider>();
|
Security.Authorization.GatewayBrowseScopeProvider>();
|
||||||
|
|
||||||
|
|||||||
+53
-7
@@ -1,26 +1,72 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
/// <summary>Scopes Galaxy browse results to the calling API key's BrowseSubtrees constraint.</summary>
|
||||||
public sealed class GatewayBrowseScopeProvider(IGatewayRequestIdentityAccessor identityAccessor)
|
/// <remarks>
|
||||||
|
/// Distinguishes two states the pre-GWC-38 code collapsed into one: "authenticated, no
|
||||||
|
/// BrowseSubtrees constraint" (legitimately unscoped — the common case) and "no ambient identity
|
||||||
|
/// at all" (the interceptor-ordering invariant below is broken). The second used to resolve to the
|
||||||
|
/// full hierarchy silently, which made a scoping bypass invisible; it now denies loudly.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="identityAccessor">Accessor for the ambient per-call API-key identity.</param>
|
||||||
|
/// <param name="options">Gateway options supplying <c>MxGateway:Authentication:Mode</c>.</param>
|
||||||
|
/// <param name="logger">Logger used to name the broken invariant when identity is absent.</param>
|
||||||
|
public sealed class GatewayBrowseScopeProvider(
|
||||||
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
ILogger<GatewayBrowseScopeProvider> logger)
|
||||||
: IGalaxyBrowseScopeProvider
|
: IGalaxyBrowseScopeProvider
|
||||||
{
|
{
|
||||||
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
|
/// <summary>Resolves the Galaxy browse subtrees the calling API key is scoped to, from the ambient request identity.</summary>
|
||||||
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
|
/// <param name="context">The gRPC server call context for the in-flight browse request.</param>
|
||||||
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
|
/// <returns>The caller's configured browse subtrees, or <see langword="null"/>/empty when the caller is unscoped (full hierarchy).</returns>
|
||||||
|
/// <exception cref="RpcException">
|
||||||
|
/// <see cref="StatusCode.PermissionDenied"/> when API-key authentication is enabled but no
|
||||||
|
/// ambient identity is present — the invariant described below has been broken and the
|
||||||
|
/// request's scope cannot be established.
|
||||||
|
/// </exception>
|
||||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
|
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context)
|
||||||
{
|
{
|
||||||
// Invariant: the caller identity is the ambient one pushed by
|
// Invariant: the caller identity is the ambient one pushed by
|
||||||
// GatewayGrpcAuthorizationInterceptor earlier in this same call. That interceptor is
|
// GatewayGrpcAuthorizationInterceptor earlier in this same call. That interceptor is
|
||||||
// registered globally and therefore runs before the library's galaxy service, so
|
// registered globally and therefore runs before the library's galaxy service, so
|
||||||
// Current is populated for any authenticated galaxy RPC. If it is somehow absent we
|
// Current is populated for any authenticated galaxy RPC. Do not reorder the interceptor
|
||||||
// return empty BrowseSubtrees, which the library treats as "no scoping" (full
|
// without revisiting this.
|
||||||
// hierarchy) — safe only because the global interceptor has already authenticated and
|
ApiKeyIdentity? identity = identityAccessor.Current;
|
||||||
// authorized the request first. Do not reorder the interceptor without revisiting this.
|
if (identity is not null)
|
||||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
{
|
||||||
return constraints.BrowseSubtrees;
|
return identity.EffectiveConstraints.BrowseSubtrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode=Disabled is the one configuration in which identity-absent is expected rather than
|
||||||
|
// broken: nothing authenticates, so no key and no constraints exist to scope by, and the
|
||||||
|
// deployment has already accepted an unauthenticated gRPC surface. Empty BrowseSubtrees is
|
||||||
|
// what the library reads as "no scoping".
|
||||||
|
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
return ApiKeyConstraints.Empty.BrowseSubtrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication is on, so an identity MUST have been pushed before the galaxy service ran.
|
||||||
|
// Failing open here would hand a browse-scoped key the full hierarchy with nothing logged
|
||||||
|
// and nothing failing — a constraint-narrowing control's absence is invisible in normal
|
||||||
|
// operation. Deny instead, and name the invariant so the misconfiguration is diagnosable
|
||||||
|
// from one log line rather than from a scoping audit nobody thought to run.
|
||||||
|
logger.LogWarning(
|
||||||
|
"Galaxy browse scope could not be resolved for {Method}: no ambient API-key identity "
|
||||||
|
+ "while Authentication:Mode is {Mode}. GatewayGrpcAuthorizationInterceptor must run "
|
||||||
|
+ "before the Galaxy service on every call; the request is denied rather than served "
|
||||||
|
+ "unscoped.",
|
||||||
|
context.Method,
|
||||||
|
options.Value.Authentication.Mode);
|
||||||
|
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.PermissionDenied,
|
||||||
|
"Browse scope could not be established for this request."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
@@ -13,7 +14,9 @@ using ZB.MOM.WW.Secrets.Ui;
|
|||||||
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders <see cref="MainLayout"/> and asserts whether the side rail emits the Secrets link.
|
/// Renders <see cref="MainLayout"/> and asserts which policy-gated links the side rail emits —
|
||||||
|
/// Secrets (<c>secrets:manage</c>) and, since SEC-40, Alarms
|
||||||
|
/// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -39,6 +42,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
|
|||||||
public sealed class SecretsNavRenderTests
|
public sealed class SecretsNavRenderTests
|
||||||
{
|
{
|
||||||
private const string SecretsHref = "/admin/secrets";
|
private const string SecretsHref = "/admin/secrets";
|
||||||
|
private const string AlarmsHref = "/alarms";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered
|
/// 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);
|
Assert.Contains("/apikeys", html, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Rail_OmitsAlarmsLink_ForAnonymous()
|
||||||
|
{
|
||||||
|
string html = await RenderRailAsync();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(AlarmsHref, html, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Rail_EmitsAlarmsLink_ForViewer()
|
||||||
|
{
|
||||||
|
string html = await RenderRailAsync(DashboardRoles.Viewer);
|
||||||
|
|
||||||
|
Assert.Contains(AlarmsHref, html, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<string> RenderRailAsync(params string[] roles)
|
private static async Task<string> RenderRailAsync(params string[] roles)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddLogging();
|
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<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
|
||||||
services.AddCascadingAuthenticationState();
|
services.AddCascadingAuthenticationState();
|
||||||
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider(roles));
|
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider(roles));
|
||||||
services.AddSingleton<NavigationManager, StubNavigationManager>();
|
services.AddSingleton<NavigationManager, StubNavigationManager>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only
|
||||||
|
/// environment bypasses (<c>Authentication:Mode=Disabled</c>, anonymous localhost) no longer
|
||||||
|
/// receive galaxy-wide alarm metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The enforcement is a requirement TYPE distinction — <see cref="DashboardAuthorizationHandler"/>
|
||||||
|
/// is an <c>AuthorizationHandler<DashboardAuthorizationRequirement></c> and therefore never
|
||||||
|
/// runs for <see cref="AuthenticatedDashboardRoleRequirement"/> — 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AlarmFeedAuthorizationTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The bypass that satisfies <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>
|
||||||
|
/// — auth disabled AND a loopback request with <c>AllowAnonymousLocalhost</c> on, i.e. both
|
||||||
|
/// arms at once — does not satisfy the alarm requirement, with the real bypass handler present
|
||||||
|
/// in the same evaluation.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task AlarmRequirement_AnonymousLocalhostAndAuthDisabled_DoesNotSucceed()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
AuthenticatedDashboardRoleRequirement.AnyDashboardRole);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task ViewerRequirement_AnonymousLocalhostAndAuthDisabled_StillSucceeds()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await EvaluateAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
DashboardAuthorizationRequirement.AnyDashboardRole);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="role">The dashboard role the authenticated caller holds.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>DashboardSessionAdminService</c> read a role.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The registered policy is built from the non-bypassable requirement and from neither
|
||||||
|
/// <see cref="DashboardAuthorizationRequirement"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task AlarmsPolicy_IsRegisteredWithNonBypassableRequirement()
|
||||||
|
{
|
||||||
|
await using WebApplication app = GatewayApplication.Build([]);
|
||||||
|
IAuthorizationPolicyProvider provider = app.Services
|
||||||
|
.GetRequiredService<IAuthorizationPolicyProvider>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Both readers of the one central monitor carry the alarm policy: the hub (external clients)
|
||||||
|
/// and the <c>/alarms</c> Razor endpoint (the in-page reader). Gating only the hub would leave
|
||||||
|
/// the same metadata one navigation away for a bypassed caller.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task AlarmsHubAndAlarmsPage_BothCarryTheAlarmsPolicy()
|
||||||
|
{
|
||||||
|
AuthorizeAttribute? hubAttribute = typeof(AlarmsHub)
|
||||||
|
.GetCustomAttribute<AuthorizeAttribute>(inherit: false);
|
||||||
|
|
||||||
|
Assert.Equal(DashboardAuthenticationDefaults.AlarmsPolicy, hubAttribute?.Policy);
|
||||||
|
|
||||||
|
await using WebApplication app = GatewayApplication.Build([]);
|
||||||
|
RouteEndpoint alarmsPage = ((IEndpointRouteBuilder)app).DataSources
|
||||||
|
.SelectMany(source => source.Endpoints)
|
||||||
|
.OfType<RouteEndpoint>()
|
||||||
|
.Single(endpoint => endpoint.RoutePattern.RawText == "/alarms");
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
alarmsPage.Metadata.OfType<IAuthorizeData>(),
|
||||||
|
data => data.Policy == DashboardAuthenticationDefaults.AlarmsPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="principal">The caller.</param>
|
||||||
|
/// <param name="requirement">The single requirement under evaluation.</param>
|
||||||
|
/// <returns>The context after both handlers have run.</returns>
|
||||||
|
private static async Task<AuthorizationHandlerContext> EvaluateAsync(
|
||||||
|
ClaimsPrincipal principal,
|
||||||
|
IAuthorizationRequirement requirement)
|
||||||
|
{
|
||||||
|
DefaultHttpContext httpContext = new();
|
||||||
|
httpContext.Connection.RemoteIpAddress = IPAddress.Loopback;
|
||||||
|
|
||||||
|
IOptions<GatewayOptions> 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));
|
||||||
|
}
|
||||||
@@ -178,12 +178,112 @@ public sealed class DashboardSessionAclTests
|
|||||||
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
|
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SEC-41, the load-bearing regression: a cookie-style principal whose tag claim was stamped
|
||||||
|
/// at login under an older <c>GroupToTag</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-resolution is ordinal-ignore-case on both sides — the group key against the map (which
|
||||||
|
/// <see cref="DashboardGroupTagMapping"/> owns) and the resolved tag against the session's tag
|
||||||
|
/// set. Pinned because the pre-SEC-41 claim scan got the second comparison from
|
||||||
|
/// <c>Session.Tags</c>'s own comparer, and the re-resolution arm probes the grant set in the
|
||||||
|
/// opposite direction.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The documented fallback: a hub-token principal carries the tags <see cref="HubTokenService"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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(
|
private static DashboardSessionAcl CreateAcl(
|
||||||
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
|
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly,
|
||||||
|
Dictionary<string, string[]>? groupToTag = null)
|
||||||
{
|
{
|
||||||
GatewayOptions options = new()
|
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<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
: new Dictionary<string, string[]>(groupToTag, StringComparer.OrdinalIgnoreCase),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new DashboardSessionAcl(
|
return new DashboardSessionAcl(
|
||||||
@@ -193,13 +293,26 @@ public sealed class DashboardSessionAclTests
|
|||||||
Options.Create(options));
|
Options.Create(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
|
/// <summary>
|
||||||
|
/// Builds a dashboard principal. <paramref name="groups"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="roles">Role claims to stamp.</param>
|
||||||
|
/// <param name="tags">Login-stamped visibility tag claims, if any.</param>
|
||||||
|
/// <param name="groups">LDAP group claims, if any.</param>
|
||||||
|
/// <returns>An authenticated principal carrying the requested claims.</returns>
|
||||||
|
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null, string[]? groups = null)
|
||||||
{
|
{
|
||||||
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
|
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||||
claims.AddRange((tags ?? []).Select(tag => new Claim(
|
claims.AddRange((tags ?? []).Select(tag => new Claim(
|
||||||
DashboardAuthenticationDefaults.DashboardTagClaimType,
|
DashboardAuthenticationDefaults.DashboardTagClaimType,
|
||||||
tag)));
|
tag)));
|
||||||
|
claims.AddRange((groups ?? []).Select(group => new Claim(
|
||||||
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
|
group)));
|
||||||
|
|
||||||
return new ClaimsPrincipal(new ClaimsIdentity(
|
return new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
claims,
|
claims,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.GalaxyRepository;
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
@@ -23,8 +26,11 @@ public sealed class GalaxyRepositoryHostWiringTests
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies the full production authz-filtering chain end-to-end.
|
/// Verifies the full production authz-filtering chain end-to-end.
|
||||||
/// Phase 1: no identity pushed → <c>BrowseChildren</c> returns both children
|
/// Phase 1: an identity carrying no <c>BrowseSubtrees</c> pushed → <c>BrowseChildren</c>
|
||||||
/// (unconstrained). Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
/// returns both children (unconstrained). That, not an absent identity, is production's
|
||||||
|
/// unscoped case since GWC-38 — with <c>Authentication:Mode=ApiKey</c> an absent identity
|
||||||
|
/// now denies, which the isolated provider test pins.
|
||||||
|
/// Phase 2: identity with <c>BrowseSubtrees = ["NonExistent"]</c>
|
||||||
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
/// pushed via <see cref="GatewayRequestIdentityAccessor"/> → the real
|
||||||
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
/// <see cref="GatewayBrowseScopeProvider"/> threads the constraint into the lib
|
||||||
/// service → <c>BrowseChildren</c> returns empty children.
|
/// service → <c>BrowseChildren</c> returns empty children.
|
||||||
@@ -35,7 +41,16 @@ public sealed class GalaxyRepositoryHostWiringTests
|
|||||||
{
|
{
|
||||||
// Arrange: construct the REAL host seam — not stubs.
|
// Arrange: construct the REAL host seam — not stubs.
|
||||||
GatewayRequestIdentityAccessor identityAccessor = new();
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||||
GatewayBrowseScopeProvider scopeProvider = new(identityAccessor);
|
GatewayBrowseScopeProvider scopeProvider = new(
|
||||||
|
identityAccessor,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Authentication = new Server.Configuration.AuthenticationOptions
|
||||||
|
{
|
||||||
|
Mode = AuthenticationMode.ApiKey,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
NullLogger<GatewayBrowseScopeProvider>.Instance);
|
||||||
|
|
||||||
GalaxyObject[] objects = CreateFilterObjects();
|
GalaxyObject[] objects = CreateFilterObjects();
|
||||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||||
@@ -55,13 +70,23 @@ public sealed class GalaxyRepositoryHostWiringTests
|
|||||||
new GalaxyDeployNotifier(),
|
new GalaxyDeployNotifier(),
|
||||||
scopeProvider);
|
scopeProvider);
|
||||||
|
|
||||||
// Phase 1: no identity on the accessor → browse is unconstrained.
|
// Phase 1: an authenticated identity with no BrowseSubtrees constraint → browse is
|
||||||
// Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
|
// unconstrained. Pump_001 and Valve_001 are both children of Line3 (GobjectId = 2).
|
||||||
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
ApiKeyIdentity unscopedIdentity = new(
|
||||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
KeyId: "test-key",
|
||||||
new TestServerCallContext());
|
KeyPrefix: "mxgw_test",
|
||||||
|
DisplayName: "unscoped",
|
||||||
|
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
|
||||||
|
Constraints: ApiKeyConstraints.Empty);
|
||||||
|
|
||||||
Assert.Equal(2, unconstrained.Children.Count);
|
using (identityAccessor.Push(unscopedIdentity))
|
||||||
|
{
|
||||||
|
BrowseChildrenReply unconstrained = await service.BrowseChildren(
|
||||||
|
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal(2, unconstrained.Children.Count);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
|
// Phase 2: push a constraint whose BrowseSubtrees do not match any seeded object.
|
||||||
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],
|
// GatewayBrowseScopeProvider.ResolveBrowseSubtrees must return ["NonExistent"],
|
||||||
|
|||||||
+61
-6
@@ -1,3 +1,7 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
|
||||||
@@ -19,7 +23,7 @@ public sealed class GatewayBrowseScopeProviderTests
|
|||||||
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
|
public void ResolveBrowseSubtrees_WithCurrentIdentity_ReturnsConstraintBrowseSubtrees()
|
||||||
{
|
{
|
||||||
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
|
StubIdentityAccessor accessor = new(CreateIdentity(["AreaA"]));
|
||||||
GatewayBrowseScopeProvider provider = new(accessor);
|
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||||
|
|
||||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||||
|
|
||||||
@@ -27,14 +31,16 @@ public sealed class GatewayBrowseScopeProviderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// With no ambient identity, the provider falls back to the empty browse-subtree
|
/// An authenticated key with no BrowseSubtrees constraint is legitimately unscoped: the
|
||||||
/// list, which the library treats as "no scoping" rather than denying everything.
|
/// empty list is what the library reads as "no scoping". This is the state GWC-38 required
|
||||||
|
/// to stay distinguishable from an absent identity, so it is asserted alongside its
|
||||||
|
/// denying twin below rather than left implicit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveBrowseSubtrees_WithNoCurrentIdentity_ReturnsEmptyList()
|
public void ResolveBrowseSubtrees_WithUnconstrainedIdentity_ReturnsEmptyList()
|
||||||
{
|
{
|
||||||
StubIdentityAccessor accessor = new(current: null);
|
StubIdentityAccessor accessor = new(CreateIdentity([]));
|
||||||
GatewayBrowseScopeProvider provider = new(accessor);
|
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||||
|
|
||||||
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||||
|
|
||||||
@@ -42,6 +48,55 @@ public sealed class GatewayBrowseScopeProviderTests
|
|||||||
Assert.Empty(result);
|
Assert.Empty(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GWC-38: with API-key authentication enabled, an absent ambient identity means the
|
||||||
|
/// interceptor-ordering invariant is broken — the request's browse scope cannot be
|
||||||
|
/// established. The provider must DENY rather than resolve to the full hierarchy, because
|
||||||
|
/// a silently-unscoped browse is an invisible authorization bypass. The status code is
|
||||||
|
/// pinned: PermissionDenied is what the gRPC surface returns for an authorization failure
|
||||||
|
/// the caller cannot fix by re-authenticating.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndApiKeyAuth_ThrowsPermissionDenied()
|
||||||
|
{
|
||||||
|
StubIdentityAccessor accessor = new(current: null);
|
||||||
|
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.ApiKey);
|
||||||
|
|
||||||
|
RpcException exception = Assert.Throws<RpcException>(
|
||||||
|
() => provider.ResolveBrowseSubtrees(new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Under <c>Authentication:Mode=Disabled</c> nothing authenticates, so an absent identity
|
||||||
|
/// is the expected steady state rather than a broken invariant: there is no key and no
|
||||||
|
/// constraint to scope by. The provider keeps returning unscoped, so disabling
|
||||||
|
/// authentication does not turn every Galaxy browse into a PermissionDenied.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ResolveBrowseSubtrees_WithNoCurrentIdentityAndAuthDisabled_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
StubIdentityAccessor accessor = new(current: null);
|
||||||
|
GatewayBrowseScopeProvider provider = CreateProvider(accessor, AuthenticationMode.Disabled);
|
||||||
|
|
||||||
|
IReadOnlyList<string>? result = provider.ResolveBrowseSubtrees(new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Empty(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayBrowseScopeProvider CreateProvider(
|
||||||
|
StubIdentityAccessor accessor,
|
||||||
|
AuthenticationMode mode) =>
|
||||||
|
new(
|
||||||
|
accessor,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Authentication = new Server.Configuration.AuthenticationOptions { Mode = mode },
|
||||||
|
}),
|
||||||
|
NullLogger<GatewayBrowseScopeProvider>.Instance);
|
||||||
|
|
||||||
private static ApiKeyIdentity CreateIdentity(IReadOnlyList<string> browseSubtrees) =>
|
private static ApiKeyIdentity CreateIdentity(IReadOnlyList<string> browseSubtrees) =>
|
||||||
new(
|
new(
|
||||||
KeyId: "operator01",
|
KeyId: "operator01",
|
||||||
|
|||||||
Reference in New Issue
Block a user