Files
mxaccessgw/archreview/40-security-dashboard.md
T
Joseph Doherty 59856b8c63 docs(archreview): add architecture review + per-domain remediation designs and tracker
Adds the 2026-07-08 architecture review (00-overall + six domain reports)
and a remediation/ tree: one design+implementation doc per domain covering
every finding, plus 00-tracking.md as the master progress tracker.

- 153 findings with stable IDs (GWC/WRK/IPC/SEC/CLI/TST), each with
  design rationale, implementation steps, tests, docs, and verification.
- Tracker rolls findings up by severity and P0/P1/P2 roadmap tier, records
  cross-cutting clusters and per-finding status (all Not started).
- Planning docs only; no source changes.
2026-07-09 00:39:00 -04:00

187 lines
31 KiB
Markdown

# Security, Dashboard & Observability — Architecture Review
## Scope & method
This review covers `src/ZB.MOM.WW.MxGateway.Server`: `Security/` (API-key authentication glue, gRPC authorization interceptor, scope resolver, constraint enforcement, audit, TLS), `Dashboard/` (LDAP login, cookie/hub-token authentication, SignalR hubs, admin services, redaction), `Metrics/`, `Diagnostics/`, and the security-relevant parts of `Configuration/`. Method: static reading of the actual source on the macOS tree (no builds, no runtime probes, no source modifications), cross-checked against `gateway.md`, `glauth.md`, `docs/Authentication.md`, `docs/Authorization.md`, `docs/GatewayDashboardDesign.md`, `docs/GatewayConfiguration.md`, `docs/Metrics.md`, and `docs/Diagnostics.md`. Every finding cites a verified `path:line`.
An important structural fact for this review: the core API-key pipeline (parser, peppered HMAC hasher, `FixedTimeEquals` compare, SQLite stores, verifier, migrator) no longer lives in this repository. It moved to the shared `ZB.MOM.WW.Auth.ApiKeys` 0.1.2 package (`src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj:11`, remark in `src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs:16-23`). Claims about hashing and timing-safety in this report are therefore sourced from `docs/Authentication.md` and the package's test coverage in this repo, not from readable implementation code.
## Executive summary
- The confirmed lead is real: `src/ZB.MOM.WW.MxGateway.Server/C:\ProgramData\MxGateway\gateway-auth.db` is a literal filename containing backslashes — a genuine SQLite auth database (schema v2: `api_keys`, `api_key_audit`, `schema_version`) created in the source tree because the Windows-absolute default path in `AuthenticationOptions.cs:9` is treated as a relative filename on macOS. It contains **zero key rows and zero audit rows**, and it is untracked (caught by the `*.db` gitignore rule), so no hash material leaked — this time.
- The gRPC authorization design is sound: one global interceptor, fail-closed scope fallback to `admin`, distinct `Unauthenticated`/`PermissionDenied`, and an ambient identity accessor consumed by the constraint enforcer and browse-scope provider.
- The scope resolver is missing an arm for `QueryActiveAlarmsRequest`, so that RPC silently demands the `admin` scope; the two tests that appear to cover it actually construct `StreamAlarmsRequest`, masking the gap.
- `AllowAnonymousLocalhost` (default `true`) satisfies **every** dashboard authorization requirement on loopback — including `AdminOnly` — not just Viewer. Service-layer re-checks currently prevent anonymous key/session management, but the policy layer is a single-line mistake away from anonymous local admin.
- The dashboard cookie is named `MxGatewayDashboard`; `gateway.md`, `docs/GatewayDashboardDesign.md`, and `CLAUDE.md` all still claim `__Host-MxGatewayDashboard`. The `__Host-` browser-enforced protections documented everywhere are not actually in effect.
- Secret-logging discipline is good: LDAP passwords and the pepper are redacted from effective config, the Serilog redaction seam masks API keys, and no logging call site was found emitting passwords, secrets, or credentials.
- Per-RPC authentication costs a SQLite read **and** a `last_used_utc` write on every call, with no caching; this is the gateway's per-call throughput ceiling.
- `mxgateway.heartbeats.failed` is tagged with `session_id`, an unbounded-cardinality label on an exported counter.
- Underdeveloped areas: no API-key expiry, no rate limiting or lockout on either auth surface, hub bearer tokens are irrevocable for 30 minutes, dashboard Close/Kill actions bypass the canonical audit store, and the per-session EventsHub ACL is an acknowledged TODO.
## Findings
### Security
**SEC-1 · Medium — Windows-absolute default paths become relative files on non-Windows; a real auth DB materialized inside the source tree.**
Evidence: the stray file `src/ZB.MOM.WW.MxGateway.Server/C:\ProgramData\MxGateway\gateway-auth.db` (32 KB, SQLite 3.x, tables `api_keys`/`api_key_audit`/`schema_version`, zero rows in both data tables); default in `src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs:9` and `src/ZB.MOM.WW.MxGateway.Server/appsettings.json:17`.
Mechanism: on Unix, `C:\ProgramData\MxGateway\gateway-auth.db` contains no path separators, so the shared `AuthSqliteConnectionFactory` (which "ensures the parent directory exists" per `docs/Authentication.md:110`) sees no parent directory and SQLite creates the whole string as a single filename relative to the content root, which `GatewayApplication.ResolveContentRootPath` resolves to the server project directory (`src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs:132-157`). The migration hosted service (`RunMigrationsOnStartup` default `true`, `AuthenticationOptions.cs:15`) or a CLI run then creates the schema. The startup validator does not catch this: `AddIfInvalidPath` only requires `Path.GetFullPath` to succeed (`src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs:40-43,383-410`), and the string is a valid relative path on Unix.
Impact: (a) the auth DB location silently depends on host OS and working directory, so keys created on one launch path are invisible on another; (b) had a key been created on this tree, its peppered hash, key id, scopes, and audit rows (with remote addresses) would sit inside the repo protected only by the generic `*.db` gitignore entry; (c) the same defect class applies to `src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs:11-12` (`SelfSignedCertPath` — a **private-key PFX** would be written into the tree if an HTTPS endpoint were configured on a non-Windows host) and `appsettings.json:80` (`SnapshotCachePath`).
Recommendation: derive defaults from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` + `Path.Combine`, and make `GatewayOptionsValidator` fail startup when `Path.IsPathRooted` is false for `SqlitePath`/`SelfSignedCertPath`. Delete the stray file and add an explicit ignore (or a test) that fails if a `*.db` ever appears under `src/`.
**SEC-2 · Medium — `AllowAnonymousLocalhost` satisfies the Admin requirement, not just Viewer.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs:32-37` calls `context.Succeed(requirement)` for any loopback request before the role loop runs, and the same handler serves `DashboardAuthorizationRequirement.AdminOnly` and `AnyDashboardRole`. The same applies to `Authentication:Mode=Disabled` at lines 25-30, which succeeds every requirement for **remote** requests too.
Impact: any endpoint or component gated solely by the `MxGateway.Dashboard.Admin` policy is authorized for an anonymous local process (or for everyone when auth is disabled). Today the destructive surfaces are saved by service-layer re-checks that require an authenticated principal with the Admin role (`Dashboard/DashboardApiKeyAuthorization.cs:10-18`, `Dashboard/DashboardApiKeyManagementService.cs:35-37`, `Dashboard/DashboardSessionAdminService.cs:33-39`), so the docs' claim that anonymous localhost is read-only holds — but only by defense-in-depth, not by the policy. Additionally, the loopback bypass grants anonymous local processes full SignalR hub access (`HubClientsPolicy` uses the same requirement): the snapshot hub pushes the API-key inventory (key ids, scopes, constraints) and effective configuration every second (`Dashboard/DashboardSnapshotService.cs:102-103`), and `EventsHub.SubscribeSession` lets them join any session's raw `MxEvent` feed (`Dashboard/Hubs/EventsHub.cs:47-55`), including tag values.
Recommendation: make the loopback bypass satisfy only the Viewer requirement (check `requirement.RequiredRoles` before succeeding), and decide explicitly whether anonymous loopback should reach the hubs at all. Note the loopback test trusts `Connection.RemoteIpAddress` (`DashboardAuthorizationHandler.cs:56-61`); if a reverse proxy or forwarded-headers middleware is ever added, revisit.
**SEC-3 · Medium — Dashboard cookie lost its `__Host-` prefix; four documents still promise it.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs:38` (`CookieName = "MxGatewayDashboard"`) versus `gateway.md:211`, `docs/GatewayDashboardDesign.md:425`, `docs/GatewayProcessDesign.md:686`, `docs/ImplementationPlanGateway.md:454`, and the project `CLAUDE.md`. Only `docs/GatewayConfiguration.md:170` reflects the real name.
Impact: the `__Host-` prefix's browser-enforced guarantees (Secure required, no `Domain`, `Path=/`) are gone. The cookie is still HttpOnly/SameSite=Strict/SecurePolicy-controlled via `ZbCookieDefaults.Apply` (`Dashboard/DashboardServiceCollectionExtensions.cs:93-109`), and `RequireHttpsCookie=false` support plus the configurable `CookieName` (`Configuration/DashboardOptions.cs:39,50`) are legitimate reasons the prefix was dropped — but the security docs now overstate the cookie's protections.
Recommendation: either restore `__Host-` as the default when `RequireHttpsCookie` is true, or update `gateway.md`/design docs/`CLAUDE.md` in one change to describe the actual cookie contract ("update docs in the same change as the source" is a stated repo rule).
**SEC-4 · Medium — `DisableLogin` has no production guard.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs:63-90` swaps in `DashboardAutoLoginAuthenticationHandler` under the cookie scheme for **all** clients (remote included, `Dashboard/DashboardAutoLoginAuthenticationHandler.cs:56-62`); `GatewayOptionsValidator.ValidateDashboard` (`Configuration/GatewayOptionsValidator.cs:219-253`) never inspects `DisableLogin`, and the warning only fires on first `GatewayOptions` resolution.
Impact: a single copied config flag turns the entire dashboard — including API-key CRUD and worker Kill — into an unauthenticated admin surface on a network-exposed port (both deployed hosts bind 0.0.0.0).
Recommendation: fail startup (or force the flag off) when `IHostEnvironment.IsProduction()` and `DisableLogin` is true; at minimum, add a validator error so the misconfiguration is fail-fast like every other section.
**SEC-5 · Medium — Hub bearer tokens are irrevocable and travel in query strings.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/HubTokenService.cs:29,44-52` (30-minute data-protected token, no jti/revocation state, roles frozen at issue time) and `Dashboard/HubTokenAuthenticationHandler.cs:59-61` (accepts `?access_token=` on the WebSocket upgrade).
Impact: logout (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:136-155`) clears the cookie but every previously minted hub token stays valid for up to 30 minutes; a token captured from an access log or proxy grants live snapshot/alarm/event access. The query-string carriage is the standard SignalR pattern but puts bearer material where request logging can see it.
Recommendation: keep the lifetime short (or shorten to ~5 minutes given the factory refreshes per reconnect, `docs/GatewayDashboardDesign.md:497-499`), and confirm no request-path logging captures query strings (Serilog request logging is not currently enabled; keep it that way or scrub `access_token`).
**SEC-6 · Medium — LDAP is plaintext-by-default with a committed service-account password.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Configuration/LdapOptions.cs:49-61` (defaults `Transport=None`, `AllowInsecure=true`, `ServiceAccountPassword = "serviceaccount123"`), `appsettings.json:21-33` (same values checked into the repo), `glauth.md:30,327` (dev LDAPS disabled; "binding sends passwords cleartext on the wire").
Impact: every dashboard login sends the operator's password in cleartext to `10.100.0.35:3893`, and the LDAP service-account credential is in source control. This is a documented dev posture (the shadow-options rationale at `LdapOptions.cs:20-28` is explicit that the shared library is secure-by-default), and the validator does enforce the `Transport=None ⇒ AllowInsecure` consistency rule (`GatewayOptionsValidator.cs:82-85`) — but nothing distinguishes dev from prod at runtime.
Recommendation: for production deployment docs, require `Transport=Ldaps`/`StartTls` + `AllowInsecure=false` and move `ServiceAccountPassword` to env-var/secret configuration; consider an `IsProduction` startup check mirroring SEC-4. LDAP injection risk is delegated to the shared `ZB.MOM.WW.Auth.Ldap` provider (bind-then-search per `Dashboard/DashboardAuthenticator.cs:41-47`); its escaping cannot be verified from this repo — flag for review in the donor repo.
**SEC-7 · Low — Credential-bearing command list omits the secured-bulk variants.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs:11-16` names `AuthenticateUser`, `WriteSecured`, `WriteSecured2` but not `WriteSecuredBulk`/`WriteSecured2Bulk`, which exist as command kinds (`Security/Authorization/GatewayGrpcScopeResolver.cs:41-45`).
Impact: currently latent — `RedactCommandValue`/`IsCredentialBearingCommand` have no call sites in the server outside the redactor itself (verified by grep), so no value logging occurs at all. If value logging is ever wired up per `docs/Diagnostics.md:124-148`, secured-bulk payloads (which carry credentials) would pass the unconditional-redaction check.
Recommendation: add the two bulk names now, and add a test asserting every `WriteSecured*` command kind is credential-bearing.
**SEC-8 · Low — `/metrics` and `/health` are mapped with no visible authorization; a metric leaks session ids.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs:196-197` maps `MapZbHealth()`/`MapZbMetrics()` (shared packages) with no `RequireAuthorization`; `Metrics/GatewayMetrics.cs:354` tags `mxgateway.heartbeats.failed` with `session_id`.
Impact: if the packages do not enforce auth internally (not verifiable from this repo), an unauthenticated scraper on the gRPC/dashboard port can read gateway telemetry, including live session identifiers usable with the anonymous-localhost hub surface (SEC-2).
Recommendation: verify the shared packages' endpoint auth; if anonymous, either bind metrics to a loopback-only endpoint or replace the `session_id` tag (see PERF-2).
**SEC-9 · Low — GET `/logout` skips antiforgery.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs:53-58` (comment acknowledges the choice).
Impact: a third-party page can sign a dashboard operator out (nuisance-level CSRF; no state beyond the session is affected). POST login/logout correctly validate antiforgery (`:104,140`).
Recommendation: acceptable as documented; consider a confirmation interstitial if it ever grows side effects.
**SEC-10 · Low — CLI accepts the pepper as a command-line argument.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Program.cs:37-40` (`command.Pepper``MxGateway:ApiKeyPepper`).
Impact: the pepper lands in shell history and the process command line visible to other local users.
Recommendation: prefer an environment variable or prompt; document the risk in the CLI help.
**SEC-11 · Info — Positive observations.**
Verified: the interceptor treats every unrecognized request type as `admin` (fail-closed, `Security/Authorization/GatewayGrpcScopeResolver.cs:28`); authentication failures are opaque to clients (`Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:66-78`); the effective-config surface redacts the pepper name and LDAP password (`Configuration/GatewayConfigurationProvider.cs:20,30`); the Galaxy connection string is rebuilt field-by-field for display (`Dashboard/DashboardConnectionStringDisplay.cs:14-24`); the Serilog seam masks identity-bearing properties globally (`Diagnostics/GatewayLogRedactorSeam.cs:17-27`); self-signed PFX generation hardens file permissions before writing private-key bytes and clears the buffer (`Security/Tls/SelfSignedCertificateProvider.cs:160-193`); scope strings are validated against the canonical catalog on every creation path (`Security/Authorization/GatewayScopes.cs:20-36`, `Dashboard/DashboardApiKeyManagementService.cs:297-304`); dashboard key-management inputs constrain key ids to a safe character set (`Dashboard/DashboardApiKeyManagementService.cs:309-321`); the login flow returns one generic failure message for every failure mode (`Dashboard/DashboardAuthenticator.cs:26,34-67`); `SanitizeReturnUrl` blocks open redirects (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:196-207`). No grep hit was found for any logging call emitting passwords, secrets, peppers, or credentials.
### Stability
**STA-1 · Medium — `QueryActiveAlarmsRequest` is missing from the scope resolver, and the tests that claim to cover it test the wrong request type.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs:15-29` has no `QueryActiveAlarmsRequest` arm, so the RPC (`Grpc/MxAccessGatewayService.cs:212-213`, proto `mxaccess_gateway.proto:37`) falls to the `_ => GatewayScopes.Admin` default. The two tests named `ServerStreamingServerHandler_QueryActiveAlarms…` construct `new StreamAlarmsRequest()` instead (`src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs:322-359`), so they pass while asserting the intended `events:read` behavior against the wrong message. `docs/Authorization.md`'s scope table (lines 200-215) omits the RPC entirely.
Impact: fail-closed, so not a vulnerability — but a client holding `events:read` (the documented alarm/event scope) receives `PermissionDenied` demanding `admin` when calling `QueryActiveAlarms`, contradicting the stated design that alarm snapshot data shares the event surface.
Recommendation: add `QueryActiveAlarmsRequest => GatewayScopes.EventsRead`, fix both tests to use the real request type, and add the row to `docs/Authorization.md`.
**STA-2 · Low — The interceptor does not override client-streaming or duplex handlers.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:21-47` overrides only `UnaryServerHandler` and `ServerStreamingServerHandler`. All current RPCs are unary or server-streaming (verified against both `.proto` files), so nothing bypasses auth today.
Impact: a future duplex/client-streaming RPC would run with **no** authentication and no scope check, silently.
Recommendation: override `ClientStreamingServerHandler` and `DuplexStreamingServerHandler` to throw `Unimplemented` (or run the same auth path with an `admin` fallback scope) so the failure mode is loud.
**STA-3 · Low — Pepper-unavailable detection matches library exception message text.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs:21,281-282` catches `InvalidOperationException` whose `Message` contains `"pepper unavailable"`.
Impact: a wording change in `ZB.MOM.WW.Auth.ApiKeys` turns the friendly "pepper is not configured" result into an unhandled exception on the Blazor circuit.
Recommendation: ask the library to expose a typed exception (the pre-cutover code had `ApiKeyPepperUnavailableException` per `docs/Authentication.md:68`) and catch that.
**STA-4 · Low — Canonical audit store re-issues `CREATE TABLE IF NOT EXISTS` on every write and read.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Security/Audit/SqliteCanonicalAuditStore.cs:54,94,131-136`.
Impact: an extra round-trip per audit operation and a schema definition that lives outside the migrator, so a future column change has no migration path — the `IF NOT EXISTS` silently keeps the old shape.
Recommendation: move `audit_event` creation into the startup migration path and drop the per-call ensure.
### Performance
**PERF-1 · Medium — Every authenticated RPC performs a SQLite read plus a `last_used_utc` write; there is no verification cache.**
Evidence: the interceptor calls `IApiKeyVerifier.VerifyAsync` per call (`Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs:69-71`); the verifier contract is find-by-id → hash → compare → `MarkKeyUsedAsync` per `docs/Authentication.md:72-96,122`, which explicitly notes the write "runs on every authenticated request". The interceptor additionally deserializes the constraints JSON per call (`Security/Authentication/GatewayApiKeyIdentityMapper.cs:41`).
Impact: WAL and busy-timeout make this correct, but a per-call database **write** is the gateway's throughput ceiling on the hot path (bulk reads at high frequency are the primary workload), causes continuous WAL churn on `gateway-auth.db`, and makes auth-store latency a tail-latency contributor to every RPC.
Recommendation: add a short-TTL (5-30 s) in-memory verification cache keyed by key id + presented-hash, invalidated on revoke/rotate (all mutations flow through in-process `ApiKeyAdminCommands`), and coalesce `last_used_utc` updates to at most one write per key per minute.
**PERF-2 · Low — `session_id` as a metric tag is unbounded cardinality.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs:354` (`mxgateway.heartbeats.failed` tagged `session_id`); `docs/Metrics.md:47` documents it.
Impact: every session ever opened mints a new exporter time series; long-running gateways with session churn bloat Prometheus/OTLP storage. (The in-memory `EventsBySession` map is correctly pruned on session close, `GatewayMetrics.cs:310-313` — the problem is only the exported tag.)
Recommendation: drop the tag (keep the aggregate counter) and rely on the dashboard snapshot/log scope for per-session attribution.
**PERF-3 · Low — The snapshot publisher works every second regardless of audience, including a SQLite query per tick.**
Evidence: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs:134-166` (hosted publisher loop), `:256-279` (`RefreshApiKeySummariesAsync` calls `IApiKeyAdminStore.ListAsync` on every tick, gated and 2-s-timeboxed), `:76-105` (`GetSnapshot` rebuilds session/worker/metric/fault/config projections each tick). The Galaxy breakdown is properly memoized by sequence (`:107-129`).
Impact: constant background SQLite reads and allocation churn on an idle gateway; the full snapshot (including API-key summaries and effective configuration) is serialized to all hub clients every second.
Recommendation: refresh the API-key summary on a slower cadence (or on mutation, since all mutations are in-process), and consider skipping publication when the hub has no connections.
### Conventions
**CON-1 · Medium — The dashboard design doc's configuration sample now fails startup validation.**
Evidence: `docs/GatewayDashboardDesign.md:515-519` shows `"GroupToRole": { "GwAdmin": "Admin", … }`, but `GatewayOptionsValidator.cs:233-238` accepts only `DashboardRoles.Admin` = `"Administrator"` (`Dashboard/DashboardRoles.cs:14`) or `"Viewer"` (ordinal compare). The live `appsettings.json:66-69` correctly uses `"Administrator"`.
Impact: an operator copying the documented sample gets a fail-fast boot error; the doc also still describes the role as `Admin` throughout (e.g. lines 412-413, 434).
Recommendation: update the doc sample and prose to the canonical `Administrator` value (the rename is otherwise well-annotated in `glauth.md:79-83`).
**CON-2 · Low — `docs/Authentication.md` documents implementation types that no longer exist in this repository.**
Evidence: the doc presents `ApiKeyParser`, `ApiKeySecretGenerator`, `SqliteApiKeyStore`, `AuthStoreServiceCollectionExtensions.AddSqliteAuthStore()` with a parameterless signature and code excerpts (`docs/Authentication.md:9-28,36-48,253-272`) — the real registration is the package-delegating two-parameter method (`Security/Authentication/AuthStoreServiceCollectionExtensions.cs:41-105`), and the store/verifier code lives in `ZB.MOM.WW.Auth.ApiKeys`. The doc also states the library's `api_key_audit` table is written on every denial (`:122,131`), but the audit store override redirects all writes to `audit_event`, leaving `api_key_audit` unused (`Security/Audit/CanonicalForwardingApiKeyAuditStore.cs:21-25`).
Impact: violates the repo's "no stale prose" documentation rule; a maintainer auditing hashing or storage from the doc will look for code that is not there.
Recommendation: rewrite `docs/Authentication.md` as a consumer-side doc: token format, options binding, the audit-store override, and a pointer to the donor library for internals.
**CON-3 · Info — UI-stack rule verified compliant.**
Evidence: `wwwroot/lib/` contains only `bootstrap/css/bootstrap.min.css` and `bootstrap/js/bootstrap.bundle.min.js`; grep for MudBlazor/Radzen/Syncfusion/Telerik across `.csproj`/`.razor`/`.cs` returns nothing. The shared `ZB.MOM.WW.Theme` package provides CSS only (`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:178-179`).
**CON-4 · Low — Validator coverage gaps against `docs/GatewayConfiguration.md`.**
Evidence: `GatewayOptionsValidator.ValidateDashboard` (`Configuration/GatewayOptionsValidator.cs:219-253`) validates `GroupToRole`, snapshot interval, and the two limits, but ignores `DisableLogin`, `AutoLoginUser`, `RequireHttpsCookie`, and `CookieName` (all documented at `docs/GatewayConfiguration.md:169-177`). A `CookieName` beginning with `__Host-` combined with `RequireHttpsCookie=false` produces a cookie browsers silently drop — no startup diagnosis.
Impact: silent misconfiguration classes the validator was built to prevent.
Recommendation: add the `__Host-`/`RequireHttpsCookie` consistency check and the SEC-4 production guard; validate `AutoLoginUser` is non-blank-or-null semantics explicitly.
**CON-5 · Low — Effective-config view omits the riskiest dashboard flags.**
Evidence: `Configuration/GatewayConfigurationProvider.cs:57-64` projects `EffectiveDashboardConfiguration` without `DisableLogin`, `AutoLoginUser`, `RequireHttpsCookie`, or `CookieName`; TLS and Alarms sections are absent from `EffectiveGatewayConfiguration` entirely.
Impact: the Settings page cannot show an operator that login is disabled — the one flag they most need to see.
Recommendation: add the missing fields (values are non-secret).
### Underdeveloped
**UND-1 · Medium — No API-key expiry.**
Evidence: the `api_keys` schema has `created_utc`/`last_used_utc`/`revoked_utc` only (confirmed from the stray DB's `sqlite_master` and `docs/Authentication.md:128-130`); no expiry field, no expiry check in the documented verification flow, no dashboard staleness surfacing beyond `LastUsedUtc` display.
Impact: keys live until an operator remembers to revoke; a leaked key is valid indefinitely.
Recommendation: add optional `expires_utc` in the shared library and enforce it in the verifier; surface age/staleness warnings on the API Keys page.
**UND-2 · Medium — No rate limiting or lockout on either authentication surface.**
Evidence: the gRPC interceptor verifies unconditionally per call (`GatewayGrpcAuthorizationInterceptor.cs:54-91`); `/auth/login` has no throttle (`DashboardEndpointRouteBuilderExtensions.cs:38-43,99-134`); the only brake is dev GLAuth's per-IP 3-fail lockout (`glauth.md:35`), which production AD will not replicate and which a shared NAT makes hazardous anyway (`glauth.md:323-325`).
Impact: unbounded online guessing of API-key secrets (mitigated by 256-bit secrets, but each guess costs the gateway a SQLite read) and unthrottled LDAP credential stuffing relayed to the directory.
Recommendation: add ASP.NET Core rate limiting on `/auth/login` and a cheap per-peer failure counter (or fixed-window limiter) in front of `VerifyAsync`.
**UND-3 · Medium — Dashboard session/worker Close and Kill bypass the canonical audit store.**
Evidence: `Dashboard/DashboardSessionAdminService.cs:64-69,129-134` record the action via `ILogger` only; API-key operations write `AuditEvent`s through `IAuditWriter` (`Dashboard/DashboardApiKeyManagementService.cs:242-264`).
Impact: destructive operational actions (killing a worker mid-production) leave no durable, queryable audit row; the log line is subject to log rotation and is invisible to the dashboard's recent-audit view.
Recommendation: emit `dashboard-close-session`/`dashboard-kill-worker` `AuditEvent`s through the existing `IAuditWriter`, mirroring the API-key pattern (actor resolution helpers already exist).
**UND-4 · Low — Per-session EventsHub ACL is an acknowledged TODO.**
Evidence: `Dashboard/Hubs/EventsHub.cs:29-44` (`TODO(per-session-acl)`) — any Viewer (and anonymous localhost per SEC-2) can subscribe to any session's raw event feed, which bypasses the per-gRPC-subscriber filtering (`docs/GatewayDashboardDesign.md:170`).
Impact: acceptable per the in-code rationale for v1, but it is the single seam where tag values reach the least-privileged principals; combine with `ShowTagValues=false` expectations and it can surprise operators.
Recommendation: keep the TODO but tie it to the tracked per-session-ACL work item; consider redacting event values in the dashboard mirror when `ShowTagValues` is false.
**UND-5 · Low — Audit trail has no retention or pruning.**
Evidence: `audit_event` is append-only with no cleanup path (`Security/Audit/SqliteCanonicalAuditStore.cs`); constraint denials append per denied bulk entry (`docs/Authorization.md:194-198`).
Impact: a misconfigured constrained client hammering denied reads grows the auth DB without bound.
Recommendation: add a retention sweep (age- or row-count-based) or document the operational expectation to archive.
**UND-6 · Low — Dashboard `GatewayStatus` is hardcoded.**
Evidence: `Dashboard/DashboardSnapshotService.cs:17,96` (`GatewayStatus: HealthyStatus` constant).
Impact: the home page's headline status never reflects the registered health checks (e.g. `AuthStoreHealthCheck` unhealthy while the banner says Healthy).
Recommendation: project `HealthCheckService` results into the snapshot.
**UND-7 · Info — Value-logging feature is unwired.**
Evidence: `GatewayLogRedactor.RedactCommandValue`/`IsCredentialBearingCommand` have no call sites in the server (grep-verified); the opt-in value-logging flag described in `docs/Diagnostics.md:124-148` has no configuration knob in `GatewayOptions`.
Impact: currently the safest possible state (no values are logged anywhere); the doc implies a capability that does not exist end-to-end.
Recommendation: either wire the flag or trim the doc to match (see SEC-7 before wiring).
## Top 5 recommendations
1. **Make filesystem defaults cross-platform and fail-fast** (SEC-1): replace the three Windows-literal defaults (`AuthenticationOptions.SqlitePath`, `TlsOptions.SelfSignedCertPath`, Galaxy `SnapshotCachePath`) with `SpecialFolder`-derived paths, reject non-rooted paths in `GatewayOptionsValidator`, and delete the stray `C:\ProgramData\MxGateway\gateway-auth.db` file from the source tree.
2. **Constrain the loopback bypass to the Viewer requirement** (SEC-2) so `AllowAnonymousLocalhost` can never satisfy `AdminOnly`, and decide deliberately whether anonymous loopback should reach the SignalR hubs and the API-key inventory in the snapshot payload.
3. **Fix the `QueryActiveAlarms` scope gap and its mislabeled tests** (STA-1): map `QueryActiveAlarmsRequest => events:read`, correct the two tests that construct `StreamAlarmsRequest`, and update the `docs/Authorization.md` scope table.
4. **Add production guards for the dev bypasses** (SEC-4, CON-4): fail startup when `DisableLogin=true` in a Production environment, and validate the `CookieName`/`RequireHttpsCookie` combination; reconcile the `__Host-` cookie documentation (SEC-3) in the same change.
5. **Cache API-key verification and batch `last_used_utc` writes** (PERF-1), and drop the `session_id` tag from `mxgateway.heartbeats.failed` (PERF-2), removing the per-RPC database write from the hot path and the unbounded metric cardinality in one observability-focused change.