docs(archreview): flesh out TST-15 dashboard per-session ACL design
Add docs/plans/2026-07-10-dashboard-session-acl-tst15.md: the fleshed-out Phase-4 design for the deferred TST-15 finding. Resolves the crux the deferral left open — the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (OwnerKeyId), two disjoint identity domains — via a session tag sourced from the owning API key (carried in the existing ApiKeyConstraints JSON blob, no SQLite migration). Admin-sees-all; a Viewer may SubscribeSession iff session.Tags intersects the Viewer's granted tags (new Dashboard:GroupToTag map -> hub-token tag claims); untagged sessions Admin-only by default. Includes the enforcement path, task breakdown (epic Tasks 16-19), test plan incl. live-LDAP, and rejected alternatives. Design only — TST-15 stays Not started (no implementation). The tracker and the 60-testing-docs-gaps TST-15 section point at the design doc; the change-log also records the TST-03 finding (zero registered runners; needs a runner co-located on the gitea Docker network). Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
@@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog
|
|||||||
|
|
||||||
| Date | Change |
|
| Date | Change |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 2026-07-10 | **TST-15 design fleshed out** (still `Not started` — design only, not implementation): `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Resolves the crux the deferral left open — the dashboard is LDAP-identity (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`), two disjoint identity domains — via a **session tag** sourced from the owning API key (rides in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin-sees-all; Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (new `Dashboard:GroupToTag` map → hub-token tag claims); untagged sessions Admin-only by default (`Dashboard:UntaggedSessionVisibility`). Includes the enforcement path (`HubTokenPayload.Tags` + `IDashboardSessionAcl` gate at `SubscribeSession`), task breakdown (epic Tasks 16–19), test plan incl. live-LDAP, and rejected alternatives (client-supplied tag; group→key-id map). Tracker + `60-testing-docs-gaps.md` TST-15 section point at the doc. **TST-03 investigated:** the CI never ran because the repo had **zero registered Gitea Actions runners** (Actions is enabled; runs are created on push/PR/nightly but fail instantly with nothing to execute them). A Mac runner proved the pipeline executes but cannot clone — this Gitea hands runners the internal `http://gitea:3000` URL, reachable only by a runner co-located on the gitea Docker network. Fix = run a co-located runner on the Gitea host (recipe prepared, `scratchpad/gitea-runner/setup-gitea-host-runner.sh`); pending host access. TST-03 stays `In review`. |
|
||||||
| 2026-07-09 | **P2 Epic wrap — user decision: DEFER TST-15 + TST-24, close the epic.** Epic bucket result: 5 of 7 findings `Done` (CLI-15, CLI-04, CLI-30, TST-01, TST-04); **TST-15** and **TST-24** intentionally deferred to a follow-up (kept `Not started`, not `Won't fix` — they are gated, not rejected). **TST-15** (dashboard EventsHub per-session ACL) is epic Phase 4 — a real feature needing a new session-"tag" mechanism + dashboard group→tag config, not a mechanical fix; the `EventsHub` `TODO(per-session-acl)` stays, and the already-shipped **SEC-25** mitigation (tag *values* redacted from the dashboard mirror by default) means no sensitive payload leaks through the hub today regardless of the missing ACL — so deferring carries no value-leak risk. **TST-24** (per-client wire tests) depends on **TST-03** (CI), which is `In review` (YAML authored, never run on a Gitea runner) — no point wiring client tests into a pipeline that isn't live yet. Net P2: 35/38 `Done`; remaining = TST-15 (deferred feature), TST-24 (deferred, CI-gated), TST-14 (user deletes their own untracked gitignored `*-docs-*.md` files). |
|
| 2026-07-09 | **P2 Epic wrap — user decision: DEFER TST-15 + TST-24, close the epic.** Epic bucket result: 5 of 7 findings `Done` (CLI-15, CLI-04, CLI-30, TST-01, TST-04); **TST-15** and **TST-24** intentionally deferred to a follow-up (kept `Not started`, not `Won't fix` — they are gated, not rejected). **TST-15** (dashboard EventsHub per-session ACL) is epic Phase 4 — a real feature needing a new session-"tag" mechanism + dashboard group→tag config, not a mechanical fix; the `EventsHub` `TODO(per-session-acl)` stays, and the already-shipped **SEC-25** mitigation (tag *values* redacted from the dashboard mirror by default) means no sensitive payload leaks through the hub today regardless of the missing ACL — so deferring carries no value-leak risk. **TST-24** (per-client wire tests) depends on **TST-03** (CI), which is `In review` (YAML authored, never run on a Gitea runner) — no point wiring client tests into a pipeline that isn't live yet. Net P2: 35/38 `Done`; remaining = TST-15 (deferred feature), TST-24 (deferred, CI-gated), TST-14 (user deletes their own untracked gitignored `*-docs-*.md` files). |
|
||||||
| 2026-07-09 | P2 Epic — **Java client completes CLI-15 + CLI-04 locally** (commit `1cc0fa4`); **CLI-15, CLI-04, CLI-30, TST-01 all → `Done` (5/5 clients + server e2e)**. Java CLI-15: `MxEventStreamItem` record + `MxEventStream.nextItem()` (`isReplayGap()`/`replayGap()`/`event()`); existing `Iterator<MxEvent>` path unchanged, sentinel never swallowed. Java CLI-04: Phase 1 `adviseSupervisory`/`writeSecured`/`writeSecured2`/`authenticateUser`/`archestrAUserToId` + Phase 2 `addBufferedItem`/`setBufferedUpdateInterval`/`suspend`/`activate` (unregister already present) on `MxGatewaySession`, each through `invokeCommand` → `ensureProtocolSuccess`+`ensureMxAccessSuccess`; credentials scrubbed via `MxGatewaySecrets.redactCredentials` (tests assert absent from message/toString/CLI). `gradle test` 106/0 (58 client + 48 cli), no generated churn. Built locally with `JAVA_HOME=/opt/homebrew/opt/openjdk@17` — Java toolchain now works on the Mac (see prior note). Shared docs `ClientLibrariesDesign.md` + CLAUDE.md updated to "all five clients". **TST-01 → Done** (server e2e `fed0685` + all 5 client `ReplayGap` consumers). This closes session-resilience epic Phase 3 fully. |
|
| 2026-07-09 | P2 Epic — **Java client completes CLI-15 + CLI-04 locally** (commit `1cc0fa4`); **CLI-15, CLI-04, CLI-30, TST-01 all → `Done` (5/5 clients + server e2e)**. Java CLI-15: `MxEventStreamItem` record + `MxEventStream.nextItem()` (`isReplayGap()`/`replayGap()`/`event()`); existing `Iterator<MxEvent>` path unchanged, sentinel never swallowed. Java CLI-04: Phase 1 `adviseSupervisory`/`writeSecured`/`writeSecured2`/`authenticateUser`/`archestrAUserToId` + Phase 2 `addBufferedItem`/`setBufferedUpdateInterval`/`suspend`/`activate` (unregister already present) on `MxGatewaySession`, each through `invokeCommand` → `ensureProtocolSuccess`+`ensureMxAccessSuccess`; credentials scrubbed via `MxGatewaySecrets.redactCredentials` (tests assert absent from message/toString/CLI). `gradle test` 106/0 (58 client + 48 cli), no generated churn. Built locally with `JAVA_HOME=/opt/homebrew/opt/openjdk@17` — Java toolchain now works on the Mac (see prior note). Shared docs `ClientLibrariesDesign.md` + CLAUDE.md updated to "all five clients". **TST-01 → Done** (server e2e `fed0685` + all 5 client `ReplayGap` consumers). This closes session-resilience epic Phase 3 fully. |
|
||||||
| 2026-07-09 | P2 Epic Wave E2 — typed-command parity (commit `bde042b`). CLI-04 → `In progress` (4/5 clients; Java pending in the SAME session — see next note), CLI-30 → `Done`. Every parity-critical single-item command now has a typed session helper across .NET/Go/Rust/Python: Phase 1 `AdviseSupervisory`/`WriteSecured`/`WriteSecured2`/`AuthenticateUser`/`ArchestrAUserToId`, Phase 2 `AddBufferedItem`/`SetBufferedUpdateInterval`/`Suspend`/`Activate`, plus `Unregister` (CLI-30: Rust + .NET added; Go/Java/Python already had it — CLI-30 fully Done). Each wraps existing raw-command machinery (no new wire surface) and runs the client's MXAccess-level reply validation (`hresult < 0` + `MxStatusProxy`). MXAccess parity preserved (WriteSecured-before-authenticate surfaces the native failure). **Credentials route through each client's secret-redaction seam** (never in logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors); new CLI subcommands take credentials via flag/env, never echoed. Verified per toolchain: .NET build clean 102 passed; Go gofmt/vet/build/test clean; Rust fmt/check/test/clippy clean; Python 145 passed. Shared doc: `ClientLibrariesDesign.md` "Typed Command Parity" section. |
|
| 2026-07-09 | P2 Epic Wave E2 — typed-command parity (commit `bde042b`). CLI-04 → `In progress` (4/5 clients; Java pending in the SAME session — see next note), CLI-30 → `Done`. Every parity-critical single-item command now has a typed session helper across .NET/Go/Rust/Python: Phase 1 `AdviseSupervisory`/`WriteSecured`/`WriteSecured2`/`AuthenticateUser`/`ArchestrAUserToId`, Phase 2 `AddBufferedItem`/`SetBufferedUpdateInterval`/`Suspend`/`Activate`, plus `Unregister` (CLI-30: Rust + .NET added; Go/Java/Python already had it — CLI-30 fully Done). Each wraps existing raw-command machinery (no new wire surface) and runs the client's MXAccess-level reply validation (`hresult < 0` + `MxStatusProxy`). MXAccess parity preserved (WriteSecured-before-authenticate surfaces the native failure). **Credentials route through each client's secret-redaction seam** (never in logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors); new CLI subcommands take credentials via flag/env, never echoed. Verified per toolchain: .NET build clean 102 passed; Go gofmt/vet/build/test clean; Rust fmt/check/test/clippy clean; Python 145 passed. Shared doc: `ClientLibrariesDesign.md` "Typed Command Parity" section. |
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ If TST-02's interim mitigation (flip retention off) is chosen instead of impleme
|
|||||||
|
|
||||||
**Impact.** Acceptable for a single-tenant dashboard; wrong the moment `GroupToRole` admits low-trust viewers. It is the dashboard-side twin of the gRPC owner-revalidation gap (TST-02).
|
**Impact.** Acceptable for a single-tenant dashboard; wrong the moment `GroupToRole` admits low-trust viewers. It is the dashboard-side twin of the gRPC owner-revalidation gap (TST-02).
|
||||||
|
|
||||||
**Design.** Covered by epic Phase 4 (Tasks 16–19, TST-04). Introduce a role/scope that scopes a hub connection to permitted sessions, plus a hub-token session tag (Task 18). The open design decision (`oldtasks.md:52`) — Viewer default of admin-sees-all vs strict per-session — must be settled first. Recommendation: admin-sees-all, Viewer strictly scoped to sessions they own/are granted, matching TST-02's gRPC owner binding for consistency. Until Phase 4 lands, keep the TODO (it correctly documents the accepted single-tenant assumption); do not silently remove it.
|
**Design.** Fully fleshed out in `docs/plans/2026-07-10-dashboard-session-acl-tst15.md` (epic Phase 4, Tasks 16–19, TST-04). In brief: the dashboard authenticates LDAP users (Admin/Viewer) while sessions are API-key-owned (`OwnerKeyId`) — two disjoint identity domains — so the ACL needs a bridge: a **session tag** sourced from the owning API key (riding in the existing `ApiKeyConstraints` JSON blob, no SQLite migration). Admin sees all; a Viewer may `SubscribeSession` iff `session.Tags ∩ viewer.GrantedTags ≠ ∅` (granted via a new `Dashboard:GroupToTag` map, carried into the hub token as tag claims); untagged sessions are Admin-only by default. The Viewer-default decision (admin-sees-all vs strict) is settled there. Until Phase 4 lands, keep the TODO (it correctly documents the accepted single-tenant assumption); do not silently remove it.
|
||||||
|
|
||||||
**Implementation.** `Dashboard/Hubs/EventsHub.cs` (ACL check on group join), hub-token minting to carry the session tag, `Configuration/DashboardOptions.cs` for any group-to-tag config (Task 17). Tests: `...Tests/Gateway/Dashboard/` hub ACL cases incl. live-LDAP users (Task 19). Docs: `docs/Sessions.md`/`gateway.md` dashboard section document the ACL model; CLAUDE.md dashboard-auth paragraph.
|
**Implementation.** `Dashboard/Hubs/EventsHub.cs` (ACL check on group join), hub-token minting to carry the session tag, `Configuration/DashboardOptions.cs` for any group-to-tag config (Task 17). Tests: `...Tests/Gateway/Dashboard/` hub ACL cases incl. live-LDAP users (Task 19). Docs: `docs/Sessions.md`/`gateway.md` dashboard section document the ACL model; CLAUDE.md dashboard-auth paragraph.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Dashboard EventsHub per-session ACL (TST-15 / SEC-25 · session-resilience epic Phase 4)
|
||||||
|
|
||||||
|
Status: **Design** — approved-to-implement pending the schema-touch call in §9.
|
||||||
|
Findings: TST-15 (`Medium`, P2), SEC-25 (`Low`, P2). Epic tasks: 16–19 of
|
||||||
|
`docs/plans/2026-06-15-session-resilience.md`.
|
||||||
|
Depends on: TST-02 (owner-scoped gRPC attach, shipped P0), SEC-25 near-term
|
||||||
|
mitigation (value redaction in the dashboard mirror, shipped P2).
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
`EventsHub.SubscribeSession(sessionId)` joins the caller's SignalR connection to
|
||||||
|
the `session:{id}` group with no per-session check. The hub-level
|
||||||
|
`[Authorize(Policy = HubClientsPolicy)]` only requires *any* dashboard role, so
|
||||||
|
**every authenticated Viewer (and, via SEC-02, anonymous localhost) can subscribe
|
||||||
|
to any session's raw `MxEvent` feed** — the one production-source `TODO` left in
|
||||||
|
the repo (`Dashboard/Hubs/EventsHub.cs:42`).
|
||||||
|
|
||||||
|
SEC-25's shipped mitigation already strips tag *values* from the mirrored events
|
||||||
|
when `Dashboard:ShowTagValues` is false (the default), so no payload leaks through
|
||||||
|
this seam today. What remains is **event-metadata visibility**: which sessions'
|
||||||
|
event streams (worker sequence, family, server/item handles, alarm references) a
|
||||||
|
given Viewer may observe. That is what this ACL gates.
|
||||||
|
|
||||||
|
### 1.1 The crux: two disjoint identity domains
|
||||||
|
|
||||||
|
This is *not* a copy of the gRPC owner check (TST-02), and the reason is the whole
|
||||||
|
design problem:
|
||||||
|
|
||||||
|
| | gRPC `StreamEvents` (TST-02) | Dashboard EventsHub (TST-15) |
|
||||||
|
|---|---|---|
|
||||||
|
| Caller identity | API key (`mxgw_<id>_<secret>`) | LDAP user → `Administrator`/`Viewer` role |
|
||||||
|
| Session identity | `GatewaySession.OwnerKeyId` (an API key id) | same session, but caller has **no** key id |
|
||||||
|
| Check | `caller.KeyId == session.OwnerKeyId` — same domain, trivial | caller is an LDAP principal; there is **nothing on the session to compare it to** |
|
||||||
|
|
||||||
|
TST-02's binding is API-key-id ↔ API-key-id. A dashboard Viewer has no API key id,
|
||||||
|
so "sessions they own" does not translate directly. The design must introduce a
|
||||||
|
**bridge concept** that both a *session* and a *dashboard group* can be associated
|
||||||
|
with. That bridge is the **session tag** (epic Task 17, "Session Tag + dashboard
|
||||||
|
group-to-tag config").
|
||||||
|
|
||||||
|
## 2. Decision summary
|
||||||
|
|
||||||
|
1. **Admin sees all.** A connection carrying `DashboardRoles.Admin` bypasses the
|
||||||
|
ACL entirely — it may subscribe to any session. (Admin already reaches every
|
||||||
|
destructive surface; event-metadata visibility is strictly weaker.)
|
||||||
|
2. **Viewer is strict.** A Viewer-only connection may subscribe to a session iff
|
||||||
|
`session.Tags ∩ viewer.GrantedTags ≠ ∅`.
|
||||||
|
3. **A session's tags come from its owning API key**, not from the client wire
|
||||||
|
request. The API key is the tenant principal (TST-02 already treats it as the
|
||||||
|
session's owner); deriving the tag from the key makes the dashboard boundary
|
||||||
|
match the gRPC owner boundary. See §3.1 and the rejected alternative in §10.1.
|
||||||
|
4. **Untagged sessions are Admin-only by default.** If the owning key carries no
|
||||||
|
tag, no Viewer sees the session. A config knob
|
||||||
|
(`Dashboard:UntaggedSessionVisibility`) can relax this to `AllViewers` for a
|
||||||
|
genuinely single-tenant deployment, preserving today's behaviour for operators
|
||||||
|
who want it. Default is the safe `AdminOnly`.
|
||||||
|
5. **Enforcement is server-side and stateless**, carried in the existing
|
||||||
|
short-lived hub bearer token (§4). No new server-side session/among-connection
|
||||||
|
state.
|
||||||
|
|
||||||
|
## 3. The session tag
|
||||||
|
|
||||||
|
A session gains an immutable set of tags, assigned once at `OpenSession` and never
|
||||||
|
changed for the session's life (so no re-check on an already-joined SignalR
|
||||||
|
group is needed).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// GatewaySession — new read-only property, set from the owner key at construction.
|
||||||
|
public IReadOnlySet<string> Tags { get; } // empty set == untagged
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 Source: the owning API key
|
||||||
|
|
||||||
|
Sessions inherit the tag set of the API key that opened them. The tag rides in the
|
||||||
|
**existing `ApiKeyConstraints` JSON blob** (`ApiKeyConstraintSerializer`), so there
|
||||||
|
is **no SQLite schema migration** — a new column is not required:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ApiKeyConstraints — add one field (serialized into the existing constraints column).
|
||||||
|
public sealed record ApiKeyConstraints(
|
||||||
|
...,
|
||||||
|
bool ReadHistorizedOnly,
|
||||||
|
IReadOnlyList<string> DashboardTags); // NEW — default empty
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
Semantic note: `ApiKeyConstraints` today scopes *data-access* authorization
|
||||||
|
(read/write subtrees, globs, classification). A dashboard *visibility* tag is a
|
||||||
|
distinct concern, but piggy-backing on the same serialized blob is the pragmatic
|
||||||
|
choice — it avoids a migration and keeps all per-key policy in one place. The
|
||||||
|
field is documented as visibility-only; it does **not** gate any data-access path.
|
||||||
|
|
||||||
|
### 3.2 Dashboard group → tag grant
|
||||||
|
|
||||||
|
A new config map, mirroring the shape of `Dashboard:GroupToRole`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"MxGateway": {
|
||||||
|
"Dashboard": {
|
||||||
|
"GroupToRole": { "GwAdmin": "Administrator", "GwViewer": "Viewer" },
|
||||||
|
"GroupToTag": { "GwViewer": ["team-a"], "TeamBViewers": ["team-b"] },
|
||||||
|
"UntaggedSessionVisibility": "AdminOnly" // or "AllViewers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GroupToTag` maps an LDAP group (short RDN, matching the `GroupToRole` convention
|
||||||
|
in `DashboardGroupRoleMapping`) to the tags that group is granted. A Viewer's
|
||||||
|
`GrantedTags` is the union over their LDAP groups. Admin's grant is irrelevant
|
||||||
|
(they bypass). Bound on `DashboardOptions`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Dictionary<string, string[]> GroupToTag { get; init; }
|
||||||
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public UntaggedSessionVisibility UntaggedSessionVisibility { get; init; }
|
||||||
|
= UntaggedSessionVisibility.AdminOnly;
|
||||||
|
```
|
||||||
|
|
||||||
|
Validated in `GatewayOptionsValidator` exactly like `GroupToRole` (keys non-empty;
|
||||||
|
`UntaggedSessionVisibility` a known enum). No validation coupling to `GroupToRole`
|
||||||
|
— a group may appear in one, the other, or both.
|
||||||
|
|
||||||
|
## 4. Enforcement path
|
||||||
|
|
||||||
|
The tag grant must reach `SubscribeSession`. A hub connection authenticates by
|
||||||
|
either the dashboard cookie or the 5-minute bearer minted by `HubTokenService`.
|
||||||
|
`HubTokenPayload` today carries `(Name, NameIdentifier, Roles)` — extend it with
|
||||||
|
the resolved granted-tag set:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private sealed record HubTokenPayload(
|
||||||
|
string? Name, string? NameIdentifier, string[]? Roles, string[]? Tags);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Mint (`HubTokenService.Issue`)** — resolve the principal's granted tags from
|
||||||
|
their LDAP-group claims (`mxgateway:ldap_group`) via a `GroupToTag` mapper
|
||||||
|
(sibling of `DashboardGroupRoleMapping`), and stamp them into the payload as a
|
||||||
|
`zb:dashboardtag` claim set. Admin needs no tags stamped.
|
||||||
|
- **Cookie path** — the same tag claims are added in
|
||||||
|
`DashboardAuthenticator.CreatePrincipal` so a cookie-authenticated circuit
|
||||||
|
carries them without a token round-trip.
|
||||||
|
- **Validate (`HubTokenService.Validate`)** — rehydrate the tag claims onto the
|
||||||
|
reconstructed principal, alongside the existing role claims.
|
||||||
|
- The 5-minute lifetime already bounds staleness of a changed grant
|
||||||
|
(documented at `HubTokenService.cs:30-37`) — this now also bounds a changed
|
||||||
|
tag grant, which is the natural place the SEC-25 "tokens gain session binding"
|
||||||
|
note anticipated.
|
||||||
|
|
||||||
|
`SubscribeSession` becomes an authorizing, `SessionManager`-aware method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task SubscribeSession(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId)) return;
|
||||||
|
|
||||||
|
if (!_sessionAcl.CanViewSession(Context.User, sessionId))
|
||||||
|
{
|
||||||
|
// Deny quietly: do NOT join the group. Optionally surface a HubException
|
||||||
|
// so the client can distinguish "denied" from "no events yet".
|
||||||
|
throw new HubException("Not authorized for this session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`IDashboardSessionAcl.CanViewSession(ClaimsPrincipal, sessionId)`:
|
||||||
|
1. Admin role → `true`.
|
||||||
|
2. Session not found in `SessionManager` → `false` (no group join for a phantom id).
|
||||||
|
3. Session untagged → `UntaggedSessionVisibility == AllViewers`.
|
||||||
|
4. Otherwise → `session.Tags ∩ principal.GrantedTags ≠ ∅`.
|
||||||
|
|
||||||
|
Because the broadcaster only ever publishes to `session:{id}` and only joined
|
||||||
|
connections receive, gating at join is sufficient — there is no second seam to
|
||||||
|
guard. Tags are immutable per session, so a mid-life re-check is unnecessary.
|
||||||
|
|
||||||
|
### 4.1 Interaction with SEC-02 (anonymous localhost)
|
||||||
|
|
||||||
|
Anonymous-localhost satisfies `HubClientsPolicy` but carries no role and no tags.
|
||||||
|
Under this ACL it is treated as a **Viewer with an empty tag grant** → sees only
|
||||||
|
untagged sessions, and only when `UntaggedSessionVisibility == AllViewers`. That
|
||||||
|
tightens SEC-02's current "loopback sees every session" posture without a separate
|
||||||
|
change. `DisableLogin` auto-login (both roles) keeps admin-sees-all — unchanged.
|
||||||
|
|
||||||
|
## 5. End-to-end data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
apikey create --dashboard-tags team-a → key.Constraints.DashboardTags = [team-a]
|
||||||
|
client OpenSession (Bearer mxgw_<id>_..) → session.OwnerKeyId = <id>,
|
||||||
|
session.Tags = [team-a]
|
||||||
|
LDAP login gw-viewer ∈ group GwViewer → GroupToTag[GwViewer] = [team-a]
|
||||||
|
principal grantedTags = {team-a}
|
||||||
|
hub token / cookie → payload.Tags = [team-a]
|
||||||
|
SubscribeSession("s-123") (session.Tags={team-a})→ {team-a} ∩ {team-a} ≠ ∅ → JOIN
|
||||||
|
SubscribeSession("s-999") (session.Tags={team-b})→ {team-a} ∩ {team-b} = ∅ → DENY
|
||||||
|
Admin → bypass → JOIN either
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Task breakdown (maps to epic Tasks 16–19)
|
||||||
|
|
||||||
|
- **Task 16 — gRPC all-sessions admin scope (already largely shipped).** The gRPC
|
||||||
|
owner gate is TST-02. This task is the *dashboard* admin bypass in
|
||||||
|
`IDashboardSessionAcl` (§4). No gRPC change required; the "all-sessions admin
|
||||||
|
scope" for gRPC is out of scope for TST-15 and stays with the broader epic.
|
||||||
|
- **Task 17 — session tag + group-to-tag config.** `ApiKeyConstraints.DashboardTags`
|
||||||
|
+ serializer + `apikey` CLI flag; `GatewaySession.Tags` set at `OpenSession`;
|
||||||
|
`DashboardOptions.GroupToTag` + `UntaggedSessionVisibility` + validator.
|
||||||
|
- **Task 18 — EventsHub ACL + hub-token tag claim.** `HubTokenPayload.Tags`,
|
||||||
|
mint/validate, cookie-principal tag claims, `IDashboardSessionAcl` +
|
||||||
|
`SubscribeSession` gate. Remove the `TODO(per-session-acl)`.
|
||||||
|
- **Task 19 — ACL tests, incl. live LDAP users.** §7.
|
||||||
|
|
||||||
|
Sequence: 17 → 18 → 19; 16's dashboard slice folds into 18.
|
||||||
|
|
||||||
|
## 7. Test plan
|
||||||
|
|
||||||
|
Unit / fake-worker (default suite, no LDAP, no COM):
|
||||||
|
- `DashboardSessionAclTests` — the four `CanViewSession` branches: admin bypass;
|
||||||
|
session-not-found deny; untagged under both `UntaggedSessionVisibility` values;
|
||||||
|
tag-intersection match and non-match.
|
||||||
|
- `HubTokenServiceTests` — extend: tags round-trip through `Issue`/`Validate`;
|
||||||
|
a token minted before a grant change still carries the old tags until expiry.
|
||||||
|
- `EventsHubTests` — `SubscribeSession` joins on allow, throws `HubException`
|
||||||
|
and does **not** join on deny (assert via a fake `IGroupManager`).
|
||||||
|
- `GroupToTag` mapper tests — union across multiple groups; unknown group → no tags.
|
||||||
|
- Config: `GatewayOptionsValidator` accepts/ rejects `GroupToTag` /
|
||||||
|
`UntaggedSessionVisibility` shapes.
|
||||||
|
|
||||||
|
Live LDAP (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`, `LiveLdapFactAttribute`) — extend
|
||||||
|
`DashboardLdapLiveTests`: `gw-viewer`/`password` (Viewer) is granted `team-a` via
|
||||||
|
`GroupToTag`, subscribes to a `team-a` session (allow) and a `team-b` session
|
||||||
|
(deny); `multi-role`/`password` (Admin) subscribes to both (allow). Uses the
|
||||||
|
shared GLAuth server per `glauth.md`.
|
||||||
|
|
||||||
|
Verification: `dotnet test ... --filter FullyQualifiedName~DashboardSessionAcl`,
|
||||||
|
`~EventsHub`, `~HubTokenService`; `dotnet build src/ZB.MOM.WW.MxGateway.Server`.
|
||||||
|
|
||||||
|
## 8. Docs to update (same change as source)
|
||||||
|
|
||||||
|
- `docs/Sessions.md` — dashboard event visibility / session-tag model.
|
||||||
|
- `gateway.md` dashboard section — the ACL and the tag source.
|
||||||
|
- `docs/GatewayConfiguration.md` — `Dashboard:GroupToTag`,
|
||||||
|
`Dashboard:UntaggedSessionVisibility`; `apikey --dashboard-tags`.
|
||||||
|
- `docs/Authorization.md` — note `DashboardTags` is visibility-only, not a
|
||||||
|
data-access constraint.
|
||||||
|
- `CLAUDE.md` dashboard-auth paragraph — Viewer is tag-scoped; Admin sees all;
|
||||||
|
anonymous-localhost is an empty-grant Viewer.
|
||||||
|
- `glauth.md` — the test-user tag grants used by the live tests.
|
||||||
|
|
||||||
|
## 9. Open call for the implementer
|
||||||
|
|
||||||
|
**Does the tag ride in the existing `ApiKeyConstraints` JSON blob (no migration,
|
||||||
|
recommended) or a new dedicated key column/table?** The blob avoids a SQLite
|
||||||
|
migration and keeps per-key policy in one place; a dedicated column is cleaner
|
||||||
|
semantically (visibility ≠ data-access constraint) but costs a migration + audit
|
||||||
|
touch. Recommendation: **blob**, documented as visibility-only. Settle before
|
||||||
|
Task 17.
|
||||||
|
|
||||||
|
## 10. Rejected alternatives
|
||||||
|
|
||||||
|
### 10.1 Client-supplied session tag (proto field on `OpenSession`)
|
||||||
|
Lighter — one proto field, one session field, no API-key-store touch. Rejected as
|
||||||
|
*primary* because the tag would then be attacker-controlled: a low-trust gRPC
|
||||||
|
client could label its session with any tenant's tag, choosing which dashboard
|
||||||
|
groups observe its event feed. Deriving the tag from the owning API key keeps the
|
||||||
|
boundary aligned with TST-02's trust model (the key is the tenant). (If a future
|
||||||
|
requirement wants client-declared sub-tags *within* an owner's grant, they can be
|
||||||
|
intersected with the key's tags — additive, not a replacement.)
|
||||||
|
|
||||||
|
### 10.2 Map dashboard groups directly to owner key ids (reuse `OwnerKeyId`, no tag)
|
||||||
|
Zero API-key-store change — `Dashboard:GroupToOwnerKeys` maps a group to a set of
|
||||||
|
key ids. Rejected: key ids are opaque and rotate; operators would maintain a map
|
||||||
|
of GUID-like ids, and every key rotation breaks the dashboard grant. A named tag
|
||||||
|
is the right operator-facing abstraction and survives key rotation.
|
||||||
|
|
||||||
|
### 10.3 Full jti-denylist token revocation
|
||||||
|
Out of scope. The 5-minute token lifetime bounds a changed grant; the tokens are
|
||||||
|
data-protection-encrypted and single-purpose. Matches the SEC-25 recommendation
|
||||||
|
to defer heavy revocation.
|
||||||
|
|
||||||
|
## 11. Security considerations
|
||||||
|
|
||||||
|
- **No new value-leak surface.** SEC-25 already redacts values in the mirror; this
|
||||||
|
change only narrows *which sessions'* metadata a Viewer sees. Strictly a
|
||||||
|
tightening.
|
||||||
|
- **Fail closed.** Session-not-found, empty grant, and untagged-under-`AdminOnly`
|
||||||
|
all deny. A misconfigured/empty `GroupToTag` yields Viewers who see nothing
|
||||||
|
(except untagged-under-`AllViewers`) — safe, not open.
|
||||||
|
- **Default preserves single-tenant ergonomics** only if the operator opts into
|
||||||
|
`UntaggedSessionVisibility=AllViewers`; the shipped default (`AdminOnly`) is the
|
||||||
|
strict one, so an upgrade tightens rather than loosens.
|
||||||
|
- **Staleness bound.** A revoked tag grant takes effect within one token lifetime
|
||||||
|
(≤5 min) for token-auth connections and immediately for a fresh cookie login.
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user