3a31dd2de7
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
90 lines
3.4 KiB
C#
90 lines
3.4 KiB
C#
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));
|
|
}
|
|
}
|