docs+test(closeout): final-review reservations — stale ACL prose, worker test gaps, config sample fix
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m14s
ci / java (push) Successful in 2m10s
ci / portable (push) Successful in 8m31s

This commit is contained in:
Joseph Doherty
2026-08-17 05:23:23 -04:00
parent f5a58d884b
commit b621d692d0
18 changed files with 167 additions and 46 deletions
+13 -4
View File
@@ -198,10 +198,19 @@ Tags are set at key creation with
trimmed and de-duplicated ordinal-ignore-case). Keys created from the dashboard
API Keys page are currently always untagged.
The tag is carried end to end today; the dashboard ACL that consumes it — scoping
a Viewer's `EventsHub` subscriptions to the sessions their LDAP groups are
granted — is a separate change. Until it lands, the tag affects nothing at
runtime.
The dashboard ACL that consumes the tag shipped on 2026-08-17 (SEC-25 / TST-15).
`IDashboardSessionAcl.CanViewSession` is consulted at both dashboard subscribe
seams — the SignalR `EventsHub.SubscribeSession` join and the in-process
`IDashboardSessionEventSubscriber.Subscribe` behind the session-details page — so
per-session event visibility is enforced at runtime: a Viewer observes a session
only when the session's tags intersect the tags their LDAP groups are granted
through `MxGateway:Dashboard:GroupToTag`. Administrators bypass the intersection,
and a session with no tags is visible to Administrators only unless
`MxGateway:Dashboard:UntaggedSessionVisibility` is set to `AllViewers`.
That enforcement is still *visibility*, not data access. The ACL decides which
sessions' mirrored events a dashboard principal may observe; it does not widen or
narrow what any API key may read, write, browse, or subscribe to over gRPC.
Glob matching is anchored, case-insensitive, and supports `*` and `?`.
Subtree and tag glob lists are alternatives: matching either list allows that
+14 -6
View File
@@ -256,11 +256,19 @@ decisions rather than one open backlog.
shipped as **TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client
`ReplayGap` handling) shipped as **CLI-15** for four of five clients
(.NET/Go/Rust/Python); the Java client is the only remainder.
- **Phase 4 (per-session dashboard ACL)** — scoped, not yet built. Tracked as archreview
**TST-15**. The Viewer-default decision is settled: admin-sees-all, Viewer strictly
scoped to sessions it owns or is granted — matching the gRPC owner-binding decision in
[Session Reconnect](#session-reconnect) above, for consistency between the gRPC and
dashboard surfaces.
- **Phase 4 (per-session dashboard ACL)** — shipped 2026-08-17 (branch
`feat/deferred-closeout`), tracked as archreview **TST-15**. The Viewer default is
admin-sees-all, Viewer strictly scoped — but the scope is the session **tag**, not
session ownership. The dashboard authenticates LDAP users while sessions are owned by
API keys, two disjoint identity domains, so there is no "sessions it owns" branch to
write: `GatewaySession.Tags` is inherited from the owning key's `DashboardTags`, a
dashboard group grants tags via `MxGateway:Dashboard:GroupToTag`, and
`IDashboardSessionAcl.CanViewSession` allows a Viewer iff the two sets intersect.
Administrators bypass the intersection; an untagged session is Admin-only under the
default `MxGateway:Dashboard:UntaggedSessionVisibility=AdminOnly`. The decision is
taken at both subscribe seams (`EventsHub.SubscribeSession` and the in-process
`IDashboardSessionEventSubscriber.Subscribe`), never per event. See
`docs/plans/2026-07-10-dashboard-session-acl-tst15.md` and `docs/Authorization.md`.
- **Phase 5 (orphan-worker reattach)** — deferred, not planned. It would reverse the
"Gateway restart does not reattach orphan workers" invariant (see CLAUDE.md), adding a
stable gateway-instance id, an adoption-manifest SQLite store, a worker phone-home
@@ -270,7 +278,7 @@ decisions rather than one open backlog.
if it does** until that task actually lands.
`docs/plans/2026-06-15-session-resilience.md.tasks.json` remains the sole resume state
for the still-pending Phase 4 tasks (16-19) and the deferred Phase 5 tasks (20-28) — one
for the Phase 4 tasks (16-19, now shipped) and the deferred Phase 5 tasks (20-28) — one
authority, no mirror.
## Authentication
+2 -2
View File
@@ -58,7 +58,7 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"RecentSessionLimit": 200,
"ShowTagValues": false,
"GroupToRole": {
"GwAdmin": "Admin",
"GwAdmin": "Administrator",
"GwReader": "Viewer"
},
"GroupToTag": {
@@ -193,7 +193,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `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` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR hub mirror — it does **not** cover the `/browse` live-value display, which remains a separate, still-open residual. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `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 CN or full DN — leading-RDN match, case-insensitive); 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: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. |
+5 -1
View File
@@ -801,7 +801,11 @@ the deliberate mitigation: it bounds that exposure window without the cost of a
revocation store. It now bounds a stale *tag* grant the same way: the token carries
the tags resolved from the caller's LDAP groups at mint time, so removing a
`GroupToTag` entry takes effect for token-authenticated hub connections within one
lifetime. That is where the per-session ACL's revocation need landed — a jti
lifetime. That 5-minute staleness bound covers token-authenticated connections only:
a cookie principal carries the `zb:dashboardtag` claims stamped at login for the
cookie's whole life, so for cookie-authenticated (in-process page) subscriptions a
revoked `GroupToTag` grant takes effect at the user's next login, not within five
minutes. That is where the per-session ACL's revocation need landed — a jti
denylist stays deferred, since the short lifetime already bounds every grant the
token carries.
+1 -1
View File
@@ -751,7 +751,7 @@ secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
connections additionally accept a 30-minute data-protected bearer minted at
connections additionally accept a 5-minute data-protected bearer minted at
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
to bypass the cookie requirement and defaults to `true`.
+1 -1
View File
@@ -6,7 +6,7 @@ The sessions subsystem owns the in-memory representation of an active gateway-to
A session is the gateway-side handle that callers use to invoke worker commands, stream worker events, and tear the worker down. The subsystem is split between the per-session state machine (`GatewaySession`), an in-memory directory (`SessionRegistry`), the orchestrator that opens and closes sessions (`SessionManager`), the worker construction step (`SessionWorkerClientFactory`), and a hosted service that drains sessions during host shutdown (`SessionShutdownHostedService`).
All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`.
All three interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`.
## Key Types
@@ -87,7 +87,8 @@ public sealed record ApiKeyConstraints(
At `OpenSession`, the resolved `ApiKeyIdentity.EffectiveConstraints.DashboardTags`
is copied onto the new `GatewaySession.Tags`. The `apikey` admin CLI gains
`--dashboard-tags team-a,team-b` on `create`/`update`.
`--dashboard-tags team-a,team-b` on `create-key` (there is no `update`
subcommand — see `docs/Authentication.md`).
Semantic note: `ApiKeyConstraints` today scopes *data-access* authorization
(read/write subtrees, globs, classification). A dashboard *visibility* tag is a
+1 -1
View File
@@ -171,7 +171,7 @@ Task 10/11 gates it → commit
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs` (add `IReadOnlyList<string> DashboardTags`, default empty)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs` (round-trip the new field; absent-in-JSON → empty — old rows keep deserializing)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on create/update; shown in list output)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs` + `ApiKeyAdminCliRunner.cs` + `ApiKeyAdminCommand.cs` + `ApiKeyAdminListedKey.cs` (CLI `--dashboard-tags team-a,team-b` on `create-key` — there is no `update` subcommand; shown in list output)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/GatewaySession.cs` (new `public IReadOnlySet<string> Tags { get; }`, set at construction from the owner key's effective constraints; empty = untagged)
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Sessions/SessionManager.cs` (`OpenSession` path copies `ApiKeyIdentity.EffectiveConstraints.DashboardTags` onto the session)
- Test: serializer round-trip incl. legacy-JSON-without-field; CLI parse; session tag inheritance via the fake-worker harness
@@ -11,7 +11,7 @@
{ "id": 8, "subject": "Task 8: Alarm-truncation degraded-status signal — proto + worker + gateway + dashboard", "status": "completed", "blockedBy": [3] },
{ "id": 9, "subject": "Task 9: Client regeneration + rebuild for new alarm fields", "status": "completed", "blockedBy": [8] },
{ "id": 10, "subject": "Task 10: Phase gate — full gateway suite on macOS", "status": "completed", "blockedBy": [1, 4, 5, 6, 7, 8, 9] },
{ "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "pending", "blockedBy": [2, 10] },
{ "id": 11, "subject": "Task 11: windev gate — full Windows verification", "status": "completed", "blockedBy": [2, 10] },
{ "id": 12, "subject": "Task 12: Wrap-up — closure notes, umbrella check, final review", "status": "pending", "blockedBy": [11] }
],
"lastUpdated": "2026-08-17T00:00:00Z"