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.
31 KiB
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.dbis 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 inAuthenticationOptions.cs:9is treated as a relative filename on macOS. It contains zero key rows and zero audit rows, and it is untracked (caught by the*.dbgitignore rule), so no hash material leaked — this time. - The gRPC authorization design is sound: one global interceptor, fail-closed scope fallback to
admin, distinctUnauthenticated/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 theadminscope; the two tests that appear to cover it actually constructStreamAlarmsRequest, masking the gap. AllowAnonymousLocalhost(defaulttrue) satisfies every dashboard authorization requirement on loopback — includingAdminOnly— 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, andCLAUDE.mdall 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_utcwrite on every call, with no caching; this is the gateway's per-call throughput ceiling. mxgateway.heartbeats.failedis tagged withsession_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 AuditEvents 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 AuditEvents 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
- Make filesystem defaults cross-platform and fail-fast (SEC-1): replace the three Windows-literal defaults (
AuthenticationOptions.SqlitePath,TlsOptions.SelfSignedCertPath, GalaxySnapshotCachePath) withSpecialFolder-derived paths, reject non-rooted paths inGatewayOptionsValidator, and delete the strayC:\ProgramData\MxGateway\gateway-auth.dbfile from the source tree. - Constrain the loopback bypass to the Viewer requirement (SEC-2) so
AllowAnonymousLocalhostcan never satisfyAdminOnly, and decide deliberately whether anonymous loopback should reach the SignalR hubs and the API-key inventory in the snapshot payload. - Fix the
QueryActiveAlarmsscope gap and its mislabeled tests (STA-1): mapQueryActiveAlarmsRequest => events:read, correct the two tests that constructStreamAlarmsRequest, and update thedocs/Authorization.mdscope table. - Add production guards for the dev bypasses (SEC-4, CON-4): fail startup when
DisableLogin=truein a Production environment, and validate theCookieName/RequireHttpsCookiecombination; reconcile the__Host-cookie documentation (SEC-3) in the same change. - Cache API-key verification and batch
last_used_utcwrites (PERF-1), and drop thesession_idtag frommxgateway.heartbeats.failed(PERF-2), removing the per-RPC database write from the hot path and the unbounded metric cardinality in one observability-focused change.