Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/SitePskRotationInvalidator.cs
T
Joseph Doherty 153609ef8a feat(central): one central cache-invalidation topic; role-mapping and external-definition caches follow peer edits (ScadaBridge#100 item 3, ScadaBridge#83)
The #96 site-psk-invalidation topic was the first cross-node invalidation broadcast.
It is generalised into ONE central DistributedPubSub topic, central-cache-invalidation,
carrying typed messages (Commons ICentralCacheInvalidation: SitePskInvalidated,
RoleMappingsInvalidated, ExternalDefinitionsInvalidated, InboundApiMethodInvalidated)
in an envelope stamped with the publisher's process-unique origin id.

- Host: CentralCacheInvalidationBus (ICentralCacheInvalidationPublisher, no-op until
  the mediator is set, never throws) and CentralCacheInvalidationSubscriber, which
  skips its own node's envelopes and hands the peer's to every registered
  ICentralCacheInvalidationHandler (a failing handler does not stop the rest).
  Replaces SitePskInvalidationSubscriber.
- Migration of #96: SitePskRotationInvalidator publishes through the bus and is the
  PSK handler; SitePskInvalidated keeps its type name and is also published bare on
  site-psk-invalidation, which the subscriber still listens to, so a mixed-version
  central pair keeps exchanging PSK invalidations. Remove after one release.
- Audit D1: RoleMappingCache is a handler; ManagementActor's post-commit role-mapping
  invalidation also publishes RoleMappingsInvalidated, so a revocation is immediate on
  the peer instead of after RoleMappingCacheSeconds.
- Audit D3 / #83 (peer half): ExternalDefinitionLookupCache is a handler; the 9
  ManagementActor handlers and the Transport bundle importer publish
  ExternalDefinitionsInvalidated after commit. (#83's local import half was already in.)

TTLs remain the fallback for a lost broadcast or a direct SQL edit. Tests: bus and
subscriber unit tests (origin filter, legacy topic), peer-node tests through the real
caches in ManagementService.Tests, importer publish, composition root, and a real
two-node cluster from the production HOCON (CentralCacheInvalidationTwoNodeTests)
proving every message type crosses with the production serializer. Docs: Host,
Communication, Security, ManagementService, ESG, and the #85 cache audit.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 16:04:28 -05:00

144 lines
6.3 KiB
C#

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.Secrets;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Central's <see cref="ISecretCacheInvalidator"/>: forwards every eviction to the secret
/// resolver's cache as before, and when the evicted secret is a site's gRPC preshared key
/// (<c>SB-GRPC-PSK-{siteId}</c>) also drops that site's entry from <see cref="ISitePskProvider"/>
/// and tells the peer central node to do the same (Gitea #96).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the invalidator seam.</b> Every <c>ZB.MOM.WW.Secrets</c> write path — the
/// <c>/admin/secrets</c> editor and delete modal — calls <see cref="ISecretCacheInvalidator"/>
/// right after a rotate or delete. Hooking that seam catches an in-place PSK rotation at the
/// moment it is written, without ScadaBridge owning a write path of its own.
/// </para>
/// <para>
/// <b>Why the peer needs telling.</b> Both central nodes cache the key in their own
/// <see cref="SitePskProvider"/> (and their own resolver cache), and the write only ran on one of
/// them. The peer is told with a <see cref="SitePskInvalidated"/> on the central cache-invalidation
/// topic (<see cref="CentralCacheInvalidationBus"/>, ScadaBridge#100 item 3, which generalised
/// the #96 <c>site-psk-invalidation</c> topic); this class is also the peer's handler for it.
/// </para>
/// <para>
/// Writes made outside this process (the <c>secret</c> CLI, a direct SQL write) never reach this
/// seam; the site's rejection of the stale key covers those (see
/// <c>ControlPlaneCredentials.WithSiteCredentials</c>).
/// </para>
/// </remarks>
public sealed class SitePskRotationInvalidator : ISecretCacheInvalidator, ICentralCacheInvalidationHandler
{
private readonly ISecretCacheInvalidator _inner;
private readonly ISitePskProvider _pskProvider;
private readonly ICentralCacheInvalidationPublisher? _publisher;
private readonly ILogger<SitePskRotationInvalidator> _logger;
/// <summary>Creates the invalidator.</summary>
/// <param name="inner">The library's own invalidator (the secret resolver's cache).</param>
/// <param name="pskProvider">Central's per-site PSK cache.</param>
/// <param name="publisher">The central cache-invalidation publisher; null keeps evictions local.</param>
/// <param name="logger">Logger.</param>
public SitePskRotationInvalidator(
ISecretCacheInvalidator inner,
ISitePskProvider pskProvider,
ICentralCacheInvalidationPublisher? publisher,
ILogger<SitePskRotationInvalidator> logger)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(pskProvider);
ArgumentNullException.ThrowIfNull(logger);
_inner = inner;
_pskProvider = pskProvider;
_publisher = publisher;
_logger = logger;
}
/// <inheritdoc />
public void Invalidate(SecretName name)
{
_inner.Invalidate(name);
var siteId = SitePskProvider.SiteIdFromSecretName(name.Value);
if (siteId is null)
{
return;
}
_pskProvider.Invalidate(siteId);
_logger.LogInformation(
"gRPC preshared key secret '{SecretName}' was written; dropped the cached key for site "
+ "{SiteId} and notified the peer central node.", name.Value, siteId);
_publisher?.Publish(new SitePskInvalidated(siteId));
}
/// <inheritdoc />
public void Apply(ICentralCacheInvalidation invalidation)
{
if (invalidation is SitePskInvalidated psk)
{
ApplyPeerInvalidation(psk.SiteId);
}
}
/// <summary>
/// Applies an invalidation received from the peer central node: evicts the resolver's cached
/// secret and the PSK cache entry. Never re-publishes.
/// </summary>
/// <param name="siteId">The site whose key was rotated.</param>
public void ApplyPeerInvalidation(string siteId)
{
if (string.IsNullOrWhiteSpace(siteId))
{
return;
}
_inner.Invalidate(new SecretName(SitePskProvider.SecretNameFor(siteId)));
_pskProvider.Invalidate(siteId);
}
}
/// <summary>
/// Peer message: the gRPC preshared key of <paramref name="SiteId"/> was rotated on a central node.
/// Rides the central cache-invalidation topic; kept in this namespace because the #96
/// <c>site-psk-invalidation</c> topic carries it bare by this type name (see
/// <see cref="CentralCacheInvalidationBus"/>).
/// </summary>
/// <param name="SiteId">The site whose key changed.</param>
public sealed record SitePskInvalidated(string SiteId) : ICentralCacheInvalidation;
/// <summary>DI wiring for <see cref="SitePskRotationInvalidator"/>.</summary>
public static class SitePskRotationServiceCollectionExtensions
{
/// <summary>
/// Replaces the secrets library's <see cref="ISecretCacheInvalidator"/> with
/// <see cref="SitePskRotationInvalidator"/> wrapping the library's resolver, and registers it
/// as a central cache-invalidation handler. Central only; call after
/// <c>AddScadaBridgeSecrets</c>, after <see cref="ISitePskProvider"/> is registered and after
/// <see cref="CentralCacheInvalidationServiceCollectionExtensions.AddCentralCacheInvalidation"/>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same collection.</returns>
public static IServiceCollection AddSitePskRotationInvalidation(this IServiceCollection services)
{
services.TryAddSingleton(sp => new SitePskRotationInvalidator(
sp.GetRequiredService<DefaultSecretResolver>(),
sp.GetRequiredService<ISitePskProvider>(),
sp.GetService<ICentralCacheInvalidationPublisher>(),
sp.GetRequiredService<ILogger<SitePskRotationInvalidator>>()));
services.Replace(ServiceDescriptor.Singleton<ISecretCacheInvalidator>(
sp => sp.GetRequiredService<SitePskRotationInvalidator>()));
services.AddSingleton<ICentralCacheInvalidationHandler>(
sp => sp.GetRequiredService<SitePskRotationInvalidator>());
return services;
}
}