docs(archreview): round-2 re-review (2026-07-12) + 8 fix plans (86 tasks)
Re-ran all 8 domain reviews at HEAD8c888f13against theb910f5ebbaseline: every round-1 finding source-verified (168 fixed, 0 regressions, 0 false claims); 56 new findings (1 Critical / 4 High / 15 Medium / 36 Low), concentrated in post-baseline code (anti-entropy resync, KPI rollup backfill, live alarm stream) and seams the fixes exposed. Headliners: S&F resync predicate inversion can wipe the delivering node's buffer (02-N1 Critical); resync snapshot exceeds the Akka remoting frame size (02-N2); failover drill kills the one node keep-oldest can't survive (01-N1); unbounded rollup backfill per failover (04-R1); live production API key in untracked test.txt (08-NF1). Adds PLAN-R2-01..08 + .tasks.json manifests and the Round-2 board, P0 list, cross-plan mutexes, and wave order in 00-MASTER-TRACKER.
This commit is contained in:
@@ -1,126 +1,119 @@
|
||||
# Architecture Review 07 — Management & Presentation Surface
|
||||
# Architecture Review 07 — Management & Presentation Surface (Round 2)
|
||||
|
||||
**Domain:** Central UI (Blazor Server), Management Service (ManagementActor + HTTP API), CLI, Security & Auth
|
||||
**Reviewed:** `src/ZB.MOM.WW.ScadaBridge.CentralUI`, `src/ZB.MOM.WW.ScadaBridge.ManagementService`, `src/ZB.MOM.WW.ScadaBridge.CLI`, `src/ZB.MOM.WW.ScadaBridge.Security`, design docs `docs/requirements/Component-CentralUI.md`, `Component-ManagementService.md`, `Component-CLI.md`, `Component-Security.md`, and the corresponding test projects.
|
||||
**Date:** 2026-07-12
|
||||
**Domain:** Central UI (Blazor Server), Management Service (ManagementActor + HTTP API), CLI, Security & Auth — plus the post-initiative **Alarm Summary aggregated live-alarm stream** (central `ISiteAlarmLiveCache` / Blazor-circuit push side; the gRPC/site side belongs to review 02).
|
||||
**Baseline:** round-1 report at commit `b910f5eb`; this round re-reviews everything through HEAD `8c888f13` (PLAN-07 execution `74c295e3..d8a39f3c` plus post-initiative work: live alarm stream `b91ed3c8`/`8c888f13`, Test-Run WaitForAttribute `22136d36`, PLAN-06 edge spillover into UI forms).
|
||||
|
||||
## Method
|
||||
|
||||
1. Read the round-1 report and `archreview/plans/PLAN-07-ui-management-security.md` in full.
|
||||
2. Verified every round-1 finding's disposition **in current source** (no trust in plan claims) — every disposition below carries file:line evidence read this session.
|
||||
3. Fresh sweep of `git diff b910f5eb..HEAD` scoped to `CentralUI`, `ManagementService`, `CLI`, `Security` (54 files, +2,397/−231), with a focused review of the new Alarm Summary live path (`AlarmSummary.razor`, `AlarmSummaryService.BuildFromLiveAlarms`, `ISiteAlarmLiveCache`, `SiteAlarmLiveCacheService`).
|
||||
4. Static review only (suites verified green 2026-07-10); no builds/tests run.
|
||||
|
||||
## Round-1 vs Round-2 Verdict
|
||||
|
||||
**Round 1:** mature surface with sharp asymmetries — an auth-disabled production artifact, a silently-ignored deploy option, inconsistent secret projection, a fictional secured-write "Expired" state. **Round 2:** the fix plan genuinely landed — 17 of 24 findings fully fixed and verified in source (including both criticals), 6 partially fixed with the residue consciously deferred and documented, 1 was a false premise; the new code (frozen 139-entry authz matrix, dispatch-coverage guard, TTL'd secured writes, live alarm cache) is of high quality, and the remaining new findings are Medium-and-below — mostly seams the fixes themselves exposed (an unthrottled debug-hub bind the docs claim is covered, throttle keying behind Traefik, and site-scope not enforced on secured writes despite the doc saying scoping "is layered on at the LDAP-mapping level").
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
## Round-1 Finding Disposition
|
||||
|
||||
Deep read of the ManagementActor (all 3,157 lines), the HTTP management/audit endpoints and authenticator, the full Security project (JWT service, cookie session validator, authorization policies, role mapper, service registration), the secured-writes end-to-end path (UI page → `SecuredWriteService` → actor handlers), the debug-stream path (`DebugStreamHub`, `DebugView.razor`), representative Blazor pages (Alarm Summary, Health, Secured Writes, Notification Report), UI-side trust-boundary services (`BrowseService`), the CLI HTTP client and command wiring, and deployed configuration in `docker/` and `deploy/wonder-app-vd03/`. Spec-vs-code comparison against the four component documents.
|
||||
| Finding | R1 Severity | Disposition | Evidence (file:line) |
|
||||
|---|---|---|---|
|
||||
| C1 — `DisableLogin: true` shipped in production deploy artifact; no environment guard | Critical | **Fixed (verified)** | `Security/Auth/DisableLoginGuard.cs:17-28` (refuses outside Development without `AllowOutsideDevelopment` ack); wired fail-fast at the composition root `Host/Program.cs:149-154` *before* `AddSecurity`; `deploy/wonder-app-vd03/appsettings.Central.json` no longer contains `DisableLogin` (repo-wide grep: no non-`false` occurrence under `deploy/`, `docker/`, `docker-env2/`), and its `_comment_Security` documents the new posture |
|
||||
| C2 — `deploy artifacts --site-id` silently ignored; fleet-wide deploy with no scope check | High | **Fixed (verified)** | `ArtifactDeploymentService.cs:228-255` (`DeployToAllSitesAsync`/`DeployToSiteAsync` over shared `DeployCoreAsync`); `ManagementActor.cs:2295-2316` honors `cmd.SiteId`, adds the explicit fleet-wide guard for site-scoped non-admins (`SiteScopeViolationException`, :2300-2306) **and** `EnforceSiteScope` (:2307) |
|
||||
| C3 — DataConnection / ExternalSystem / DatabaseConnection secrets leak in List/Get + audit | High | **Fixed (verified)** | `ConfigSecretScrubber.cs` (recursive JSON elision + sentinel merge); `DataConnectionPublicShape` `ManagementActor.cs:1632-1647` applied to List/Get/Create/Update **and** audit afterState (:1653-1654, :1663, :1677-1678, :1697-1698), sentinel-preserving update :1692-1693; `ExternalSystemPublicShape` :1847-1857 + preserve-if-null :1897; `DatabaseConnectionPublicShape` :2835-2841 applied :2845-2878. Note: UI editors write via repositories directly (e.g. `ExternalSystemForm.razor`), so UI round-tripping was never at risk; CLI round-trips are sentinel-protected |
|
||||
| S1 — 30 s server Ask vs 5-min bundle operations; import keeps applying after 504 | High | **Partially fixed (verified; residue deferred)** | `ManagementEndpoints.cs:41-58` — `ResolveAskTimeout(options, commandType)` gives Import/Preview/Export/DeployArtifacts/DeployInstance a 5-min default (`ManagementServiceOptions.cs:9`), call site :126; `ManagementService` timeout config shipped to all three topologies (`docker/central-node-a/appsettings.Central.json:51`, `docker-env2/.../:48`, `deploy/wonder-app-vd03/.../:49`); `ManagementServiceOptionsValidator` fail-fast. **Still open (accepted, plan-05 domain):** `ImportBundleCommand` (`TransportCommands.cs:121-128`) has no client-supplied idempotency id — a timeout-then-retry can still double-apply; the `TransportGate` single-flight (:3203) only narrows the window on one node |
|
||||
| S2 — Pending secured writes never expire; "Expired" fictional | High | **Fixed (verified)** | `ISecuredWriteRepository.cs:100` (`TryMarkExpiredAsync` CAS), :111 (`CountAsync`); `TryExpireIfStaleAsync` `ManagementActor.cs:1224-1241` (TTL default 24 h, `ManagementServiceOptions.cs:12`; non-positive disables; emits `AuditKind.SecuredWriteExpire`); enforced **before** self-approval/CAS at approve (:1296-1302) and reject (:1453-1458); opportunistic sweep on list (:1493-1506); approve dialog now shows submission age (`SecuredWrites.razor:415`); spec'd in `Component-Security.md:145` |
|
||||
| S3 — Poll timers overlap their own refreshes | Medium | **Fixed (verified)** | `CentralUI/Services/PollGate.cs` (Interlocked CAS gate); wired in `AlarmSummary.razor:315-326` and `Health.razor:552-562` |
|
||||
| S4 — 200 MB bodies buffered as strings, ~4 copies resident | Medium | **Partially fixed (verified; residue deferred)** | `TransportGate` single-flight semaphore `ManagementActor.cs:3203-3213` wraps all three bundle handlers (released in `finally` :3317/:3361/:3442); export copy eliminated via `ms.GetBuffer().AsSpan(...)` :3309-3312. Body is still `StreamReader.ReadToEndAsync()` into a string (`ManagementEndpoints.cs:103-107`) with the 200 MB cap (:75); streaming multipart remains the deferred long-term shape |
|
||||
| S5 — Fire-and-forget SignalR sends swallow failures | Low | **Fixed (verified)** | `DebugStreamHub.cs:221-232` — `ContinueWith(OnlyOnFaulted)` logs push failures on both the event and termination paths while keeping fire-and-forget semantics |
|
||||
| P1 — Full LDAP bind per request; no throttle/lockout (spray oracle) | Medium | **Partially fixed (verified — one surface missed, see N1)** | `Security/LoginThrottle.cs` (fixed-window, per `username\|ip`, bounded at 10k keys, TimeProvider-driven); wired in `ManagementAuthenticator.cs:110-129` (covers `POST /management` + audit REST via `AuditEndpoints.cs:525`), `/auth/login` `AuthEndpoints.cs:38-58`, `/auth/token` :125-147. **`DebugStreamHub.OnConnectedAsync` still binds LDAP directly with no throttle** (`DebugStreamHub.cs:119-121`) — see new finding N1. Per-request bind cost itself unchanged (documented stateless design) |
|
||||
| P2 — Unbounded list handlers materialize whole tables | Medium | **Partially fixed (verified; residue accepted)** | Additive `Skip`/`Take` on `ListTemplatesCommand`/`ListInstancesCommand` (`TemplateCommands.cs:5`, `InstanceCommands.cs:5`); shared clamped `Page` helper `ManagementActor.cs:576-580`; instances paged **after** the site-scope filter :797-799. `QueryDeployments` unfiltered branch still loads all records + instances (:2334-2339) and `ExportBundle` still loads all-entities for name resolution — documented deferral, bounded by fleet size |
|
||||
| P3 — `ListSecuredWrites` hard-caps at 200, no paging | Medium | **Fixed (verified)** | `ListSecuredWritesCommand(…, int Skip = 0, int Take = 200)` `SecuredWriteCommands.cs:63`; handler clamps (1..500) + `CountAsync` total `ManagementActor.cs:1488-1491`; UI history pager with real bounds `SecuredWrites.razor:224-236, 343-349, 352-362` |
|
||||
| P4 — Alarm Summary re-sorts/filters per render | Low | **Fixed (verified)** | Memoized `_visibleRows` (`AlarmSummary.razor:217-220`), `RecomputeVisibleRows()` :401 invoked only from refresh/filter (`@bind:after`, :114-156)/sort (:461) |
|
||||
| C4 — Browse/Verify/Cert commands documented as actor commands but UI-only; no CLI parity | Medium | **Fixed (verified)** | Six actor handlers `ManagementActor.cs:3103-3161` (SiteIdentifier required + `EnforceSiteScopeForIdentifier` + relay via `CommunicationService`); dispatch arms :453-458; role gates: browse/search/verify → Designer (:258), cert trust → Admin (:219-223); additive `SiteIdentifier` on the six Commons records; CLI `Commands/ConnectionBrowseCommands.cs` (browse/search/verify-endpoint/certs list\|trust\|remove) registered in `CLI/Program.cs:26-27` |
|
||||
| C5 — Role-casing asymmetry (UI ordinal vs actor ignore-case) | Medium | **Fixed (verified)** | `CanonicalizeMappingRole` `ManagementActor.cs:2203-2210` — stored casing canonicalized against `Roles.All` at both mapping write paths (:2212, :2223); invalid-role rejection message preserved |
|
||||
| C6 — Area role gate contradicts three docs | Medium | **Fixed (verified)** | Reconciled on any-of `[Designer, Deployer]`: `AreaManagers` `ManagementActor.cs:171-178`, arm :228; `Component-ManagementService.md:224` documents the any-of gate and the rationale (docs' "Admin" was the stale side) |
|
||||
| C7 — `InstanceName` passed into slot commented "InstanceId filter" | Low | **Fixed (verified)** | Behavior was correct (site `instance_id` stores UniqueName); comment now says so explicitly `ManagementActor.cs:3086-3089` |
|
||||
| C8 — Stale CLAUDE.md note on SecuredWrite `SourceNode` | Low | **Fixed (verified)** | CLAUDE.md Security & Auth section now reads "(SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.)" |
|
||||
| UA1 — Secured-write lifecycle (no expiry, no paging, history ungated, dual-role hazard) | UA | **Fixed (verified)** | Expiry + paging per S2/P3; `ListSecuredWrites` gated to `SecuredWriteReaders` = Operator/Verifier/Admin (`ManagementActor.cs:184-185, :290`) with matching UI policy `RequireSecuredWriteAccess` (`AuthorizationPolicies.cs:99-106, :179-180`; page attribute `SecuredWrites.razor:5`); dual-role deployment hazard restated in `Component-Security.md:143` |
|
||||
| UA2 — Test depth: no authz matrix over ~150 commands | UA | **Fixed (verified)** | `RequiredRoleMatrixTests.cs` — frozen ~139-entry `Expected` table + `EveryRegisteredCommand_IsInTheFrozenTable` reflection guard (:26, :194); `DispatchCoverageTests.cs` — every registered command must reach a dispatch arm (NotSupportedException sentinel; documented exclusions for `ResolveRolesCommand` and the site-routed `ReadTagValuesCommand`) |
|
||||
| UA3 — CLI.Tests not in slnx | UA | **Fixed (verified)** | `ZB.MOM.WW.ScadaBridge.slnx:52` (landed via PLAN-08 T1; PLAN-07 T33 correctly skipped as duplicate) |
|
||||
| UA4 — No single deferred-work tracking list | UA | **Fixed (verified)** | `docs/plans/2026-07-08-deferred-work-register.md` — actively maintained (deferred #10/#22/#12/#14 resolved through it post-initiative) |
|
||||
| UA5 — Login endpoints lack throttling; user-enumeration check | UA | **Partially fixed (verified)** | Throttling as per P1 (with the hub gap, N1). Enumeration verified safe: `LdapAuthFailureMessages.cs:79-80` maps `BadCredentials` and `UserNotFound` to the identical "Invalid username or password." |
|
||||
| UA6 — Accessibility debt on sortable headers | UA | **Partially fixed (accepted deferral)** | AlarmSummary headers: `tabindex="0"`, `aria-sort` (`AriaSortFor` :469-470), Enter/Space activation with Space preventDefault (:479-487, markup :182-191). Fleet-wide grid sweep explicitly deferred and logged in the component comment (:175-179) |
|
||||
| UA7 — "Committed" runtime logs under docker topology | UA | **No longer applicable (false premise)** | `git check-ignore docker/central-node-a/logs` → ignored; `git ls-files docker docker-env2 \| grep -i logs/` → empty. Untracked local output; nothing to scrub |
|
||||
|
||||
## Maturity Verdict
|
||||
|
||||
This is a **mature, carefully engineered surface with a small number of sharp edges**. The security layer is genuinely good — fail-fast key validation, a single shared claim builder for login and refresh, a deliberate and well-documented idle/refresh model, DB-persisted Data Protection keys, curated-vs-generic error mapping that prevents internal-detail leaks, and a secured-writes implementation with a real CAS race guard, server-side no-self-approval, and a TOCTOU protocol re-check at execute time. The Blazor code shows unusual discipline for disposal, thread marshalling, and best-effort degradation. The problems that remain are not sloppiness but *asymmetries*: a production deployment artifact that ships with authentication disabled, a per-site artifact-deploy option that is silently ignored server-side, secret-projection hygiene applied to SMTP/SMS/API keys but not to data connections / external systems / DB connection strings, and a secured-write "Expired" state that exists in the UI and spec but nowhere in the code. Test depth is thin relative to the actor's ~150-command surface.
|
||||
**Disposition counts:** Fixed (verified) **17** · Partially fixed **6** (S1, S4, P1, P2, UA5, UA6 — all residues consciously deferred and documented except the P1/UA5 hub gap, which is new finding N1) · No longer applicable **1** (UA7) · Not fixed / Regressed **0**.
|
||||
|
||||
---
|
||||
|
||||
## Findings — Dimension 1: Stability
|
||||
## New Findings — Round 2
|
||||
|
||||
### [High] S1 — Bundle import via `/management` can 504 while the import keeps applying
|
||||
`ManagementEndpoints.HandleRequest` Asks the actor with a configurable timeout that defaults to **30 s** (`ManagementEndpoints.cs:18`, `ResolveAskTimeout` at `:28-33`), and neither `docker/central-node-a/appsettings.Central.json` nor `deploy/wonder-app-vd03/appsettings.Central.json` sets `ScadaBridge:ManagementService:CommandTimeout`. The CLI's bundle commands use a **5-minute client timeout** (`CLI/Commands/BundleCommands.cs:16`) precisely because imports are slow — but the server-side Ask expires at 30 s, returns `504 TIMEOUT` (`ManagementEndpoints.cs:107-113`), **and the actor's `ProcessCommand` task keeps running** (PipeTo is not cancelled), so `HandleImportBundle` (`ManagementActor.cs:2949-3015`) continues applying the bundle after the caller was told it timed out. Failure scenario: an operator imports a large bundle, gets a 504, retries — under `--on-conflict rename` the second import produces a duplicate `-imported-<stamp>` artifact set; under `overwrite` it double-applies. The same ambiguity applies to fleet-wide `DeployArtifacts`. Fix: raise the configured timeout for transport/deploy commands (per-command timeout in the envelope), or make import idempotent on a client-supplied import id.
|
||||
### A. Security & Auth
|
||||
|
||||
### [High] S2 — Pending secured writes never expire; the "Expired" status is fictional
|
||||
The UI's terminal-status list and badge mapping include `"Expired"` (`CentralUI/Components/Pages/Operations/SecuredWrites.razor:265, 411`), and Component-CentralUI.md §Secured Writes lists "Executed / Failed / Rejected / Expired" — but **no code anywhere sets a row to Expired** (repo-wide grep: the only two hits are the UI file). `HandleApproveSecuredWrite` (`ManagementActor.cs:1099-1246`) will approve a `Pending` row of any age. Failure scenario on a live SCADA surface: an Operator submits a setpoint write, it sits unverified over a weekend while the process moves to a different state, and a Verifier approves the stale value days later — the confirmation dialog (`SecuredWrites.razor:342-347`) shows the value but not its age. A pending-write TTL (with a sweeper or an at-approve age check) is the obvious missing piece; the status enum already reserved the name.
|
||||
#### [Medium] N1 — DebugStreamHub still binds LDAP with no throttle; Component-Security.md falsely claims complete coverage
|
||||
`DebugStreamHub.OnConnectedAsync` extracts Basic-Auth credentials itself and calls `ILdapAuthService.AuthenticateAsync` directly (`DebugStreamHub.cs:118-124`) — it uses `ManagementAuthenticator` **only** for the DisableLogin bypass (`TryDisableLoginUser`, :82), not for the bind, so the `LoginThrottle` never sees hub connection attempts. Meanwhile `Component-Security.md:208` states the throttle scope includes "the debug-stream hub … No bind happens outside these paths, so throttling coverage is complete" — a doc statement that is simply untrue in code. A SignalR negotiate + connect per guess is clumsier than `POST /auth/token`, but it remains an unthrottled LDAP-bind oracle, and it also skips the `RecordSuccess`/`RecordFailure` bookkeeping that would let hub failures contribute to a shared lockout. Fix: route the hub's credential path through `ManagementAuthenticator.AuthenticateAsync` (it already returns roles + site scopes) or replicate the three throttle calls; correct the doc either way.
|
||||
|
||||
### [Medium] S3 — Poll timers can overlap their own refreshes
|
||||
`Health.razor:521-528` and `AlarmSummary.razor:283-291` both use `System.Threading.Timer` with a fixed period whose callback does `InvokeAsync(async () => { await RefreshAsync(); ... })` with no reentrancy guard. `AlarmSummary.RefreshAsync` fans out per-instance snapshot Asks (site offline ⇒ each fetch waits its full Ask timeout); with a 15 s period and a >15 s degraded fetch, ticks stack up on the dispatcher and the page issues overlapping fan-outs against an already-struggling site. A `PeriodicTimer` loop or an `Interlocked` in-flight flag would bound this. (Disposal itself is handled correctly on both pages — `Health.razor:757-760`, `AlarmSummary.razor:382`.)
|
||||
#### [Medium] N2 — LoginThrottle keys collapse behind Traefik: no ForwardedHeaders handling anywhere
|
||||
Every throttle surface keys on `context.Connection.RemoteIpAddress` (`ManagementAuthenticator.cs:111`, `AuthEndpoints.cs:39, :126`), and no `UseForwardedHeaders`/`ForwardedHeadersOptions` exists in the Host, CentralUI, or ManagementService (repo grep: zero hits). The documented topology fronts both the UI and the management API with Traefik (`CLAUDE.md` CLI quick reference: management URL is the LB on :9000), so in production **every client shares Traefik's IP** and the key degenerates to `username|<proxy-ip>`. Two consequences: (a) the per-IP isolation the design intends ("`alice@10.0.0.2` is not locked out by `alice@10.0.0.1`'s failures" — `LoginThrottleTests` asserts exactly this) does not exist in the shipped topology; (b) any network-adjacent actor can lock out any username — five wrong passwords for `admin` via the LB locks `admin` out for all real operators for `LoginLockoutMinutes`, repeatable indefinitely: a cheap login-DoS against a SCADA control surface. Fix: honor `X-Forwarded-For` from the trusted proxy only (`ForwardedHeadersOptions.KnownProxies`), or document the trade-off and consider a success-path bypass (a correct password unlocks) to blunt the DoS.
|
||||
|
||||
### [Medium] S4 — 200 MB request bodies are buffered as strings, then multiplied
|
||||
The management endpoint raises the body cap to **200 MB** for transport bundles (`ManagementEndpoints.cs:50, 58-62`) and then reads the whole body with `StreamReader.ReadToEndAsync()` into a string (`:77-81`); the actor then base64-decodes to a `byte[]` (`ManagementActor.cs:3105-3119`), wraps in a `MemoryStream`, and export goes the other way — `stream.CopyToAsync(ms); ms.ToArray(); Convert.ToBase64String(bytes)` (`ManagementActor.cs:2902-2905`), which is ~4 copies of the artifact resident simultaneously (bytes, MemoryStream buffer, base64 string, JSON envelope string). A concurrent pair of large imports on a central node is a realistic OOM/LOH-fragmentation path. Streaming multipart upload (as the audit export already streams downloads — `AuditEndpoints.cs:183+`) is the right long-term shape.
|
||||
#### [Medium] N3 — Secured-write handlers ignore site scope, while the spec says scoping "is layered on at the LDAP-mapping level"
|
||||
None of the four secured-write handlers calls `EnforceSiteScope`/`EnforceSiteScopeForIdentifier` (verified: zero occurrences in `ManagementActor.cs:1243-1510`): `HandleSubmitSecuredWrite` resolves the target site (:1247) but never checks `user.PermittedSiteIds`; approve/reject/list likewise. `Component-Security.md:141` explicitly says Operator/Verifier are "coarse global roles like the others; **any site scoping is layered on at the LDAP-mapping level**" — but a mapping row that grants Operator with `PermittedSiteIds=["3"]` still lets that principal submit (and a scoped Verifier approve) MxGateway **device writes** to every site. This is the same class of gap C2 just closed for `DeployArtifacts`, on a higher-consequence path. Fix: `EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId)` at submit and `row.SiteId` at approve/reject (+ scope-filter the list), or amend the spec to state that secured-write roles are exempt from site scoping — code or doc, one must move.
|
||||
|
||||
### [Low] S5 — Fire-and-forget SignalR sends swallow failures silently
|
||||
`DebugStreamHub.SubscribeInstance` pushes events with `_ = hubClients.Client(connectionId).SendAsync(...)` (`DebugStreamHub.cs:204-221`). Deliberate (documented) — but a persistently failing client produces zero telemetry. Elsewhere the hub is exemplary: transient-hub-instance pitfall avoided via `IHubContext` (`:198-200`), unsubscribe on disconnect (`:255-259`).
|
||||
### B. Alarm Summary live path (`ISiteAlarmLiveCache` → Blazor circuit)
|
||||
|
||||
### Positive stability observations
|
||||
- `DebugView.razor` is a model Blazor Server component: `_disposed` flag set before stream stop, `SafeInvokeAsync` guarding `ObjectDisposedException`, dictionary mutation marshalled onto the render thread with the race explicitly documented (`DebugView.razor:511-544, 589-611`).
|
||||
- Failover transparency is real: Data Protection keys persist to the config DB (`ConfigurationDatabase/ServiceCollectionExtensions.cs:75-76`), the cookie principal is self-contained, and the ManagementActor runs on every central node (stateless, per spec).
|
||||
- `AlarmSummaryService` implements the spec'd fan-out faithfully: `SemaphoreSlim(8)` cap, partial-results tolerance, caller-cancel propagation (`AlarmSummaryService.cs:26, 68-84, 112-123`).
|
||||
The design is genuinely good (see "What's genuinely good"), but the page-side reconciliation has seams:
|
||||
|
||||
#### [Medium] N4 — `RefreshAsync` applies results without re-checking the selected site: cross-site data displayed after a site switch
|
||||
`AlarmSummary.razor:283-297` captures `siteId` at entry, awaits the multi-second fan-out (`GetSiteAlarmsAsync`), then unconditionally assigns `_rows`/`_notReporting`/`_rollup`. A poll tick for site A in flight when the operator switches to site B completes **after** B's initial refresh and overwrites the page with A's alarms (and A's not-reporting list) labeled under B's picker — on an operator alarm page. The live path already has exactly the right guard (`OnLiveAlarmsChanged` drops callbacks when `_selectedSiteId != siteId`, :374); the poll path needs the same one line before assignment (`if (_selectedSiteId != siteId) return;`). Self-corrects within ≤15 s, but wrong-site alarm data for that window is not acceptable on this surface.
|
||||
|
||||
#### [Low] N5 — When live, a slower poll snapshot can regress fresher live state
|
||||
The 15 s poll always rebuilds `_rows` from the fan-out even when the cache is live (`AlarmSummary.razor:293-297`); the comment claims the poll "simply re-affirms the same snapshot" (:344-347), but a poll whose fan-out started before a live delta lands **after** it and momentarily reverts the delta (an alarm flickers back to its prior state until the next delta/poll). Cheap fix: when `LiveAlarmCache.IsLive(siteId)`, let the poll update only `_notReporting` (its unique authority) and skip the row rebuild — this also removes redundant per-instance snapshot load while live.
|
||||
|
||||
#### [Low] N6 — `IsLive` is sticky: `HasPublished` never resets on aggregator death or stream loss
|
||||
`SiteEntry.HasPublished` is set on first publish (`SiteAlarmLiveCacheService.cs:392-393`) and never cleared; `IsLive` (:132-136) therefore reports `true` forever, even if the aggregator actor later dies or the site goes dark and `entry.Current` freezes. Exposure is bounded (the poll keeps overwriting rows, and reconcile re-seeds a *living* aggregator), but `OnSiteChangedAsync` deliberately applies the live snapshot **on top of** the fresh poll when `IsLive` (`AlarmSummary.razor:277-280`) — with a dead aggregator that grafts an arbitrarily stale snapshot over correct data for up to 15 s. Consider clearing `HasPublished` (or publishing a degraded flag) when the actor terminates (a `Context.Watch`/deathwatch hook from the service) and having the interface expose staleness, not just "seeded once".
|
||||
|
||||
#### [Low] N7 — Live-callback `InvokeAsync` lacks the house disposal guard
|
||||
`OnLiveAlarmsChanged` does `_ = InvokeAsync(...)` with no `_disposed` flag (`AlarmSummary.razor:367-382`); a callback racing `Dispose()` can fault the discarded task with `ObjectDisposedException` from `StateHasChanged` on a torn-down renderer. `DebugView.razor` established the pattern for exactly this (`SafeInvokeAsync` + `_disposed` set before teardown — cited in round 1 as exemplary); the new page should match it. `SiteAlarmLiveCacheService.OnPublish` catches and logs synchronous callback throws (:399-410) but cannot observe the async fault.
|
||||
|
||||
### C. Management / Security implementation details
|
||||
|
||||
#### [Low] N8 — `LoginThrottle.Prune` scans (and may sort) the whole map on every failed bind
|
||||
`RecordFailure` → `Prune` enumerates all entries and, when over cap, runs `OrderBy` over the entire dictionary (`LoginThrottle.cs:121, :146-168`). During the exact scenario the throttle exists for — a spray — every failed request pays an O(n) scan (n up to 10,000) plus a possible full sort, on the request path. Harmless at this cap, but the pruning would be better amortized (e.g. every Nth write or on a timer). Counterpoint noted: the cap and opportunistic pruning are exactly what keeps memory bounded, and the constants are sane.
|
||||
|
||||
#### [Low] N9 — `ConfigSecretScrubber.MergeSentinels` matches array elements by index; fragment list is name-based
|
||||
Sentinel restoration inside arrays pairs `incoming[i]` with `stored[i]` (`ConfigSecretScrubber.cs:200-221`): a client that reorders an array of credentialed objects while round-tripping sentinels grafts the *wrong* stored secret into each slot (silent misconfiguration, not disclosure). And detection is name-fragment-based (`password|secret|token|apikey|credential|passphrase`, :18-19) — current config types are fully covered (`OpcUaUserIdentityConfig.Password`/`CertificatePassword`, `MxGatewayEndpointConfig.ApiKey`), but a future field named e.g. `SigningKey` or `Pin` would silently leak; worth a lock-in test enumerating the DataConnections config types' string properties against the fragment list, mirroring the transport round-trip-guard idea.
|
||||
|
||||
---
|
||||
|
||||
## Findings — Dimension 2: Performance
|
||||
## What's genuinely good
|
||||
|
||||
### [Medium] P1 — Every CLI/management/audit HTTP request performs a full LDAP bind
|
||||
`ManagementAuthenticator.AuthenticateAsync` binds against LDAP and re-runs role mapping on **every** request (`ManagementAuthenticator.cs:107-118`); the audit endpoints and `DebugStreamHub.OnConnectedAsync` (`DebugStreamHub.cs:118-137`) do the same. This is the documented stateless design, but there is no negative-result cache, rate limit, or lockout — so (a) a scripted CLI loop hammers the directory once per command, and (b) `POST /management` and `POST /auth/token` (`AuthEndpoints.cs:99-148`) are unauthenticated LDAP-bind oracles usable for password spraying at whatever rate the LDAP server tolerates. A short-TTL bind cache or a per-IP failure throttle would address both.
|
||||
|
||||
### [Medium] P2 — Unbounded list handlers materialize whole tables
|
||||
Several read handlers return `GetAll*` with no paging: `HandleListTemplates` (`ManagementActor.cs:507-511`), `HandleListInstances` (`:718-729`, post-filtered in memory for scoped users), `HandleQueryDeployments` unfiltered branch loads *all* deployment records *and all instances* (`:2031-2060` — the N+1 was fixed, but it is still two full-table loads per query), and `HandleExportBundle` loads every template/script/system/site/instance to resolve names (`:2818-2831`). Fine at current fleet size; a 10k-instance estate will feel it on every CLI `instance list`. The audit surface shows the house already knows the right pattern (keyset paging, `MaxPageSize` 1000 — `AuditEndpoints.cs:59-66`).
|
||||
|
||||
### [Medium] P3 — `ListSecuredWrites` hard-caps at 200 rows with no paging
|
||||
`HandleListSecuredWrites` does `QueryAsync(status, siteId, skip: 0, take: 200)` (`ManagementActor.cs:1280-1286`) and the page loads *everything* then splits pending/history client-side (`SecuredWrites.razor:303-308`). Once >200 rows exist, history silently truncates — an **audit-adjacent** table silently losing visibility of older decisions. No pagination controls exist on the page.
|
||||
|
||||
### [Low] P4 — Alarm Summary re-sorts/filters per render
|
||||
`FilteredRows().ToList()` runs inside the render body (`AlarmSummary.razor:170`) — recomputed on every `StateHasChanged`, including each 15 s tick. Cheap at hundreds of rows; worth memoizing if sites grow. No virtualization on any table in the domain, but page sizes are mostly bounded (Notification Report pages at 50 — `NotificationReport.razor:352`).
|
||||
|
||||
### Positive performance observations
|
||||
- Audit export streams server-side in 1000-row pages and the CLI streams to disk with `ResponseHeadersRead` (`AuditEndpoints.cs:66`, `ManagementHttpClient.cs:209-210`).
|
||||
- The actor never blocks its dispatcher: `HandleEnvelope` role-checks synchronously then `PipeTo`s the async work (`ManagementActor.cs:97-120`), so the single actor instance is not a mailbox bottleneck for I/O-bound commands — commands execute concurrently on the thread pool with per-command DI scopes (`:127-132`). This is the correct pattern and worth calling out as such.
|
||||
- **The authorization table is now a frozen, mechanically-enforced artifact.** `GetRequiredRoles` is a single any-of switch over named static role sets with per-arm rationale comments (`ManagementActor.cs:163-294`), backed by the ~139-entry `RequiredRoleMatrixTests` table that fails on any unlisted command, plus `DispatchCoverageTests` proving every registered command reaches a real handler. A C2- or C4-class regression is now a CI failure, not an audit finding.
|
||||
- **The secured-write lifecycle is defensively complete.** TTL checked before self-approval before CAS (`ManagementActor.cs:1296-1315`), CAS-idempotent multi-node expiry, expiry audited as `SecuredWriteExpire`, protocol re-checked at execute, list gated and paged, submission age surfaced in the approve dialog — and the docs restate both the TTL and the dual-role deployment hazard precisely (`Component-Security.md:143-145`).
|
||||
- **The live alarm cache is a model piece of lifecycle engineering**: reference-counted per-site aggregators with linger-delayed stop and version-checked timers (`SiteAlarmLiveCacheService.cs:160-190`), fail-safe viewer cap returning a no-op handle on a render path (:100-107), race-checked start with self-healing bounded retry (:219-274), callbacks invoked outside the lock with per-viewer fault isolation (:397-410), immutable snapshot swaps, and validated options (`CommunicationOptionsValidator.cs:72-87`). The page's seed-then-stream reconciliation comments (:337-351) show real thought about ordering.
|
||||
- **Secret hygiene is now symmetric**: the `*PublicShape` pattern covers SMTP, SMS, API keys, data connections, external systems, and DB connections, on responses *and* audit afterState, with sentinel-preserving round-trips — and the elision reuses one tested scrubber instead of six bespoke projections.
|
||||
- **`DisableLoginGuard` is the right shape**: fail-fast at the composition root before any service wiring, environment-gated with an explicit double-ack escape hatch, and the error message tells the operator all three ways out (`DisableLoginGuard.cs:22-27`).
|
||||
- **Consistency discipline held under a 739-line actor diff**: paging clamped through one shared helper applied after scope filters, curated `ManagementCommandException` messages everywhere, additive-only contract evolution on all six browse/cert records and the paging params, and docs updated in the same commits (verified for C4/C5/C6/S2/throttle sections).
|
||||
|
||||
---
|
||||
|
||||
## Findings — Dimension 3: Conventions & Correctness
|
||||
## Prioritized Recommendations (new findings only)
|
||||
|
||||
### [Critical] C1 — Production deployment artifact ships with authentication disabled
|
||||
`deploy/wonder-app-vd03/appsettings.Central.json:40` sets `"DisableLogin": true` (and `:38` `"RequireHttpsCookie": false`). Per `Component-Security.md` §Dev Disable-Login Flag there is **no environment guard** — only a startup log warning — and the bypass is honoured by *every* surface: the cookie scheme (`AutoLoginAuthenticationHandler`), `POST /management` (`ManagementAuthenticator.cs:52-76`), the audit REST endpoints, and the debug-stream hub (`DebugStreamHub.cs:82-91`). On this host, every anonymous HTTP request is authenticated as `multi-role` with **all roles system-wide — including Operator and Verifier**, which nullifies the two-person secured-write control on a box that can relay MxGateway device writes. `deploy/` is described by CLAUDE.md as "Production/on-host deployment artifacts". Even if wonder-app-vd03 is a staging box today, an unauthenticated SCADA control surface should not be one config-file copy away from production. Recommendation: add an environment guard (refuse `DisableLogin=true` unless `ASPNETCORE_ENVIRONMENT=Development` or an explicit `IUnderstandThisDisablesAuth` second key), and scrub the deploy artifact.
|
||||
1. **[Medium] Throttle the debug hub's LDAP bind and fix the doc** — route `DebugStreamHub.OnConnectedAsync` through `ManagementAuthenticator` (or replicate the three throttle calls); correct `Component-Security.md:208`. (N1)
|
||||
2. **[Medium] Decide the proxy-IP question for `LoginThrottle`** — trusted-proxy `ForwardedHeaders` (KnownProxies = Traefik) so keys are real client IPs, or document the username-lockout-DoS trade-off deliberately. (N2)
|
||||
3. **[Medium] Enforce (or explicitly exempt) site scope on secured writes** — `EnforceSiteScopeForIdentifier` at submit/approve/reject + scoped list, or amend `Component-Security.md:141`; code and spec currently disagree. (N3)
|
||||
4. **[Medium] One-line staleness guard in `AlarmSummary.RefreshAsync`** — re-check `_selectedSiteId == siteId` before assigning results, mirroring the live path's guard. (N4)
|
||||
5. **[Low] Live-path polish** — poll updates only `_notReporting` while `IsLive` (N5); reset/expose liveness on aggregator death (N6); adopt the `DebugView` disposal-guard pattern for the live callback (N7).
|
||||
6. **[Low] Hygiene** — amortize `LoginThrottle.Prune` (N8); add a lock-in test binding the scrubber's fragment list to the DataConnections config types' string properties (N9).
|
||||
|
||||
### [High] C2 — `deploy artifacts --site-id` is silently ignored; artifacts always deploy fleet-wide
|
||||
`MgmtDeployArtifactsCommand` carries `int? SiteId` (`Commons/Messages/Management/DeploymentCommands.cs:3`) and both CLI commands send it (`CLI/Commands/SiteCommands.cs:209`, `CLI/Commands/DeployCommands.cs:51`; documented in `Component-CLI.md` and the CLI README as `[--site-id <id>]`). But `HandleDeployArtifacts` never reads `cmd.SiteId` — it calls `svc.DeployToAllSitesAsync(user)` unconditionally (`ManagementActor.cs:2006-2013`), and `ArtifactDeploymentService.DeployToAllSitesAsync(string user, CancellationToken)` (`ArtifactDeploymentService.cs:227-229`) has no site parameter. Two consequences: (1) **contract violation** — an operator targeting one site pushes shared scripts/external systems/DB connections to *every* site; (2) **authorization gap** — the command also has no `EnforceSiteScope` call, so a *site-scoped* Deployer can trigger a fleet-wide artifact deployment. The Central UI's per-site "Deploy Artifacts" button presumably uses a different path; the management surface needs `DeployToSiteAsync(siteId)` plus a scope check.
|
||||
## Severity tally — NEW findings (round 2)
|
||||
|
||||
### [High] C3 — Secret projection is inconsistent: connection credentials leak to any authenticated user and into the audit log
|
||||
The actor is scrupulous about SMTP credentials, Twilio auth tokens, and API-key hashes — all projected to credential-free shapes before response *and* audit (`SmtpConfigPublicShape` `ManagementActor.cs:1787-1801`, `SmsConfigPublicShape` `:1841-1853`, `HandleListApiKeys` `:1955-1967`). But three sibling entity families get no such treatment:
|
||||
- **Data connections** — `HandleListDataConnections`/`HandleGetDataConnection` (`:1401-1416`) return the raw entity, whose `PrimaryConfiguration`/`BackupConfiguration` JSON embeds `OpcUaUserIdentityConfig.Password` and `CertificatePassword` in plaintext (`Commons/Types/DataConnections/OpcUaUserIdentityConfig.cs:13,17`). List/Get are **read-only commands with no role gate** (`GetRequiredRole` default `:240-241`), so a Viewer- or Operator-only principal can read every site's OPC UA credentials via one CLI call. Create/Update also audit the full entity as afterState (`:1429, :1445`), persisting the passwords into the audit log readable by `OperationalAudit` roles.
|
||||
- **External systems** — `AuthConfiguration` (API keys / Basic credentials) returned raw to any authenticated user and audited verbatim (`:1587-1624`).
|
||||
- **Database connections** — full `ConnectionString` (SQL credentials) returned raw in List/Get (`:2526-2536`); the audit shape here *was* trimmed to `{Id, Name}` (`:2544, :2557`), which shows the team knows the pattern — it just wasn't applied to the response.
|
||||
Recommendation: apply the established `*PublicShape` pattern (config-with-secrets-elided + `HasCredentials` flag) to all three, and gate Get-with-secrets behind Admin if the UI editor needs round-tripping.
|
||||
|
||||
### [Medium] C4 — Spec drift: browse/verify/cert-trust commands are documented as ManagementActor commands but only exist UI-side
|
||||
`Component-ManagementService.md` §Remote Queries lists `BrowseNodeCommand`, `SearchAddressSpaceCommand`, `VerifyEndpointCommand`, `TrustServerCertCommand`/`RemoveServerCertCommand`/`ListServerCertsCommand` with Design/Admin role gates. The actor's `DispatchCommand` has **no handlers for any of them** — they fall through to `NotSupportedException` (`ManagementActor.cs:423`), and since `ManagementCommandRegistry` auto-registers every `*Command` in the namespace by reflection (`ManagementCommandRegistry.cs:66-79`), a CLI `POST /management {"command":"TrustServerCert"}` deserializes fine and then returns a generic `COMMAND_FAILED` internal error. The real implementations live in Central UI services (`BrowseService.cs`, `EndpointVerificationService.cs`, `CertManagementService.cs`) with hand-rolled `HasClaim` role guards (`BrowseService.cs:48-55, 103-110`). Net effect: the CLI cannot browse address spaces or manage cert trust (a gap for the automation story the CLI exists for), and the component doc misdescribes the enforcement point. Fix the doc or add actor handlers.
|
||||
|
||||
### [Medium] C5 — Role-name comparison asymmetry between UI policies and the actor (acknowledged, still live)
|
||||
ASP.NET `RequireClaim` values are compared **ordinal case-sensitive** (`AuthorizationPolicies.cs:143-169`), while the ManagementActor compares roles **OrdinalIgnoreCase** (`ManagementActor.cs:105, 440` etc.), and `ValidateMappingRole` deliberately preserves the stored row's verbatim casing while validating membership case-insensitively — the comment at `ManagementActor.cs:1904-1911` explicitly defers the asymmetry. Failure scenario: an admin creates a mapping row `deployer` (lowercase) via CLI; the affected users pass every ManagementActor gate but fail every `RequireDeployment` UI policy — a confusing split-brain where CLI works and the UI 403s. Since the write path already validates against `Roles.All`, the cheap fix is to **canonicalize the stored casing** at that single write path.
|
||||
|
||||
### [Medium] C6 — Area-management role gate contradicts both design docs
|
||||
`CreateAreaCommand`/`UpdateAreaCommand`/`DeleteAreaCommand` are gated **Designer** (`ManagementActor.cs:187, 207`), but `Component-ManagementService.md` §Authorization ("Admin role required for: site management, area management…") and `Component-Security.md` §Administrator ("Manage area definitions per site") both assign areas to **Admin**, and Component-CentralUI.md titles the workflow "Area Management (Admin Role)". Either the code or three documents are wrong; per the repo's own propagate-together rule this should be reconciled in one change.
|
||||
|
||||
### [Low] C7 — `HandleQueryEventLogs` passes `InstanceName` into a positional slot commented "InstanceId filter"
|
||||
`ManagementActor.cs:2757-2766` builds `EventLogQueryRequest(..., cmd.InstanceName, /* InstanceId filter */ ...)`. If the site-side filter expects an id, name-based CLI filtering silently matches nothing; if it expects a name, the comment/param name is wrong. Worth a five-minute verification against `SiteEventLogging`.
|
||||
|
||||
### [Low] C8 — Stale CLAUDE.md note on SecuredWrite `SourceNode`
|
||||
CLAUDE.md says "SecuredWrite audit rows currently leave `SourceNode` NULL — a logged follow-up", but `EmitSecuredWriteAuditAsync` now routes through `ICentralAuditWriter`, which stamps `SourceNode` from `INodeIdentityProvider` (documented at `ManagementActor.cs:999-1013`). The follow-up appears done; the project memory should be updated so future sessions don't re-fix it.
|
||||
|
||||
### Positive convention observations
|
||||
- **No third-party Blazor component libraries** — the CentralUI csproj references only Roslyn packages and the in-family `ZB.MOM.WW.Theme` (`ZB.MOM.WW.ScadaBridge.CentralUI.csproj:19-21`); tables, tree views, dialogs, toasts, and the KPI chart are all custom, per the repo rule.
|
||||
- Options pattern honoured throughout; component libraries take no `IConfiguration` (`Security/ServiceCollectionExtensions.cs:26-37`); `SecurityOptionsValidator` fail-fast with a genuinely well-reasoned invariant (refresh threshold < idle window, `SecurityOptions.cs:118-142`).
|
||||
- Error-surface hygiene is exemplary: `MapFault` returns curated `ManagementCommandException` messages verbatim and collapses everything else to a correlation-id-bearing generic message (`ManagementActor.cs:137-158`) — no raw exception text reaches the CLI/UI for unanticipated faults.
|
||||
- The secured-write approve handler is defensive in exactly the right places: no-self-approval **before** the CAS (`:1113-1115`), CAS via `TryMarkApprovedAsync` (`:1121`), MxGateway protocol **re-checked at execute** against submit-time reconfiguration (`:1138-1165`), value-type and decode failures contained so a row is never stuck `Approved` (`:1171-1202`), transport exceptions contained (`:1206-1226`).
|
||||
- `ResolveRolesCommand` deliberately not dispatched with the security rationale documented (`:418-422`) — good trust-boundary thinking.
|
||||
- JWT implementation pins issuer/audience, zero clock skew, disables in/outbound claim mapping symmetrically, enforces ≥256-bit keys at construction (`JwtTokenService.cs:54-78, 124-171`); the cookie path's login and mid-session refresh share `SessionClaimBuilder` so claim shapes cannot drift (`CookieSessionValidator.cs:200-210`, `AuthEndpoints.cs:84-89`); refresh failures fail-open to *current* roles and only idle-timeout rejects (`CookieSessionValidator.cs:132-152`) — matching the documented policy exactly.
|
||||
- LDAP is delegated to the shared hardened `ZB.MOM.WW.Auth.Ldap` library (bind-then-search, escaping, fail-closed — per `Security/ServiceCollectionExtensions.cs:49-59`); no bespoke LDAP string assembly exists in this repo, so injection surface is centralized in the audited library.
|
||||
|
||||
---
|
||||
|
||||
## Findings — Dimension 4: Underdeveloped Areas
|
||||
|
||||
1. **Secured-write lifecycle** — no expiry (S2), no paging (P3), history visible to any authenticated user (`ListSecuredWritesCommand` falls through to the no-role gate, `ManagementActor.cs:238-241`) — tag values on the history table may themselves be process-sensitive. Also nothing prevents the *same person* holding both Operator and Verifier roles via two LDAP groups; the control is only "not the same row's submitter", which is the documented design but worth restating as a deployment-configuration hazard.
|
||||
2. **ManagementService test depth** — 11 test files (`tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/`) against a 3,157-line actor dispatching ~150 commands. `SecuredWriteHandlerTests` exists (good — covers the flagship control), but there is no visible exhaustive matrix test asserting `GetRequiredRole` for every command type — precisely the test that would have caught C2's missing scope check and would freeze the authz table against regressions. Security.Tests is 9 files; CookieSessionValidator is covered.
|
||||
3. **CLI.Tests is not in the slnx** (`ZB.MOM.WW.ScadaBridge.slnx:24` includes only the CLI project) — a documented gotcha, but it remains a standing risk that a green solution run hides never-compiled CLI tests.
|
||||
4. **Deferred follow-ups visible in code/docs**: aggregated live alarm stream for Alarm Summary (poll-only today — Component-CentralUI.md §Alarm Summary "logged as a follow-up"); central persistence of cert trust; live LDAP group re-query mid-session (Component-Security.md "Residual limitation"); Parquet export returns 501 (`AuditEndpoints.cs:202-210`); `audit verify-chain` is a no-op pending the hash chain. All are honestly documented — the gap is that no single tracking list ties them together.
|
||||
5. **Login endpoints lack throttling** — `/auth/login`, `/auth/token`, `/management`, and the debug hub all do per-request LDAP binds with no failure throttle or lockout (P1). `/auth/login` also reflects the LDAP failure kind into the redirect query string (`AuthEndpoints.cs:41-45`); confirm `LdapAuthFailureMessages` does not distinguish "unknown user" from "bad password" (user enumeration).
|
||||
6. **Accessibility/UX debt** — sortable headers are `<th role="button" @onclick>` without keyboard handlers or `aria-sort` (`AlarmSummary.razor:176-179`); status conveyed by color+text badges is fine, but the custom tables have no `scope`/caption markup. Minor for an internal tool, but it is uniform debt across every custom grid.
|
||||
7. **`docker/central-node-a/logs/` committed runtime logs** sit inside the deploy topology folder (noticed while scanning configs) — noise that will eventually leak something.
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Recommendations
|
||||
|
||||
1. **[Critical] Guard `DisableLogin` and scrub `deploy/wonder-app-vd03`** — refuse the flag outside a Development environment (or require a second explicit ack key); remove `DisableLogin: true` from the on-host deploy artifact. (C1)
|
||||
2. **[High] Implement per-site artifact deployment or reject the option** — honour `MgmtDeployArtifactsCommand.SiteId` (add `DeployToSiteAsync`) and add `EnforceSiteScope`; until then, make the handler *fail* when SiteId is supplied rather than silently deploying fleet-wide. (C2)
|
||||
3. **[High] Apply the `*PublicShape` secret-elision pattern to DataConnection, ExternalSystem, and DatabaseConnection reads and audits**; consider role-gating those List/Get commands. (C3)
|
||||
4. **[High] Add a pending-secured-write TTL** that transitions stale rows to the already-reserved `Expired` status, plus paging on the list. (S2, P3)
|
||||
5. **[High] Fix the long-command timeout mismatch** — per-command Ask timeouts (transport/deploy get minutes), configure `CommandTimeout` in shipped configs, and/or make bundle import idempotent on an import id so a 504-then-retry is safe. (S1)
|
||||
6. **[Medium] Add a `GetRequiredRole` matrix test** enumerating every registered command and asserting its gate — freezes the authorization table and catches future C2-style gaps mechanically.
|
||||
7. **[Medium] Canonicalize role casing at the mapping write path** to close the UI-vs-actor case-sensitivity split. (C5)
|
||||
8. **[Medium] Reconcile the area-management role gate** (code says Designer, three docs say Admin) and the browse/cert-command placement (docs say actor, code says UI-only) in one doc+code pass. (C4, C6)
|
||||
9. **[Medium] Add LDAP-bind failure throttling** (per-IP or per-username backoff) in `ManagementAuthenticator` and the auth endpoints. (P1)
|
||||
10. **[Low] Housekeeping** — reentrancy guards on the two poll timers (S3), update the stale SecuredWrite `SourceNode` memory note (C8), verify the `InstanceName`/`InstanceId` event-log filter slot (C7), pull `docker/*/logs` out of the tree, and put CLI.Tests into the slnx or CI explicitly.
|
||||
| Severity | Count | Findings |
|
||||
|---|---|---|
|
||||
| Critical | 0 | — |
|
||||
| High | 0 | — |
|
||||
| Medium | 4 | N1 (hub bind unthrottled + doc drift), N2 (throttle keying behind Traefik), N3 (secured-write site scope), N4 (Alarm Summary stale-site race) |
|
||||
| Low | 5 | N5 (poll-over-live regression), N6 (sticky IsLive), N7 (disposal guard), N8 (Prune cost), N9 (scrubber merge/fragment coverage) |
|
||||
|
||||
Reference in New Issue
Block a user