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
This commit is contained in:
Joseph Doherty
2026-11-09 14:27:14 -05:00
parent 7f479f3a5c
commit 3a31dd2de7
15 changed files with 666 additions and 26 deletions
@@ -274,4 +274,58 @@ public class CentralControlAuthInterceptorTests
CentralControlAuthInterceptor.DefaultGatedPrefixes,
p => p == $"/{method}/");
}
/// <summary>A provider whose cached key is stale until <see cref="Invalidate"/> runs.</summary>
private sealed class RotatingPskProvider(string cached, string rotated) : ISitePskProvider
{
private string _current = cached;
/// <summary>Gets the number of invalidations.</summary>
public int Invalidations { get; private set; }
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(_current);
/// <inheritdoc />
public void Invalidate(string siteId)
{
Invalidations++;
_current = rotated;
}
}
/// <summary>
/// Gitea #96: a site already on its rotated key is accepted even while central's cached copy
/// is stale — the mismatch drops the cache and re-reads once.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleCachedKey_IsReReadOnce_SoARotatedSiteIsAccepted()
{
var provider = new RotatingPskProvider("pre-rotation", "rotated");
var interceptor = new CentralControlAuthInterceptor(
provider, NullLogger<CentralControlAuthInterceptor>.Instance);
var result = await Invoke(interceptor, CreateContext(ControlMethod, SiteA, "Bearer rotated"));
Assert.Equal("ok", result);
Assert.Equal(1, provider.Invalidations);
}
/// <summary>Negative control: a wrong token is still refused after the one re-read, and a right one never invalidates.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongToken_IsStillRefused_AfterTheReRead_AndAMatchNeverInvalidates()
{
var provider = new RotatingPskProvider("k", "k");
var interceptor = new CentralControlAuthInterceptor(
provider, NullLogger<CentralControlAuthInterceptor>.Instance);
Assert.Equal("ok", await Invoke(interceptor, CreateContext(ControlMethod, SiteA, "Bearer k")));
Assert.Equal(0, provider.Invalidations);
var ex = await Assert.ThrowsAsync<RpcException>(
() => Invoke(interceptor, CreateContext(ControlMethod, SiteA, "Bearer wrong")));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
Assert.Equal(1, provider.Invalidations);
}
}