Files
ScadaBridge/docs/plans/2026-11-09-central-cache-staleness-audit.md
T
Joseph Doherty 3a31dd2de7 fix(host): follow an in-place site PSK rotation without a central restart (ScadaBridge#96)
SitePskProvider cached each site's gRPC preshared key forever; only site removal
invalidated it, so rotating SB-GRPC-PSK-{site} broke the central<->site link on
both central nodes until they restarted. The cached key is now dropped when:

- the secret is written through /admin/secrets: central replaces the secrets
  library's ISecretCacheInvalidator with SitePskRotationInvalidator, which also
  publishes SitePskInvalidated on a new DistributedPubSub topic
  (site-psk-invalidation) so the peer central node drops its copy and its
  resolver cache entry too (SitePskInvalidationSubscriber);
- a site rejects a central->site call with PermissionDenied/Unauthenticated:
  WithSiteCredentials wraps every per-site channel's handler in
  PskRejectionRefreshHandler, so the next call re-reads the key (covers
  out-of-process writes such as the secret CLI);
- a site->central call presents a token that does not match the cached key:
  CentralControlAuthInterceptor invalidates and re-reads once before refusing;
- ScadaBridge:Communication:SitePsks reloads.

Invalidation is case-insensitive because SecretName lowercases names. Rotation
runbook (topology-guide), Component-Communication/Host and the #85 cache audit
(D2) updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 14:30:17 -05:00

78 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Central per-node cache staleness audit (Gitea #85)
**Date:** 2026-11-09. **Scope:** every in-process cache on a **central** node that holds
configuration-database (or secret-store) state, and how stale it can be on the **peer** central
node after a change is made through the other one. Same defect class as #83
(`ExternalDefinitionLookupCache`).
## Context that sets the risk
- Traefik routes UI, `/management` and `/api/*` to the **active** central node only
(`/health/active`). An edit and a use on different nodes therefore only happen when someone
talks to a node directly (CLI pointed at a node's own port) or across a **failover**.
- Every TTL below is measured from when the entry was loaded, so an entry a node cached while it
was previously active is still bounded by its TTL after it becomes active again.
- No cache below replicates its invalidation to the peer. Central already has one cross-node
channel: the `DistributedPubSub` topic `CentralCommunicationActor.HealthReportTopic` (peer
health replication). A config-invalidation broadcast, if an owner wants one, should be a second
topic on the same mediator, published from `AfterConfigurationCommit`.
## Inventory
| Cache | Holds | Local invalidation | Peer staleness bound | Verdict |
|---|---|---|---|---|
| `InboundScriptExecutor` compiled handlers (InboundAPI) | Compiled method delegates | `InvalidateMethod` on delete; change bus on import | **None** — every request revision-checks the cached handler against the resolved `ApiMethod.Script` and recompiles on mismatch | OK |
| `ApiMethodCache` (InboundAPI) | `ApiMethod` rows, incl. not-found | Change bus (bundle import); **management create/update/delete — added by #85** | `InboundApi:ApiMethodCacheTtl`, default **5 s** | OK (local gap fixed, see below) |
| `ExternalDefinitionLookupCache` (ESG, central only) | External systems / methods / DB connections (found only) | 9 ManagementActor handlers (#65); bundle import (#83) | `ExternalSystemGateway:DefinitionCacheTtl`, **10 s** on central | Owner decision — #83 |
| `RoleMappingCache` (Security) — the cache at `ManagementActor` `InvalidateRoleMappingCacheAfterCommit` | LDAP group → role map + Deployer site scopes | Mapping / scope-rule commands, post-commit | `Security:RoleMappingCacheSeconds`, default **30 s** (`0` disables) | Owner decision — see D1 |
| `PendingUpdatesCache` (DeploymentManager, #91) | Fleet pending-updates snapshot | Deployment finished on this node | `DeploymentManager:PendingUpdatesCacheTtl`, default **60 s** | OK — display only |
| `CentralCommunicationActor` site-address cache (+ `GrpcSiteTransport` channel pairs) | Site gRPC endpoints | Site create/update/delete → `RefreshSiteAddresses` | Periodic DB reload, **60 s** (hard-coded) | OK — rare edit, self-heals |
| `SiteStreamGrpcClientFactory` clients | Per (site, endpoint) stream clients | Site delete → `RemoveSiteAsync` (local only) | Not stale data: a removed site's client on the peer is reclaimed by idle eviction, `Communication:GrpcClientIdleTtl` (**30 min**) × 1.5 after last use | OK — resource, not correctness |
| `SitePskProvider` (Host) | Per-site gRPC PSK from config / secret store | Site removal; `SB-GRPC-PSK-{site}` write via `/admin/secrets`; site auth rejection; `SitePsks` reload (#96) | Write via `/admin/secrets`: **none** (peer told over `DistributedPubSub` topic `site-psk-invalidation`); other writes: first rejected call after `Secrets:ResolveCacheTtl` | Fixed — #96 (see D2) |
| `DefaultSecretResolver` (shared `ZB.MOM.WW.Secrets`) | Decrypted secret values | Lib's own invalidator (UI writes, sync service) | `Secrets:ResolveCacheTtl`, **30 s** | OK — owned by the shared lib |
| `KpiSnapshotCache` (CentralUI) | KPI query results (operational data) | — | **8 s** | OK — not config |
| `SharedAlarmSummaryService` (CentralUI) | Per-site alarm summary fan-out | — | **12 s** cold-cache window | OK — not config |
| `IDeploymentStatusNotifier` (DeploymentManager) | Push of deployment status to UI circuits | In-process event | Peer UI never sees the push, but the UI is only served by the active node and the pages also poll | OK |
| `ITemplateGraphWatermark` / `FlattenSession` | Template-chain memo keyed by in-process version | Repository bumps | Session lives for one deploy/validate operation; the watermark is process-local by design (peer starts from 0 → extra work, never stale work) | OK |
| `ScriptCompileVerdictCache`, `TrustVerdictCache`, `AuditRegexCache`, `ScriptAnalysisService` `IMemoryCache`, `OAuth2TokenService` | Pure functions of their key (code hash + surface, pattern, credential hash) | — | Not applicable — a changed input is a different key | OK |
| `ZB.MOM.WW.Auth.ApiKeys.SqlServer` (inbound API keys) | — | — | No in-process cache in the shared store (checked in the 0.3.0 source) — revocation is immediate on both nodes | OK |
## Fixed in #85
**`ApiMethodCache` was not invalidated by the management API even locally.** Only the compiled
handler was dropped, so for up to the 5 s TTL on the node that made the change:
- a just-**deleted** method stayed callable — the executor recompiled it from the cached row;
- a just-**created** method answered 404 if a not-found had been cached.
`ManagementActor` now drops the method's cache entry post-commit on create, update and delete
(`InvalidateApiMethodCacheAfterCommit`), the same post-commit pattern as the role-mapping cache.
The peer is still bounded by the 5 s TTL. Tests: `ApiMethodCacheInvalidationTests` (3 of 4 fail
without the fix; the fourth is the negative control).
No peer-staleness case was **clearly** a bug: every config cache with peer staleness is
TTL-bounded (5–60 s), its bound is documented at the cache, and the active-only routing means the
window is only hit via a direct-to-node call or a failover inside the TTL. So no cross-node
broadcast was added.
## Needs an owner decision
- **D1 — `RoleMappingCache` revocation lag on the peer (30 s).** Removing a mapping or narrowing a
Deployer's site scope takes effect at once on the node that made the change and within 30 s on
the other. It is a security-relevant window (revoked access keeps working briefly), but only on
the node the change did *not* go through. Options: accept and document (current state); lower
`RoleMappingCacheSeconds`; or add the `DistributedPubSub` invalidation topic described above.
Recommendation: accept unless the operators' revocation SLA is tighter than 30 s.
- **D2 — `SitePskProvider` caches a successful PSK resolve forever.** Its own remarks say
`Invalidate` covers "a key rotated in place", but nothing calls it on rotation — only on site
removal. Rotating `SB-GRPC-PSK-{site}` in the secret store (with the site pair restarted on the
new key) leaves **both** central nodes dialling with the old key until they restart. Options: make
central restart part of the rotation runbook; give the cache a TTL; or invalidate on a
`PermissionDenied` from the site. Not peer-specific, so it is out of #85's fix scope — file it
separately. **Resolved by #96:** all three — invalidate on the rotation write (and the peer, via
the new `site-psk-invalidation` topic; the first config-invalidation broadcast topic D1/D3 could
join), refresh on a site's auth rejection, and drop the cache on a `SitePsks` reload.
- **D3 — `ExternalDefinitionLookupCache` peer bound (10 s).** Already tracked by #83; the
recommendation there (accept and document) still stands. If D1 gets a broadcast topic, this cache
and `ApiMethodCache` should join it rather than add a second mechanism.