From dca09e7e06fadfe0684b33dde5843be4f442d33e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 03:25:26 -0400 Subject: [PATCH] docs(archreview): flesh out TST-15 dashboard per-session ACL design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- archreview/remediation/00-tracking.md | 1 + .../remediation/60-testing-docs-gaps.md | 2 +- .../2026-07-10-dashboard-session-acl-tst15.md | 297 ++++++++++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-07-10-dashboard-session-acl-tst15.md diff --git a/archreview/remediation/00-tracking.md b/archreview/remediation/00-tracking.md index 8bf4044..e60f860 100644 --- a/archreview/remediation/00-tracking.md +++ b/archreview/remediation/00-tracking.md @@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog | 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 — **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` 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. | diff --git a/archreview/remediation/60-testing-docs-gaps.md b/archreview/remediation/60-testing-docs-gaps.md index 1339f2b..b89c192 100644 --- a/archreview/remediation/60-testing-docs-gaps.md +++ b/archreview/remediation/60-testing-docs-gaps.md @@ -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). -**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. diff --git a/docs/plans/2026-07-10-dashboard-session-acl-tst15.md b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md new file mode 100644 index 0000000..c1e23d7 --- /dev/null +++ b/docs/plans/2026-07-10-dashboard-session-acl-tst15.md @@ -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__`) | 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 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 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 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__..) → session.OwnerKeyId = , + 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. +```