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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +407,18 @@ public class CentralCompositionRootTests : IDisposable
|
||||
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #96: the secrets write path's <see cref="ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator"/>
|
||||
/// must be the PSK-aware wrapper on central, or an in-place site-PSK rotation through
|
||||
/// /admin/secrets never reaches the PSK cache.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_SecretCacheInvalidator_IsTheSitePskRotationInvalidator()
|
||||
{
|
||||
Assert.IsType<SitePskRotationInvalidator>(
|
||||
_factory.Services.GetService<ZB.MOM.WW.Secrets.Abstractions.ISecretCacheInvalidator>());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the shared audit seam resolves to the central direct-write bridge.</summary>
|
||||
[Fact]
|
||||
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
|
||||
|
||||
@@ -7,8 +7,10 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
@@ -267,4 +269,87 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
|
||||
var service = _host.Services.GetRequiredService<EchoSiteStreamService>();
|
||||
Assert.Equal("site-a", service.LastSiteHeader);
|
||||
}
|
||||
// --- Gitea #96: a site's rejection drops central's cached key ---------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Central's real <see cref="SitePskProvider"/> over a store that served a stale key first and
|
||||
/// the rotated (site-accepted) key after: the in-place rotation scenario.
|
||||
/// </summary>
|
||||
private static (SitePskProvider Provider, ISecretResolver Resolver) RotatedProvider()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns("the-pre-rotation-key", SiteKey);
|
||||
var options = Substitute.For<IOptionsMonitor<CommunicationOptions>>();
|
||||
options.CurrentValue.Returns(new CommunicationOptions());
|
||||
return (new SitePskProvider(resolver, options, NullLogger<SitePskProvider>.Instance), resolver);
|
||||
}
|
||||
|
||||
private GrpcChannel Channel(ISitePskProvider provider) =>
|
||||
GrpcChannel.ForAddress(_server.BaseAddress, new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }
|
||||
.WithMessageLimits().WithSiteCredentials(provider, "site-a"));
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that after a site rejects the cached (pre-rotation) key on a unary call, the next
|
||||
/// call re-reads the key and is accepted — no restart needed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectedUnaryCall_DropsTheCachedKey_SoTheNextCallUsesTheRotatedOne()
|
||||
{
|
||||
var (provider, _) = RotatedProvider();
|
||||
using var channel = Channel(provider);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest()));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
|
||||
var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 });
|
||||
Assert.False(reply.MoreAvailable);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the same refresh for a rejected streaming call.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RejectedStreamingCall_DropsTheCachedKey_SoTheNextCallUsesTheRotatedOne()
|
||||
{
|
||||
var (provider, _) = RotatedProvider();
|
||||
using var channel = Channel(provider);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
using (var rejected = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "c1" }))
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await rejected.ResponseStream.MoveNext(CancellationToken.None));
|
||||
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
|
||||
using var accepted = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "c2" });
|
||||
Assert.True(await accepted.ResponseStream.MoveNext(CancellationToken.None));
|
||||
Assert.Equal("c2", accepted.ResponseStream.Current.CorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negative control: accepted calls never drop the cache, so the store is read once no
|
||||
/// matter how many calls succeed.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task AcceptedCalls_KeepTheCachedKey()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>()).Returns(SiteKey);
|
||||
var options = Substitute.For<IOptionsMonitor<CommunicationOptions>>();
|
||||
options.CurrentValue.Returns(new CommunicationOptions());
|
||||
var provider = new SitePskProvider(resolver, options, NullLogger<SitePskProvider>.Instance);
|
||||
using var channel = Channel(provider);
|
||||
var client = new SiteStreamService.SiteStreamServiceClient(channel);
|
||||
|
||||
await client.PullAuditEventsAsync(new PullAuditEventsRequest());
|
||||
await client.PullAuditEventsAsync(new PullAuditEventsRequest());
|
||||
await client.PullAuditEventsAsync(new PullAuditEventsRequest());
|
||||
|
||||
await resolver.Received(1).GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,4 +204,61 @@ public class SitePskProviderTests
|
||||
Assert.Equal("key-a", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
Assert.Equal("key-b", await provider.GetAsync("site-b", CancellationToken.None));
|
||||
}
|
||||
/// <summary>
|
||||
/// Verifies that a reload of the configured <c>SitePsks</c> map drops cached keys, so a key
|
||||
/// rotated in configuration is used without a restart (Gitea #96).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ReloadedSitePsks_DropsTheCache_SoAKeyRotatedInConfigIsUsed()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
var options = new CommunicationOptions();
|
||||
options.SitePsks["site-a"] = "old";
|
||||
Action<CommunicationOptions, string?>? listener = null;
|
||||
var monitor = Substitute.For<IOptionsMonitor<CommunicationOptions>>();
|
||||
monitor.CurrentValue.Returns(_ => options);
|
||||
monitor.OnChange(Arg.Do<Action<CommunicationOptions, string?>>(l => listener = l));
|
||||
using var provider = new SitePskProvider(resolver, monitor, NullLogger<SitePskProvider>.Instance);
|
||||
|
||||
Assert.Equal("old", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
|
||||
options = new CommunicationOptions();
|
||||
options.SitePsks["site-a"] = "rotated";
|
||||
Assert.NotNull(listener);
|
||||
listener!(options, null);
|
||||
|
||||
Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Verifies the secret-name to site-id mapping the rotation hook relies on.</summary>
|
||||
/// <param name="name">A secret name.</param>
|
||||
/// <param name="expected">The expected site id, or null.</param>
|
||||
[Theory]
|
||||
[InlineData("SB-GRPC-PSK-site-a", "site-a")]
|
||||
[InlineData("SB-GRPC-PSK-", null)]
|
||||
[InlineData("OTHER-SECRET", null)]
|
||||
[InlineData("sb-grpc-psk-site-a", "site-a")]
|
||||
public void SiteIdFromSecretName_MapsOnlyPskNames(string name, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SitePskProvider.SiteIdFromSecretName(name));
|
||||
}
|
||||
/// <summary>
|
||||
/// Verifies that invalidation ignores case: the rotation hook derives the site id from a
|
||||
/// lowercased secret name, but the site may be cached under its mixed-case identifier.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invalidate_IgnoresCase_BecauseSecretNamesAreLowercased()
|
||||
{
|
||||
var resolver = Substitute.For<ISecretResolver>();
|
||||
resolver.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
||||
.Returns("old", "rotated");
|
||||
var provider = Create(resolver);
|
||||
|
||||
Assert.Equal("old", await provider.GetAsync("Site-A", CancellationToken.None));
|
||||
provider.Invalidate("site-a");
|
||||
|
||||
Assert.Equal("rotated", await provider.GetAsync("Site-A", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Tools.PublishSubscribe;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #96: an in-place rotation of <c>SB-GRPC-PSK-{siteId}</c> written through the secrets
|
||||
/// write path must drop central's cached key on this node and on the peer central node.
|
||||
/// </summary>
|
||||
public class SitePskRotationInvalidatorTests : TestKit
|
||||
{
|
||||
private readonly ISecretCacheInvalidator _inner = Substitute.For<ISecretCacheInvalidator>();
|
||||
private readonly ISitePskProvider _psk = Substitute.For<ISitePskProvider>();
|
||||
|
||||
private SitePskRotationInvalidator Create() =>
|
||||
new(_inner, _psk, NullLogger<SitePskRotationInvalidator>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that writing a site's PSK secret evicts the resolver cache, drops the site's
|
||||
/// cached key and publishes the invalidation to the peer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WritingAPskSecret_DropsTheCachedKey_AndTellsThePeer()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
|
||||
// The secrets library lowercases names, so the site id arrives lowercased too.
|
||||
invalidator.Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
|
||||
_inner.Received(1).Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
_psk.Received(1).Invalidate("site-a");
|
||||
var publish = mediator.ExpectMsg<Publish>();
|
||||
Assert.Equal(SitePskRotationInvalidator.Topic, publish.Topic);
|
||||
Assert.Equal(new SitePskInvalidated("site-a"), publish.Message);
|
||||
}
|
||||
|
||||
/// <summary>Negative control: any other secret only evicts the resolver cache.</summary>
|
||||
[Fact]
|
||||
public void WritingAnyOtherSecret_OnlyEvictsTheResolverCache()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
|
||||
invalidator.Invalidate(new SecretName("SMTP-PASSWORD"));
|
||||
|
||||
_inner.Received(1).Invalidate(new SecretName("SMTP-PASSWORD"));
|
||||
_psk.DidNotReceiveWithAnyArgs().Invalidate(default!);
|
||||
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that before the actor system exists an invalidation still applies locally.</summary>
|
||||
[Fact]
|
||||
public void WithoutAMediator_TheLocalInvalidationStillApplies()
|
||||
{
|
||||
Create().Invalidate(new SecretName("SB-GRPC-PSK-site-a"));
|
||||
|
||||
_psk.Received(1).Invalidate("site-a");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the subscriber applies a peer's invalidation to this node's resolver and PSK
|
||||
/// caches, without re-publishing it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Subscriber_AppliesAPeerInvalidation_WithoutRepublishing()
|
||||
{
|
||||
var mediator = CreateTestProbe();
|
||||
var invalidator = Create();
|
||||
invalidator.SetMediator(mediator.Ref);
|
||||
var subscriber = Sys.ActorOf(Props.Create(() => new SitePskInvalidationSubscriber(invalidator)));
|
||||
|
||||
subscriber.Tell(new SitePskInvalidated("site-b"));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
_psk.Received(1).Invalidate("site-b");
|
||||
_inner.Received(1).Invalidate(new SecretName("SB-GRPC-PSK-site-b"));
|
||||
});
|
||||
mediator.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user