docs+test(closeout): final-review reservations — stale ACL prose, worker test gaps, config sample fix
This commit is contained in:
+13
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user