From 42e072ad713fbb6a8947fb1a3f107cd1f6fcc0fa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 25 Feb 2026 11:46:28 -0500 Subject: [PATCH] feat: add auth change propagation to existing connections (Gap 14.2) Add PropagateAuthChanges to ConfigReloader that compares Users, Accounts, and Authorization token between old and new NatsOptions, returning an AuthChangeResult describing which auth fields changed for connection re-evaluation. --- .../Configuration/ConfigReloader.cs | 187 +++++++++++++++++ .../AuthChangePropagationTests.cs | 196 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs index 20052f9..5815a20 100644 --- a/src/NATS.Server/Configuration/ConfigReloader.cs +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -422,6 +422,80 @@ public static class ConfigReloader RejectedChanges: rejectedChanges)); } + /// + /// Compares cluster-topology settings between two instances + /// and returns a describing what changed. + /// Callers use this to reconcile route/gateway/leaf connections after a hot reload. + /// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply. + /// + public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new ClusterConfigChangeResult(); + + // Compare cluster route URLs (ClusterOptions.Routes) + var oldRoutes = oldOpts.Cluster?.Routes ?? []; + var newRoutes = newOpts.Cluster?.Routes ?? []; + + var oldSet = new HashSet(oldRoutes, StringComparer.OrdinalIgnoreCase); + var newSet = new HashSet(newRoutes, StringComparer.OrdinalIgnoreCase); + + if (!oldSet.SetEquals(newSet)) + { + result.RouteUrlsChanged = true; + result.AddedRouteUrls.AddRange(newSet.Except(oldSet, StringComparer.OrdinalIgnoreCase)); + result.RemovedRouteUrls.AddRange(oldSet.Except(newSet, StringComparer.OrdinalIgnoreCase)); + } + + // Compare gateway remote URLs — flatten all URLs across all RemoteGateway entries + var oldGwUrls = FlattenGatewayUrls(oldOpts.Gateway); + var newGwUrls = FlattenGatewayUrls(newOpts.Gateway); + if (!new HashSet(oldGwUrls, StringComparer.OrdinalIgnoreCase) + .SetEquals(new HashSet(newGwUrls, StringComparer.OrdinalIgnoreCase))) + result.GatewayUrlsChanged = true; + + // Compare leaf-node remote URLs — flatten Remotes + all RemoteLeaves entries + var oldLeafUrls = FlattenLeafUrls(oldOpts.LeafNode); + var newLeafUrls = FlattenLeafUrls(newOpts.LeafNode); + if (!new HashSet(oldLeafUrls, StringComparer.OrdinalIgnoreCase) + .SetEquals(new HashSet(newLeafUrls, StringComparer.OrdinalIgnoreCase))) + result.LeafUrlsChanged = true; + + result.HasChanges = result.RouteUrlsChanged || result.GatewayUrlsChanged || result.LeafUrlsChanged; + return result; + } + + /// + /// Compares logging settings between two instances and + /// returns a describing what changed. + /// The effective log level is derived from the Debug/Trace flags: Trace → "Trace", + /// Debug → "Debug", otherwise "Information" — matching Go's precedence. + /// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply. + /// + public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new LoggingChangeResult(); + + // Derive effective level from Debug/Trace flags (Trace takes precedence over Debug) + var oldLevel = EffectiveLogLevel(oldOpts); + var newLevel = EffectiveLogLevel(newOpts); + + if (!string.Equals(oldLevel, newLevel, StringComparison.OrdinalIgnoreCase)) + { + result.LevelChanged = true; + result.OldLevel = oldLevel; + result.NewLevel = newLevel; + } + + // Check individual trace/debug flags + if (oldOpts.Trace != newOpts.Trace) + result.TraceChanged = true; + if (oldOpts.Debug != newOpts.Debug) + result.DebugChanged = true; + + result.HasChanges = result.LevelChanged || result.TraceChanged || result.DebugChanged; + return result; + } + // ─── Comparison helpers ───────────────────────────────────────── private static void CompareAndAdd(List changes, string name, T oldVal, T newVal) @@ -487,6 +561,45 @@ public static class ConfigReloader return !string.Equals(oldJetStream.StoreDir, newJetStream.StoreDir, StringComparison.Ordinal); } + /// + /// Compares auth-related fields between two instances and + /// returns an describing what changed. This drives + /// re-evaluation of existing connections after a config reload. + /// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply. + /// + public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new AuthChangeResult(); + + // Check if users changed + var oldUsers = oldOpts.Users; + var newUsers = newOpts.Users; + if ((oldUsers?.Count ?? 0) != (newUsers?.Count ?? 0)) + { + result.UsersChanged = true; + } + else if (oldUsers is not null && newUsers is not null) + { + var oldUserSet = new HashSet(oldUsers.Select(u => u.Username), StringComparer.Ordinal); + var newUserSet = new HashSet(newUsers.Select(u => u.Username), StringComparer.Ordinal); + if (!oldUserSet.SetEquals(newUserSet)) + result.UsersChanged = true; + } + + // Check if accounts changed + var oldAccountCount = oldOpts.Accounts?.Count ?? 0; + var newAccountCount = newOpts.Accounts?.Count ?? 0; + if (oldAccountCount != newAccountCount) + result.AccountsChanged = true; + + // Check auth token (Authorization is the token string directly on NatsOptions) + if (!string.Equals(oldOpts.Authorization, newOpts.Authorization, StringComparison.Ordinal)) + result.TokenChanged = true; + + result.HasChanges = result.UsersChanged || result.AccountsChanged || result.TokenChanged; + return result; + } + /// /// Reloads TLS certificates from the current options and atomically swaps them /// into the certificate provider. New connections will use the new certificate; @@ -553,3 +666,77 @@ public sealed class ConfigReloadResult /// Result of an in-memory options comparison for reload validation. /// public sealed record ReloadFromOptionsResult(bool Success, List RejectedChanges); + +/// +/// Describes what cluster-topology configuration changed between two +/// instances. Returned by and used to +/// determine which routes, gateways, or leaf connections need to be reconciled after a reload. +/// Reference: golang/nats-server/server/reload.go — clusterOption.Apply / routesOption.Apply. +/// +public sealed class ClusterConfigChangeResult +{ + /// True when any cluster-topology field changed. + public bool HasChanges { get; set; } + + /// True when the set of cluster route URLs changed. + public bool RouteUrlsChanged { get; set; } + + /// True when any gateway remote URL set changed. + public bool GatewayUrlsChanged { get; set; } + + /// True when the leaf-node remote URL set changed. + public bool LeafUrlsChanged { get; set; } + + /// Route URLs present in new config but absent in old config. + public List AddedRouteUrls { get; init; } = []; + + /// Route URLs present in old config but absent in new config. + public List RemovedRouteUrls { get; init; } = []; +} + +/// +/// Describes what logging configuration changed between two +/// instances. Returned by . +/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply. +/// +public sealed class LoggingChangeResult +{ + /// True when any logging field changed. + public bool HasChanges { get; set; } + + /// True when the effective log level string changed. + public bool LevelChanged { get; set; } + + /// The effective log level before the reload. + public string? OldLevel { get; set; } + + /// The effective log level after the reload. + public string? NewLevel { get; set; } + + /// True when the Trace flag changed. + public bool TraceChanged { get; set; } + + /// True when the Debug flag changed. + public bool DebugChanged { get; set; } +} + +/// +/// Describes what auth-related configuration changed between two +/// instances. Returned by and used to +/// determine which existing connections need to be re-evaluated after a reload. +/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply. +/// +public sealed class AuthChangeResult +{ + /// True when any auth field changed. + public bool HasChanges { get; set; } + + /// True when the set of configured users changed. + public bool UsersChanged { get; set; } + + /// True when the set of configured accounts changed. + public bool AccountsChanged { get; set; } + + /// True when the Authorization token string changed. + public bool TokenChanged { get; set; } +} diff --git a/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs b/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs new file mode 100644 index 0000000..6e5aba6 --- /dev/null +++ b/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs @@ -0,0 +1,196 @@ +// Port of Go server/reload.go — auth change propagation tests. +// Reference: golang/nats-server/server/reload.go — authOption.Apply, usersOption.Apply. + +using NATS.Server.Auth; +using NATS.Server.Configuration; +using Shouldly; + +namespace NATS.Server.Tests.Configuration; + +public sealed class AuthChangePropagationTests +{ + // ─── helpers ──────────────────────────────────────────────────── + + private static User MakeUser(string username, string password = "pw") => + new() { Username = username, Password = password }; + + private static NatsOptions BaseOpts() => new(); + + // ─── tests ────────────────────────────────────────────────────── + + [Fact] + public void No_changes_returns_no_changes() + { + // Same empty options → nothing changed. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.HasChanges.ShouldBeFalse(); + result.UsersChanged.ShouldBeFalse(); + result.AccountsChanged.ShouldBeFalse(); + result.TokenChanged.ShouldBeFalse(); + } + + [Fact] + public void User_added_detected() + { + // Adding a user must set UsersChanged. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("alice")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void User_removed_detected() + { + // Removing a user must set UsersChanged. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice")]; + var newOpts = BaseOpts(); + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Account_added_detected() + { + // Adding an account must set AccountsChanged. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Accounts = new Dictionary + { + ["engineering"] = new AccountConfig() + }; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.AccountsChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Token_changed_detected() + { + // Changing the Authorization token must set TokenChanged. + var oldOpts = BaseOpts(); + oldOpts.Authorization = "old-secret-token"; + var newOpts = BaseOpts(); + newOpts.Authorization = "new-secret-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Multiple_changes_all_flagged() + { + // Changing both users and accounts must set both flags. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice")]; + oldOpts.Accounts = new Dictionary + { + ["acct-a"] = new AccountConfig() + }; + + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("alice"), MakeUser("bob")]; + newOpts.Accounts = new Dictionary + { + ["acct-a"] = new AccountConfig(), + ["acct-b"] = new AccountConfig() + }; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.AccountsChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Same_users_different_order_no_change() + { + // Users in a different order with the same names must NOT trigger UsersChanged + // because the comparison is set-based. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice"), MakeUser("bob")]; + + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("bob"), MakeUser("alice")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeFalse(); + result.HasChanges.ShouldBeFalse(); + } + + [Fact] + public void HasChanges_true_when_any_change() + { + // A single changed field (token only) is enough to set HasChanges. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Authorization = "token-xyz"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Empty_to_non_empty_users_detected() + { + // Going from zero users to one user must be detected. + var oldOpts = BaseOpts(); + // No Users assigned — null list. + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("charlie")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void No_auth_to_auth_detected() + { + // Going from null Authorization to a token string must be detected. + var oldOpts = BaseOpts(); + // Authorization is null by default. + var newOpts = BaseOpts(); + newOpts.Authorization = "brand-new-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Same_token_no_change() + { + // The same token value on both sides must NOT flag TokenChanged. + var oldOpts = BaseOpts(); + oldOpts.Authorization = "stable-token"; + var newOpts = BaseOpts(); + newOpts.Authorization = "stable-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeFalse(); + result.HasChanges.ShouldBeFalse(); + } +}